diff --git a/.github/workflows/golden-e2e-experiment.yml b/.github/workflows/golden-e2e-experiment.yml index 1f0a2b42755..040996489d1 100644 --- a/.github/workflows/golden-e2e-experiment.yml +++ b/.github/workflows/golden-e2e-experiment.yml @@ -74,6 +74,7 @@ jobs: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:agent-tui-golden + xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:tab-bar-agent-launch-golden xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden - name: Run golden E2E tests on macOS @@ -87,6 +88,7 @@ jobs: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:agent-tui-golden + env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:tab-bar-agent-launch-golden env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden - name: Run golden E2E tests on Windows @@ -97,6 +99,7 @@ jobs: $env:ORCA_E2E_FORWARD_APP_LOGS = '1' pnpm run --if-present test:e2e:workspace-session-golden pnpm run --if-present test:e2e:windows-fresh-startup-golden + pnpm run --if-present test:e2e:tab-bar-agent-launch-golden if (Test-Path tests/e2e/golden-fresh-profile-terminal.spec.ts) { pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts } diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 01724348ba6..f44e65ddb47 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -642,6 +642,7 @@ jobs: - name: Test Windows-specific boundaries run: >- pnpm exec vitest run --config config/vitest.config.ts + config/scripts/rebuild-native-deps.test.mjs src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts src/shared/child-process/windows-command-line.win32.test.ts @@ -658,6 +659,8 @@ jobs: src/main/runtime/repo-worktree-admin-fingerprint.test.ts src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts src/shared/secure-file-fsync-flags.test.ts + src/main/ipc/pty-codex-account-attribution.test.ts + src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts # Why the :parallel variant: identical to build:release except the three # electron-vite targets overlap instead of running back to back. The Linux package @@ -673,6 +676,9 @@ jobs: ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1' run: pnpm exec electron-builder --config config/electron-builder.config.cjs --dir + - name: Smoke packaged Windows PTY native capability + run: pnpm run smoke:windows-pty-native-capability -- --exe=dist/win-unpacked/Orca.exe + - name: Smoke packaged CLI run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/win-unpacked @@ -707,38 +713,9 @@ jobs: BASE="${{ github.event.pull_request.base.sha }}" HEAD="${{ github.event.pull_request.head.sha }}" CHANGED="$(git diff --name-only --diff-filter=AMCR --merge-base "$BASE" "$HEAD")" - TEST_FILES="$(printf '%s\n' "$CHANGED" | grep -E '^tests/e2e/.*\.spec\.ts$' || true)" - if printf '%s\n' "$CHANGED" | grep -Eq '^src/(main/ephemeral-vm-(runtime-(service|provisioning-persistence)|failed-start-cleanup)|shared/(ephemeral-vm-runtime-(store|feature-store|rollback-projection|runtimes)|ephemeral-vm-recipes|orca-yaml-hook-types))\.ts$'; then - TEST_FILES="$(printf '%s\n%s\n' "$TEST_FILES" 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts' | sort -u)" - fi - # Why: the Docker-SSH specs only ever ran when someone edited a spec, so four - # pane-restore regressions shipped from SSH source edits that touched no test. - # Unit tests are excluded because they prove the same source without Docker. - # ssh-startup-exec-readiness.spec.ts is listed to route e2e.yml's dedicated - # ssh-docker-watcher-isolation lane, which filters it out of changed-e2e. - if printf '%s\n' "$CHANGED" | grep -Ev '\.test\.tsx?$' | grep -Eq '^(src/main/ssh/|src/main/providers/ssh-|src/main/ipc/(ssh-|pty)|src/relay/|src/renderer/src/components/terminal-pane/(pty-|ssh-|remote-runtime-|terminal-parked-pty))'; then - TEST_FILES="$(printf '%s\n' "$TEST_FILES" \ - 'tests/e2e/pty-input-write-queue-ssh.spec.ts' \ - 'tests/e2e/ssh-cold-activation-restore.spec.ts' \ - 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts' \ - 'tests/e2e/ssh-startup-exec-readiness.spec.ts' \ - 'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts' | sort -u)" - fi - # Why: same lesson as the SSH specs above. The IME e2e suite asserts real pty bytes and - # real overlay geometry, but only ran when someone edited a spec, so every IME fix in the - # 1.4.18x window shipped without it - including one whose diagnosis hardware later refuted. - # config/patches is listed because the terminal's input handling lives in the xterm patch. - if printf '%s\n' "$CHANGED" | grep -Ev '\.test\.tsx?$' | grep -Eq '^(config/patches/|src/renderer/src/components/terminal-pane/(terminal-ime-|use-terminal-pane-lifecycle|xterm-bypass-policy|terminal-option-shortcut-policy))'; then - TEST_FILES="$(printf '%s\n' "$TEST_FILES" \ - 'tests/e2e/terminal-cjk-ime-committed-text.spec.ts' \ - 'tests/e2e/terminal-hangul-wrap-boundary-bytes.spec.ts' \ - 'tests/e2e/terminal-ime-exact-byte.spec.ts' \ - 'tests/e2e/terminal-korean-composing-chord-order.spec.ts' \ - 'tests/e2e/terminal-korean-endofrow-preedit-cell-span.spec.ts' \ - 'tests/e2e/terminal-korean-midline-preedit-occlusion.spec.ts' \ - 'tests/e2e/terminal-korean-preedit-visibility.spec.ts' | sort -u)" - fi - TEST_FILES_JSON="$(printf '%s\n' "$TEST_FILES" | jq --raw-input --slurp --compact-output 'split("\n") | map(select(length > 0))')" + # Source routes are executable contracts so a test can prove exact + # authorities, exclusions, and sentinels without evaluating workflow shell. + TEST_FILES_JSON="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs)" echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT" if [ "$TEST_FILES_JSON" != '[]' ]; then echo "should_run=true" >> "$GITHUB_OUTPUT" diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 33e31aa2605..bf7d5b1382c 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -14,6 +14,9 @@ const { const { verifyLinuxGlibcFloor } = require('./scripts/verify-linux-glibc-floor.cjs') const { writeMacBuildCompatibility } = require('./scripts/mac-build-compatibility.cjs') const { verifyPackagedPluginResources } = require('./scripts/verify-packaged-plugin-resources.cjs') +const { + verifyPackagedNodePtyJobOwnership +} = require('./scripts/verify-packaged-node-pty-job-ownership.cjs') const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.cjs') // Why: dev-channel builds must carry the *release* identity — same bundle id, @@ -266,6 +269,13 @@ module.exports = { const archEnumByNodeArch = { ia32: 0, x64: 1, armv7l: 2, arm64: 3 } const hostArchEnum = archEnumByNodeArch[process.arch] const canExecuteTargetArch = context.arch === hostArchEnum || context.arch === 4 + if (context.electronPlatformName === 'win32') { + if (process.platform === 'win32' && canExecuteTargetArch) { + verifyPackagedNodePtyJobOwnership(resourcesDir) + } else { + console.log('[verify-packaged-node-pty] skipped cross-platform or cross-arch package') + } + } verifySkillsCliRuntime(join(resourcesDir, 'app.asar.unpacked', 'out'), resourcesDir, { executeCommands: canExecuteTargetArch }) diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 4ba1f6f5164..55d40410c20 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -3,7 +3,6 @@ # split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines"). # Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only) inline src/cli/handlers/automations.ts -inline src/cli/handlers/orchestration.ts inline src/cli/help.ts inline src/main/agent-hooks/server.ts inline src/main/automations/external-manager.ts @@ -33,19 +32,15 @@ inline src/main/github/pr-refresh-coordinator.ts inline src/main/github/project-view.ts inline src/main/github/project-view/mutations.ts inline src/main/github/work-item-details.ts -inline src/main/gitlab/client.ts inline src/main/index.ts inline src/main/ipc/filesystem-watcher.ts inline src/main/ipc/filesystem.ts inline src/main/ipc/github.ts -inline src/main/ipc/pty.ts inline src/main/ipc/repos.ts inline src/main/ipc/ssh.ts inline src/main/ipc/worktree-remote.ts -inline src/main/jira/issues.ts inline src/main/keybindings/keybinding-file.ts inline src/main/linear/issues.ts -inline src/main/linear/projects.ts inline src/main/memory/collector.ts inline src/main/opencode/hook-service.ts inline src/main/persistence/loading-store/store.ts @@ -64,7 +59,6 @@ inline src/main/runtime/orca-runtime-git.ts inline src/main/runtime/orca-runtime.test.ts inline src/main/runtime/orca-runtime.ts inline src/main/runtime/rpc/methods/orchestration.ts -inline src/main/runtime/rpc/methods/terminal.ts inline src/main/runtime/runtime-rpc.ts inline src/main/source-control/hosted-review-creation.ts inline src/main/speech/model-manager.ts @@ -147,7 +141,6 @@ inline src/renderer/src/runtime/web-session-tabs-sync.ts inline src/renderer/src/store/slices/agent-status.ts inline src/renderer/src/store/slices/browser.ts inline src/renderer/src/store/slices/diffComments.ts -inline src/renderer/src/store/slices/github.ts inline src/renderer/src/store/slices/hosted-review.ts inline src/renderer/src/store/slices/jira.ts inline src/renderer/src/store/slices/linear.ts diff --git a/config/patches/node-pty@1.1.0.patch b/config/patches/node-pty@1.1.0.patch index 3daad4fde68..c5c35191f35 100644 --- a/config/patches/node-pty@1.1.0.patch +++ b/config/patches/node-pty@1.1.0.patch @@ -2,7 +2,12 @@ diff --git a/binding.gyp b/binding.gyp index 5f63978b07ab50aaf7523219a2170ec737a6b5db..bbd9e06136e8922f40b5779e35d4fc835f1479ab 100644 --- a/binding.gyp +++ b/binding.gyp -@@ -5,9 +5,6 @@ +@@ -1,13 +1,10 @@ + { + 'target_defaults': { + 'dependencies': [ +- " [], + terminateJob: () => true, + assignCurrentProcessToJob: () => true + } +} +const PREBUILD = { + dir: 'prebuilds/win32-x64/', + module: { + startProcess: () => {}, + connect: () => {}, + resize: () => {}, + clear: () => {}, + kill: () => {} + } +} + +describe('assertNodePtyJobOwnership', () => { + it('keeps node-addon-api project paths absolute during Windows source builds', () => { + expect(NODE_PTY_PATCH).toContain( + `+ " { + expect(NODE_PTY_PATCH).toContain("- 'target_name': 'conpty_console_list'") + expect(NODE_PTY_PATCH).toContain("- 'target_name': 'pty'") + }) + + it('rejects the prebuild that shipped without the job exports', () => { + expect(() => + assertNodePtyJobOwnership({ platform: 'win32', nativeName: 'conpty', native: PREBUILD }) + ).toThrow(/listJobProcessIds, terminateJob, assignCurrentProcessToJob/) + }) + + it('names where the bad native came from, so the fix is obvious', () => { + expect(() => + assertNodePtyJobOwnership({ platform: 'win32', nativeName: 'conpty', native: PREBUILD }) + ).toThrow(/prebuilds\/win32-x64/) + }) + + it('accepts a source build carrying the patch', () => { + expect(() => + assertNodePtyJobOwnership({ platform: 'win32', nativeName: 'conpty', native: PATCHED }) + ).not.toThrow() + }) + + it.each([ + ['non-Windows hosts', { platform: 'darwin', nativeName: 'pty' }], + ['the pre-ConPTY winpty backend', { platform: 'win32', nativeName: 'pty' }] + ])('stays out of the way on %s', (_case, spec) => { + expect(() => assertNodePtyJobOwnership({ ...spec, native: PREBUILD })).not.toThrow() + }) +}) diff --git a/config/scripts/ensure-native-runtime.mjs b/config/scripts/ensure-native-runtime.mjs index 941049472fb..b570ac0e7ae 100644 --- a/config/scripts/ensure-native-runtime.mjs +++ b/config/scripts/ensure-native-runtime.mjs @@ -7,6 +7,7 @@ import { release } from 'node:os' import { basename, resolve } from 'node:path' const require = createRequire(import.meta.url) +const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs') const scriptPath = import.meta.filename const projectDir = resolve(import.meta.dirname, '../..') const runtime = readRuntimeArg() @@ -277,6 +278,7 @@ function loadNodePtyNativeModule() { // terminal is created, so require('node-pty') alone can miss ABI mismatches. const native = loadNativeModule(nativeName) assertNodePtyWindowsConptyRuntime(native?.dir) + assertNodePtyJobOwnership({ nativeName, native }) if (requiresPatchedNodePtySourceBuild() && !isNodePtyReleaseBuildDir(native?.dir)) { throw new Error( `node-pty resolved to ${native.dir}; expected build/Release so Orca's node-pty patch is active` diff --git a/config/scripts/ensure-native-runtime.test.mjs b/config/scripts/ensure-native-runtime.test.mjs index 0bc8e5ae077..d1ea104b863 100644 --- a/config/scripts/ensure-native-runtime.test.mjs +++ b/config/scripts/ensure-native-runtime.test.mjs @@ -14,6 +14,9 @@ import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' const sourceScriptPath = fileURLToPath(new URL('./ensure-native-runtime.mjs', import.meta.url)) +const sourceNodePtyJobOwnershipPath = fileURLToPath( + new URL('./node-pty-job-ownership.cjs', import.meta.url) +) describe('ensure-native-runtime', () => { it('rechecks Node native modules in fresh child processes after rebuilding', () => { @@ -156,6 +159,10 @@ describe('ensure-native-runtime', () => { function mkTempProject() { const projectDir = mkdtempSync(join(tmpdir(), 'orca-native-runtime-')) mkdirSync(join(projectDir, 'config', 'scripts'), { recursive: true }) + copyFileSync( + sourceNodePtyJobOwnershipPath, + join(projectDir, 'config', 'scripts', 'node-pty-job-ownership.cjs') + ) return projectDir } diff --git a/config/scripts/node-pty-job-ownership.cjs b/config/scripts/node-pty-job-ownership.cjs new file mode 100644 index 00000000000..35884c1414a --- /dev/null +++ b/config/scripts/node-pty-job-ownership.cjs @@ -0,0 +1,29 @@ +'use strict' + +const NODE_PTY_JOB_EXPORTS = [ + 'listJobProcessIds', + 'terminateJob', + 'assignCurrentProcessToJob' +] + +function assertNodePtyJobOwnership({ nativeName, native, platform = process.platform }) { + if (platform !== 'win32' || nativeName !== 'conpty') { + return + } + const exported = native?.module ?? native + const missing = NODE_PTY_JOB_EXPORTS.filter((name) => typeof exported?.[name] !== 'function') + if (missing.length === 0) { + return + } + throw new Error( + [ + `node-pty's conpty native is missing ${missing.join(', ')}.`, + `Resolved from: ${native?.dir ?? 'unknown'}`, + 'That build cannot own a PTY tree, so terminatePtyJob degrades to "unavailable"', + 'and pane teardown falls back to guessing by PID ancestry.', + 'Rebuild node-pty from source so config/patches/node-pty@1.1.0.patch applies.' + ].join(' ') + ) +} + +module.exports = { assertNodePtyJobOwnership } diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index 92faae907a1..877b9b07c08 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -1,11 +1,17 @@ import { existsSync, readFileSync } from 'node:fs' import { join, resolve } from 'node:path' +import { parse as parseJsonc } from 'jsonc-parser' import { describe, expect, it } from 'vitest' -import { parse } from 'yaml' +import { parse as parseYaml } from 'yaml' +import { PR_E2E_SOURCE_ROUTES, selectPrE2eSpecs } from './pr-e2e-source-routing.mjs' const projectDir = resolve(import.meta.dirname, '../..') -const prWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')) -const e2eWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8')) +const prWorkflow = parseYaml(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')) +const e2eWorkflow = parseYaml(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8')) +const reliabilityManifest = parseJsonc( + readFileSync(join(projectDir, 'config/reliability-gates.jsonc'), 'utf8') +) +const playwrightConfig = readFileSync(join(projectDir, 'tests/playwright.config.ts'), 'utf8') const sshDockerRunner = readFileSync( join(projectDir, 'config/scripts/run-ssh-docker-terminal-parking-e2e.mjs'), 'utf8' @@ -69,8 +75,15 @@ describe('PR E2E gate contract', () => { it('selects modified Playwright specs without running deleted tests', () => { expect(filterStep.run).toContain('--diff-filter=AMCR') - expect(filterStep.run).toContain("'^tests/e2e/.*\\.spec\\.ts$'") + expect(filterStep.run).toContain('config/scripts/pr-e2e-source-routing.mjs') expect(filterStep.run).not.toContain('tests/playwright\\.') + expect( + selectPrE2eSpecs([ + 'tests/e2e/active-view-restart-restore.spec.ts', + 'tests/e2e/deleted.spec.ts.bak', + 'tests/e2e/global-teardown.unit.test.ts' + ]) + ).toEqual(['tests/e2e/active-view-restart-restore.spec.ts']) }) it('uses one runner for changed specs and keeps full runs sharded', () => { @@ -89,6 +102,7 @@ describe('PR E2E gate contract', () => { expect(changedRun.run).toContain( 'pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 "${E2E_PROJECT_ARGS[@]}"' ) + expect(playwrightConfig).toContain('retries: 0') }) it('keeps startup-exec live parity in the isolated SSH lane', () => { @@ -121,7 +135,7 @@ describe('PR E2E gate contract', () => { ] for (const file of dedicatedWorkflows) { - const workflow = parse(readFileSync(join(projectDir, '.github/workflows', file), 'utf8')) + const workflow = parseYaml(readFileSync(join(projectDir, '.github/workflows', file), 'utf8')) expect(workflow.on.pull_request, file).toBeUndefined() } }) @@ -138,12 +152,14 @@ describe('PR E2E gate contract', () => { const sshSourceAuthorities = [ 'src/main/ssh/', 'src/main/providers/ssh-', - 'src/main/ipc/(ssh-|pty)', + 'src/main/ipc/pty', 'src/relay/', - 'src/renderer/src/components/terminal-pane/(pty-|ssh-|remote-runtime-|terminal-parked-pty)' + 'src/renderer/src/components/terminal-pane/remote-runtime-' ] for (const authority of sshSourceAuthorities) { - expect(filterStep.run).toContain(authority) + expect(selectPrE2eSpecs([`${authority}routing.ts`])).toContain( + 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts' + ) } const mappedSpecs = [ @@ -154,7 +170,7 @@ describe('PR E2E gate contract', () => { 'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts' ] for (const spec of mappedSpecs) { - expect(filterStep.run).toContain(`'${spec}'`) + expect(selectPrE2eSpecs(['src/main/ssh/connection.ts'])).toContain(spec) expect(existsSync(join(projectDir, spec)), spec).toBe(true) // Why: a spec that stops reading the flag would silently run without Docker. if (spec !== 'tests/e2e/ssh-startup-exec-readiness.spec.ts') { @@ -162,8 +178,7 @@ describe('PR E2E gate contract', () => { } } - // Why: unit tests next to the mapped sources prove the same code without Docker. - expect(filterStep.run).toContain("grep -Ev '\\.test\\.tsx?$'") + expect(selectPrE2eSpecs(['src/main/ssh/connection.test.ts'])).toEqual([]) // Why: startup readiness is filtered out of changed-e2e, so listing it is only // meaningful while it still routes the dedicated Docker lane. @@ -183,6 +198,99 @@ describe('PR E2E gate contract', () => { expect(rollbackStep.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"') expect(rollbackStep.run).toContain('src/shared/ephemeral-vm-recipes.ts') expect(rollbackStep.run).toContain('src/shared/orca-yaml-hook-types.ts') - expect(filterStep.run).toContain('ephemeral-vm-recipes|orca-yaml-hook-types') + expect(selectPrE2eSpecs(['src/shared/ephemeral-vm-recipes.ts'])).toEqual([ + 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts' + ]) + }) + + it('routes P0 sentinels from their causal sources', () => { + const cases = [ + [ + 'src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.tsx', + 'tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts' + ], + ['src/main/runtime/orca-runtime-files.ts', 'tests/e2e/paired-quick-open-large-tree.spec.ts'], + [ + 'src/renderer/src/runtime/sync-runtime-graph.ts', + 'tests/e2e/host-parked-pane-remote-viewer.spec.ts' + ], + [ + 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts', + 'tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts' + ], + [ + 'src/renderer/src/components/terminal-pane/remote-pane-layout-push.ts', + 'tests/e2e/paired-remote-pane-layout-retry.spec.ts' + ] + ] + for (const [source, spec] of cases) { + expect(selectPrE2eSpecs([source]), source).toEqual([spec]) + expect(selectPrE2eSpecs([source.replace(/\.tsx?$/, '.test.ts')]), source).toEqual([]) + expect(existsSync(join(projectDir, spec)), spec).toBe(true) + } + const quickCommandSpec = 'tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts' + for (const source of [ + 'src/renderer/src/components/terminal-pane/pty-connection.ts', + 'src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts', + 'src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts', + 'src/renderer/src/components/terminal-pane/pty-connection/pane-pty-visibility-bind.ts', + 'src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts' + ]) { + expect(selectPrE2eSpecs([source]), source).toContain(quickCommandSpec) + expect(selectPrE2eSpecs([source.replace(/\.ts$/, '.test.ts')]), source).not.toContain( + quickCommandSpec + ) + } + for (const source of [ + 'src/main/ipc/rg-availability.ts', + 'src/shared/ripgrep-process-availability.ts' + ]) { + expect(selectPrE2eSpecs([source]), source).toEqual([ + 'tests/e2e/paired-quick-open-large-tree.spec.ts' + ]) + } + expect( + selectPrE2eSpecs([ + 'src/main/runtime/orca-runtime-files.ts', + 'tests/e2e/paired-quick-open-large-tree.spec.ts' + ]) + ).toEqual(['tests/e2e/paired-quick-open-large-tree.spec.ts']) + expect(selectPrE2eSpecs(['src/renderer/src/components/FileExplorer.tsx'])).toEqual([]) + expect( + selectPrE2eSpecs([ + 'src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts' + ]) + ).toContain('tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts') + expect( + selectPrE2eSpecs([ + 'src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-test-harness.ts' + ]) + ).not.toContain('tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts') + expect(selectPrE2eSpecs(['src/main/ipc/pty.ts'])).not.toContain( + 'tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts' + ) + }) + + it('keeps source-routed sentinels registered to their reliability gates', () => { + const routedGateIds = [ + 'terminal-startup.quick-command-pre-bind-recovery', + 'quick-open.paired-host-path-search', + 'terminal-session.host-cold-park-stream-continuity', + 'terminal-provider.ssh-remote-reattach-contract', + 'terminal-session.remote-pane-layout-retry' + ] + for (const gateId of routedGateIds) { + const route = PR_E2E_SOURCE_ROUTES.find((candidate) => candidate.id === gateId) + const gate = reliabilityManifest.gates.find((candidate) => candidate.id === gateId) + expect(route, gateId).toBeDefined() + expect(gate, gateId).toMatchObject({ maturity: 'experimental', protection: 'partial' }) + for (const spec of route.specs) { + expect(gate.testFiles, gateId).toContain(spec) + expect( + gate.commands.some((command) => command.includes(spec)), + gateId + ).toBe(true) + } + } }) }) diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs new file mode 100644 index 00000000000..4e429913c28 --- /dev/null +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -0,0 +1,117 @@ +import process from 'node:process' +import { pathToFileURL } from 'node:url' + +const isProductSource = (file) => !/\.test\.tsx?$/.test(file) + +export const PR_E2E_SOURCE_ROUTES = [ + { + id: 'ephemeral-vm-runtime.rollback-readable-sidecar', + specs: ['tests/e2e/ephemeral-vm-provisioned-root.spec.ts'], + matches: (file) => + /^(?:src\/main\/ephemeral-vm-(?:runtime-(?:service|provisioning-persistence)|failed-start-cleanup)|src\/shared\/(?:ephemeral-vm-runtime-(?:store|feature-store|rollback-projection|runtimes)|ephemeral-vm-recipes|orca-yaml-hook-types))\.ts$/.test( + file + ) + }, + { + id: 'ssh-terminal-source', + specs: [ + 'tests/e2e/pty-input-write-queue-ssh.spec.ts', + 'tests/e2e/ssh-cold-activation-restore.spec.ts', + 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts', + 'tests/e2e/ssh-startup-exec-readiness.spec.ts', + 'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts' + ], + matches: (file) => + isProductSource(file) && + /^(?:src\/main\/ssh\/|src\/main\/providers\/ssh-|src\/main\/ipc\/(?:ssh-|pty)|src\/relay\/|src\/renderer\/src\/components\/terminal-pane\/(?:pty-|ssh-|remote-runtime-|terminal-parked-pty))/.test( + file + ) + }, + { + id: 'terminal-input.ime-and-synthetic-forwarding', + specs: [ + 'tests/e2e/terminal-cjk-ime-committed-text.spec.ts', + 'tests/e2e/terminal-hangul-wrap-boundary-bytes.spec.ts', + 'tests/e2e/terminal-ime-exact-byte.spec.ts', + 'tests/e2e/terminal-korean-composing-chord-order.spec.ts', + 'tests/e2e/terminal-korean-endofrow-preedit-cell-span.spec.ts', + 'tests/e2e/terminal-korean-midline-preedit-occlusion.spec.ts', + 'tests/e2e/terminal-korean-preedit-visibility.spec.ts' + ], + matches: (file) => + isProductSource(file) && + /^(?:config\/patches\/|src\/renderer\/src\/components\/terminal-pane\/(?:terminal-ime-|use-terminal-pane-lifecycle|xterm-bypass-policy|terminal-option-shortcut-policy))/.test( + file + ) + }, + { + id: 'terminal-startup.quick-command-pre-bind-recovery', + specs: ['tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts'], + matches: (file) => + isProductSource(file) && + /^(?:src\/renderer\/src\/components\/tab-bar\/TabBarQuickCommandsMenu\.tsx|src\/renderer\/src\/hooks\/use-terminal-quick-command-hosts\.ts|src\/renderer\/src\/components\/terminal-pane\/(?:pty-connection|pty-transport|terminal-pty-pre-spawn-e2e-barrier)\.ts|src\/renderer\/src\/components\/terminal-pane\/pty-connection\/(?:connect-pane-pty|fresh-spawn-start|pane-pty-visibility-bind|pty-input-recovery)\.ts|src\/renderer\/src\/components\/terminal-pane\/(?:TerminalPane|use-terminal-pane-lifecycle)\.tsx?|src\/renderer\/src\/store\/slices\/terminals\.ts)$/.test( + file + ) + }, + { + id: 'quick-open.paired-host-path-search', + specs: ['tests/e2e/paired-quick-open-large-tree.spec.ts'], + matches: (file) => + isProductSource(file) && + /^(?:src\/main\/ipc\/(?:filesystem-(?:list-files|search-file-paths)|rg-availability)\.ts|src\/main\/providers\/(?:filesystem-provider-contract|ssh-filesystem-provider(?:-capabilities)?)\.ts|src\/main\/runtime\/(?:orca-runtime-files|rpc\/methods\/files)\.ts|src\/relay\/(?:fs-handler(?:-install-rg|-list-files|-ripgrep-fallback)?|fs-list-files-fallback-chain)\.ts|src\/renderer\/src\/(?:components\/(?:QuickOpen|quick-open-file-list|quick-open-search)\.tsx?|runtime\/(?:runtime-file-client|runtime-legacy-quick-open-inventory)\.ts)|src\/shared\/(?:quick-open-(?:install-rg|path-search|transport-budget)|ripgrep-process-availability)\.ts)$/.test( + file + ) + }, + { + id: 'terminal-session.host-cold-park-stream-continuity', + specs: ['tests/e2e/host-parked-pane-remote-viewer.spec.ts'], + matches: (file) => + isProductSource(file) && + /^(?:src\/renderer\/src\/components\/terminal-pane\/(?:terminal-hidden-view-parking|terminal-tab-park-candidates|terminal-tab-activation-order|terminal-parked-pty-watcher|terminal-parked-tab-watchers|terminal-parked-watcher-registry)\.ts|src\/renderer\/src\/runtime\/sync-runtime-graph\.ts)$/.test( + file + ) + }, + { + id: 'terminal-provider.ssh-remote-reattach-contract', + specs: ['tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts'], + matches: (file) => + isProductSource(file) && + !file.endsWith('-test-harness.ts') && + /^(?:src\/renderer\/src\/components\/terminal-pane\/remote-runtime-pty-transport(?:-[a-z0-9-]+)?\.ts|src\/renderer\/src\/runtime\/remote-runtime-terminal-multiplexer\.ts)$/.test( + file + ) + }, + { + id: 'terminal-session.remote-pane-layout-retry', + specs: ['tests/e2e/paired-remote-pane-layout-retry.spec.ts'], + matches: (file) => + isProductSource(file) && + /^(?:src\/renderer\/src\/components\/terminal-pane\/(?:remote-pane-layout-push|TerminalPane)\.tsx?|src\/renderer\/src\/lib\/terminal-layout-equality\.ts|src\/renderer\/src\/runtime\/web-session-tabs-sync\.ts|src\/renderer\/src\/store\/slices\/terminals\.ts)$/.test( + file + ) + } +] + +export function selectPrE2eSpecs(changedPaths, reportRoute = () => undefined) { + const specs = new Set(changedPaths.filter((file) => /^tests\/e2e\/.*\.spec\.ts$/.test(file))) + for (const route of PR_E2E_SOURCE_ROUTES) { + const matchedFiles = changedPaths.filter(route.matches) + if (matchedFiles.length === 0) { + continue + } + route.specs.forEach((spec) => specs.add(spec)) + reportRoute(`[pr-e2e] ${route.id}: ${route.specs.join(', ')}`) + } + return [...specs].sort((left, right) => left.localeCompare(right)) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + let input = '' + process.stdin.setEncoding('utf8') + for await (const chunk of process.stdin) { + input += chunk + } + const changedPaths = input.split(/\r?\n/).filter(Boolean) + const specs = selectPrE2eSpecs(changedPaths, (message) => console.error(message)) + process.stdout.write(`${JSON.stringify(specs)}\n`) +} diff --git a/config/scripts/rebuild-native-deps.mjs b/config/scripts/rebuild-native-deps.mjs index 6d776abd1ca..78e25d3634c 100644 --- a/config/scripts/rebuild-native-deps.mjs +++ b/config/scripts/rebuild-native-deps.mjs @@ -475,9 +475,14 @@ function loadNativeModule(moduleName) { } if (moduleName === 'node-pty') { projectRequire('node-pty') + const { assertNodePtyJobOwnership } = projectRequire( + './config/scripts/node-pty-job-ownership.cjs' + ) const { loadNativeModule } = projectRequire('node-pty/lib/utils') - const native = loadNativeModule(getNodePtyNativeModuleName()) + const nativeName = getNodePtyNativeModuleName() + const native = loadNativeModule(nativeName) assertNodePtyWindowsConptyRuntime(native.dir) + assertNodePtyJobOwnership({ nativeName, native }) if (requirePatchedNodePtySourceBuild && !isNodePtyReleaseBuildDir(native.dir)) { throw new Error( 'node-pty resolved to ' + diff --git a/config/scripts/rebuild-native-deps.test.mjs b/config/scripts/rebuild-native-deps.test.mjs index 0ab28de7a65..b158ee57f21 100644 --- a/config/scripts/rebuild-native-deps.test.mjs +++ b/config/scripts/rebuild-native-deps.test.mjs @@ -18,6 +18,9 @@ const sourceScriptPath = fileURLToPath(new URL('./rebuild-native-deps.mjs', impo const sourceInstallScriptPath = fileURLToPath( new URL('./install-electron-package-binary.mjs', import.meta.url) ) +const sourceNodePtyJobOwnershipPath = fileURLToPath( + new URL('./node-pty-job-ownership.cjs', import.meta.url) +) describe('rebuild-native-deps Electron install fallback', () => { it('continues non-strict postinstall when Electron retry download fails', () => { @@ -135,6 +138,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => { writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) writeFakeLoadableNodePty(projectDir, { nativeDir: '../build/Release/' }) writeFakeWindowsRegistry(projectDir) + writeFakeWindowsProcessTree(projectDir) writeFakeNodePtyConptyPayload(projectDir, process.arch) const result = runRebuildScript(projectDir, { @@ -189,6 +193,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => { writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) writeFakeLoadableNodePty(projectDir) + writeFakeWindowsProcessTree(projectDir) writeFakeNodePtyConptyPayload(projectDir, process.arch) const result = runRebuildScript(projectDir, { @@ -207,6 +212,36 @@ describe('rebuild-native-deps patched node-pty rebuild', () => { } ) + it.skipIf(process.platform !== 'win32')( + 'rebuilds a loadable ConPTY native that lacks Orca job ownership', + () => { + const projectDir = mkTempProject() + + try { + const rebuildLogPath = join(projectDir, 'electron-rebuild.log') + writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) + writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) + writeFakeLoadableNodePty(projectDir, { ownsPtyJob: false }) + writeFakeWindowsRegistry(projectDir) + writeFakeWindowsProcessTree(projectDir) + + const result = runRebuildScript(projectDir, { + ORCA_REBUILD_TEST_LOG: rebuildLogPath, + npm_config_platform: 'win32', + npm_config_arch: process.arch + }) + + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain('Rebuilding failed native modules: node-pty') + expect(result.stdout).toContain('missing listJobProcessIds') + const rebuildCall = JSON.parse(readFileSync(rebuildLogPath, 'utf8').trim()) + expect(rebuildCall.onlyModules).toEqual(['node-pty']) + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + } + ) + it.skipIf(process.platform === 'win32')( 'rebuilds when Electron can load node-pty but patched build artifacts are missing', () => { @@ -305,6 +340,10 @@ function mkTempProject() { sourceInstallScriptPath, join(projectDir, 'config', 'scripts', 'install-electron-package-binary.mjs') ) + copyFileSync( + sourceNodePtyJobOwnershipPath, + join(projectDir, 'config', 'scripts', 'node-pty-job-ownership.cjs') + ) return projectDir } @@ -495,7 +534,10 @@ function writeFakeNodePtyConptyPayload(projectDir, arch) { writeFileSync(join(sourceDir, 'OpenConsole.exe'), `OpenConsole.exe ${arch}`) } -function writeFakeLoadableNodePty(projectDir, { nativeDir = 'prebuilds/pty' } = {}) { +function writeFakeLoadableNodePty( + projectDir, + { nativeDir = 'prebuilds/pty', ownsPtyJob = true } = {} +) { const nodePtyDir = join(projectDir, 'node_modules', 'node-pty') mkdirSync(join(nodePtyDir, 'lib'), { recursive: true }) writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n') @@ -503,7 +545,19 @@ function writeFakeLoadableNodePty(projectDir, { nativeDir = 'prebuilds/pty' } = join(nodePtyDir, 'lib', 'utils.js'), ` exports.loadNativeModule = function loadNativeModule(nativeName) { - return { dir: ${JSON.stringify(nativeDir)}, module: { nativeName } } + return { + dir: ${JSON.stringify(nativeDir)}, + module: { + nativeName, + ...(${JSON.stringify(ownsPtyJob)} + ? { + listJobProcessIds() {}, + terminateJob() {}, + assignCurrentProcessToJob() {} + } + : {}) + } + } } ` ) @@ -518,6 +572,12 @@ function writeFakeWindowsRegistry(projectDir) { ) } +function writeFakeWindowsProcessTree(projectDir) { + const processTreeDir = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree') + mkdirSync(processTreeDir, { recursive: true }) + writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n') +} + function writeNodePtyPatchFile(projectDir) { mkdirSync(join(projectDir, 'config', 'patches'), { recursive: true }) writeFileSync(join(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch'), 'patch marker\n') diff --git a/config/scripts/verify-packaged-node-pty-job-ownership.cjs b/config/scripts/verify-packaged-node-pty-job-ownership.cjs new file mode 100644 index 00000000000..5ba21cee8ec --- /dev/null +++ b/config/scripts/verify-packaged-node-pty-job-ownership.cjs @@ -0,0 +1,25 @@ +const { createRequire } = require('node:module') +const { join } = require('node:path') +const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs') + +function loadPackagedConpty(resourcesDir) { + const packagedRequire = createRequire(join(resourcesDir, 'package.json')) + const { loadNativeModule } = packagedRequire('./node_modules/node-pty/lib/utils') + return loadNativeModule('conpty') +} + +function verifyPackagedNodePtyJobOwnership(resourcesDir, options = {}) { + const platform = options.platform ?? process.platform + if (platform !== 'win32') { + return + } + + const native = (options.loadNative ?? loadPackagedConpty)(resourcesDir) + assertNodePtyJobOwnership({ platform, nativeName: 'conpty', native }) + if (!native.dir.replace(/\\/g, '/').includes('build/Release/')) { + throw new Error(`Packaged node-pty resolved to ${native.dir}; expected patched build/Release`) + } + console.log('[verify-packaged-node-pty] OK — packaged ConPTY owns process trees') +} + +module.exports = { verifyPackagedNodePtyJobOwnership } diff --git a/config/scripts/verify-packaged-node-pty-job-ownership.test.mjs b/config/scripts/verify-packaged-node-pty-job-ownership.test.mjs new file mode 100644 index 00000000000..26a33cfbbd6 --- /dev/null +++ b/config/scripts/verify-packaged-node-pty-job-ownership.test.mjs @@ -0,0 +1,51 @@ +import { createRequire } from 'node:module' +import { describe, expect, it, vi } from 'vitest' + +const require = createRequire(import.meta.url) +const { + verifyPackagedNodePtyJobOwnership +} = require('./verify-packaged-node-pty-job-ownership.cjs') + +const PATCHED = { + dir: '../build/Release/', + module: { + listJobProcessIds: () => [], + terminateJob: () => true, + assignCurrentProcessToJob: () => true + } +} + +describe('verifyPackagedNodePtyJobOwnership', () => { + it('accepts the packaged patched ConPTY binding', () => { + expect(() => + verifyPackagedNodePtyJobOwnership('resources', { + platform: 'win32', + loadNative: () => PATCHED + }) + ).not.toThrow() + }) + + it('rejects a packaged upstream prebuild', () => { + expect(() => + verifyPackagedNodePtyJobOwnership('resources', { + platform: 'win32', + loadNative: () => ({ dir: '../prebuilds/win32-x64/', module: {} }) + }) + ).toThrow(/missing listJobProcessIds, terminateJob, assignCurrentProcessToJob/) + }) + + it('requires the patched source-build directory', () => { + expect(() => + verifyPackagedNodePtyJobOwnership('resources', { + platform: 'win32', + loadNative: () => ({ ...PATCHED, dir: '../prebuilds/win32-x64/' }) + }) + ).toThrow(/expected patched build\/Release/) + }) + + it('does not load Windows natives for other targets', () => { + const loadNative = vi.fn() + verifyPackagedNodePtyJobOwnership('resources', { platform: 'linux', loadNative }) + expect(loadNative).not.toHaveBeenCalled() + }) +}) diff --git a/config/scripts/windows-pty-native-capability-workflow.test.mjs b/config/scripts/windows-pty-native-capability-workflow.test.mjs new file mode 100644 index 00000000000..9a6b8b285cf --- /dev/null +++ b/config/scripts/windows-pty-native-capability-workflow.test.mjs @@ -0,0 +1,70 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' + +const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) +const packageJson = JSON.parse(readFileSync('package.json', 'utf8')) + +describe('packaged Windows PTY native capability routing', () => { + it('runs the packaged executable immediately after the unpacked app is built', () => { + const job = workflow.jobs.package_windows + const packageIndex = job.steps.findIndex((step) => step.name === 'Package unpacked app') + const smokeIndex = job.steps.findIndex( + (step) => step.name === 'Smoke packaged Windows PTY native capability' + ) + const smoke = job.steps[smokeIndex] + + expect(job['runs-on']).toBe('windows-2022') + expect(smokeIndex).toBe(packageIndex + 1) + expect(smoke.run).toBe( + 'pnpm run smoke:windows-pty-native-capability -- --exe=dist/win-unpacked/Orca.exe' + ) + expect(smoke.if).toBeUndefined() + expect(smoke['continue-on-error']).toBeUndefined() + expect(packageJson.scripts['smoke:windows-pty-native-capability']).toBe( + 'node tests/tools/windows-pty-native-capability-smoke/run.mjs' + ) + }) + + it('keeps patched source rebuild, release build, runtime reuse, and aggregate routing intact', () => { + const job = workflow.jobs.package_windows + const sourceRebuild = job.steps.find( + (step) => step.name === 'Rebuild node-pty from patched source' + ) + const build = job.steps.find((step) => step.name === 'Build package inputs') + const prepare = job.steps.find((step) => step.name === 'Prepare Electron native runtime') + const packageStep = job.steps.find((step) => step.name === 'Package unpacked app') + const verify = workflow.jobs.verify.steps.find( + (step) => step.name === 'Require successful checks' + ) + + expect(sourceRebuild.env.npm_config_build_from_source).toBe('true') + expect(sourceRebuild.run).toBe('pnpm rebuild node-pty') + expect(build.run).toBe('pnpm run build:release:parallel') + expect(prepare.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron') + expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1') + expect(workflow.jobs.verify.needs).toContain('package_windows') + expect(verify.env.PACKAGE_WINDOWS).toBe('${{ needs.package_windows.result }}') + expect(verify.run).toContain('"$PACKAGE_WINDOWS"') + }) + + it('keeps the native probe event-based, scoped, and runnable in packaged Node mode', () => { + const driver = readFileSync('tests/tools/windows-pty-native-capability-smoke/run.mjs', 'utf8') + const probe = readFileSync( + 'tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.cjs', + 'utf8' + ) + const source = `${driver}\n${probe}` + + expect(driver).toContain("ELECTRON_RUN_AS_NODE: '1'") + expect(source).not.toMatch(/\b(?:sleep|tasklist|taskkill)\b/i) + expect(source).not.toContain('maxRetries') + expect(source).not.toContain('retryDelay') + expect(source).not.toContain("from 'node:child_process'") + expect(source).not.toContain("require('node:child_process')") + expect(probe).toContain("'System32', 'wscript.exe'") + expect(probe).toContain('real-orca-detached-launcher.vbs') + expect(probe).not.toMatch(/cmd\.exe|start "" \/b/i) + expect(probe).toContain('native.terminateJob(target._pty, target.pid)') + }) +}) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index a9566939925..1c352a1f7e5 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 27m + + downloads: 28m @@ -15,7 +15,7 @@ downloads downloads - 27m - 27m + 28m + 28m diff --git a/docs/reference/git-compatibility.md b/docs/reference/git-compatibility.md index b740982febb..3004b8888ab 100644 --- a/docs/reference/git-compatibility.md +++ b/docs/reference/git-compatibility.md @@ -33,13 +33,14 @@ authority. ## Current Capabilities -| Capability | Preferred behavior | Compatibility behavior | -| ----------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------- | -| `worktree-list-z` | NUL-delimited worktree paths with `prunable` marks | Line-block parser for Git before `worktree list -z` (2.36); the `prunable`/`locked` annotations still parse on Git 2.31–2.35, and a path-existence probe restores `prunable` detection for Git before 2.31 | -| `rev-parse-path-format` | Absolute repo metadata paths | Resolve legacy relative output against the scanned repo | -| `for-each-ref-exclude` | Exclude remote HEAD before the output limit | Request extra refs, then filter remote HEAD in Orca | -| `merge-tree-write-tree` | Derive real-merge conflicts and no-op tree proofs | Omit the conflict summary and keep conservative branch cleanup behavior before Git 2.38 | -| `merge-tree-merge-base` | Supply the already-resolved merge base | Use the older two-commit `merge-tree --write-tree` form | +| Capability | Preferred behavior | Compatibility behavior | +| --------------------------- | ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `fetch-no-write-fetch-head` | Fetch a private rebase ref without changing worktree-local `FETCH_HEAD` | Serialize all Orca fetch/pull operations per worktree Git directory before Git 2.29 | +| `worktree-list-z` | NUL-delimited worktree paths with `prunable` marks | Line-block parser for Git before `worktree list -z` (2.36); the `prunable`/`locked` annotations still parse on Git 2.31–2.35, and a path-existence probe restores `prunable` detection for Git before 2.31 | +| `rev-parse-path-format` | Absolute repo metadata paths | Resolve legacy relative output against the scanned repo | +| `for-each-ref-exclude` | Exclude remote HEAD before the output limit | Request extra refs, then filter remote HEAD in Orca | +| `merge-tree-write-tree` | Derive real-merge conflicts and no-op tree proofs | Omit the conflict summary and keep conservative branch cleanup behavior before Git 2.38 | +| `merge-tree-merge-base` | Supply the already-resolved merge base | Use the older two-commit `merge-tree --write-tree` form | ## Why Not `simple-git` @@ -54,7 +55,7 @@ capability problem. ## CI Contract PR checks run the capability contract against real Git 2.25.5, 2.38.1, and -2.49.1 binaries. This spans the core-workflow baseline, the transitional +2.49.1 binaries. This spans the pre-2.29 serialized `FETCH_HEAD` fallback, the transitional `merge-tree --write-tree` behavior before `--merge-base`, and current Git. Keep the unit tests alongside that matrix. They cover concurrent probes, diff --git a/mobile/src/components/AgentSpinner.tsx b/mobile/src/components/AgentSpinner.tsx index 673bbff049e..a5e81ec07a9 100644 --- a/mobile/src/components/AgentSpinner.tsx +++ b/mobile/src/components/AgentSpinner.tsx @@ -1,5 +1,7 @@ import { useEffect, useRef } from 'react' +import { Radio } from 'lucide-react-native' import { Animated, Easing, StyleSheet, View } from 'react-native' +import type { AgentWorkingMode } from '../../../src/shared/agent-status-types' type WorktreeStatus = 'working' | 'active' | 'permission' | 'done' | 'inactive' @@ -17,11 +19,18 @@ const STATUS_COLORS: Record = { inactive: 'rgba(115,115,115,0.4)' } -export function AgentSpinner({ status }: { status: WorktreeStatus }) { +export function AgentSpinner({ + status, + workingMode +}: { + status: WorktreeStatus + workingMode?: AgentWorkingMode +}) { const spinValue = useRef(new Animated.Value(0)).current + const monitoring = status === 'working' && workingMode === 'monitoring' useEffect(() => { - if (status === 'working') { + if (status === 'working' && !monitoring) { const animation = Animated.loop( Animated.timing(spinValue, { toValue: 1, @@ -34,10 +43,18 @@ export function AgentSpinner({ status }: { status: WorktreeStatus }) { return () => animation.stop() } spinValue.setValue(0) - }, [status, spinValue]) + }, [monitoring, status, spinValue]) const color = STATUS_COLORS[status] ?? STATUS_COLORS.inactive + if (monitoring) { + return ( + + + + ) + } + if (status === 'working') { const rotate = spinValue.interpolate({ inputRange: [0, 1], diff --git a/mobile/src/components/AgentStateDot.tsx b/mobile/src/components/AgentStateDot.tsx index 916525ef366..4d596b7e094 100644 --- a/mobile/src/components/AgentStateDot.tsx +++ b/mobile/src/components/AgentStateDot.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef } from 'react' +import { Radio } from 'lucide-react-native' import { Animated, Easing, StyleSheet, View } from 'react-native' import type { AgentDotState } from '../worktree/agent-row-display' @@ -7,13 +8,14 @@ import type { AgentDotState } from '../worktree/agent-row-display' // emerald for 'done', red for blocked/waiting/interrupted (attention), neutral // for idle. Distinct from the worktree-level AgentSpinner, which collapses the // agent vocabulary into the 5-state rollup the sidebar dot uses. -const DOT_COLORS: Record, string> = { +const DOT_COLORS: Record, string> = { done: '#10b981', blocked: '#ef4444', waiting: '#ef4444', interrupted: '#ef4444', idle: 'rgba(115,115,115,0.4)' } +const WORKING_COLOR = '#eab308' export function AgentStateDot({ state }: { state: AgentDotState }) { const spinValue = useRef(new Animated.Value(0)).current @@ -44,6 +46,14 @@ export function AgentStateDot({ state }: { state: AgentDotState }) { ) } + if (state === 'monitoring') { + return ( + + + + ) + } + return ( @@ -59,7 +69,7 @@ const styles = StyleSheet.create({ height: 6, borderRadius: 3, borderWidth: 1.5, - borderColor: '#eab308', + borderColor: WORKING_COLOR, borderTopColor: 'transparent' } }) diff --git a/mobile/src/components/WorktreeListRow.test.ts b/mobile/src/components/WorktreeListRow.test.ts index d0825412636..01e0128cdc1 100644 --- a/mobile/src/components/WorktreeListRow.test.ts +++ b/mobile/src/components/WorktreeListRow.test.ts @@ -199,7 +199,7 @@ describe('memoized worktree rows', () => { await act(async () => { renderer!.update( createElement(WorktreeAgentRow, { - agent: agent({ state: 'done', updatedAt: 2_000 }), + agent: agent({ state: 'working', workingMode: 'monitoring', updatedAt: 2_000 }), depth: 0, now: 2_000, unvisited: false @@ -207,5 +207,22 @@ describe('memoized worktree rows', () => { ) }) expect(agentStateDotRender).toHaveBeenCalledTimes(2) + expect(agentStateDotRender).toHaveBeenLastCalledWith({ state: 'monitoring' }) + }) + + it('passes workspace monitoring mode to the status indicator', async () => { + await act(async () => { + renderer = create( + createElement(ListRowHarness, { + item: { ...baseItem, status: 'working', workingMode: 'monitoring' }, + now: 2_000 + }) + ) + }) + + expect(agentSpinnerRender).toHaveBeenLastCalledWith({ + status: 'working', + workingMode: 'monitoring' + }) }) }) diff --git a/mobile/src/components/WorktreeListRow.tsx b/mobile/src/components/WorktreeListRow.tsx index 74b716c2b30..ba1c3865fc7 100644 --- a/mobile/src/components/WorktreeListRow.tsx +++ b/mobile/src/components/WorktreeListRow.tsx @@ -2,6 +2,7 @@ import { memo } from 'react' import { Bell, ChevronDown, ChevronRight, GitBranch, GitPullRequest } from 'lucide-react-native' import { Pressable, StyleSheet, Text, View } from 'react-native' import type { RepoIcon } from '../../../src/shared/repo-icon' +import type { AgentWorkingMode } from '../../../src/shared/agent-status-types' import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types' import { triggerMediumImpact } from '../platform/haptics' import { colors, radii, spacing, typography } from '../theme/mobile-theme' @@ -39,6 +40,7 @@ export type WorktreeListRowItem = { lineageChildCount?: number lineageCollapsed?: boolean agents?: RuntimeWorktreeAgentRow[] + workingMode?: AgentWorkingMode } type WorktreeRollupStatus = 'working' | 'active' | 'permission' | 'done' | 'inactive' @@ -97,7 +99,7 @@ function WorktreeListRowComponent({ delayLongPress={400} > - + {item.unread && ( } + } + unmount(): void +} + +const { animationLoop, animationTiming, setValue } = vi.hoisted(() => ({ + animationLoop: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), + animationTiming: vi.fn(() => ({})), + setValue: vi.fn() +})) + +vi.mock('lucide-react-native', () => ({ Radio: 'Radio' })) +vi.mock('react-native', () => ({ + Animated: { + Value: function Value() { + return { interpolate: vi.fn(() => 'rotation'), setValue } + }, + View: 'AnimatedView', + loop: animationLoop, + timing: animationTiming + }, + Easing: { linear: 'linear' }, + StyleSheet: { create: (styles: T) => styles }, + View: 'View' +})) + +describe('mobile monitoring indicators', () => { + let renderer: MonitoringTestRenderer | null = null + + beforeEach(() => { + animationLoop.mockClear() + animationTiming.mockClear() + setValue.mockClear() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it('renders a static Radio for a monitoring agent', async () => { + await act(async () => { + renderer = create(createElement(AgentStateDot, { state: 'monitoring' })) + }) + + expect(renderer?.root.findByType('Radio').props).toMatchObject({ + color: DESKTOP_WORKING_COLOR, + size: 10 + }) + expect(animationTiming).not.toHaveBeenCalled() + expect(animationLoop).not.toHaveBeenCalled() + }) + + it('renders a static Radio for an all-monitoring workspace', async () => { + await act(async () => { + renderer = create( + createElement(AgentSpinner, { status: 'working', workingMode: 'monitoring' }) + ) + }) + + expect(renderer?.root.findByType('Radio').props).toMatchObject({ + color: DESKTOP_WORKING_COLOR, + size: 12 + }) + expect(animationTiming).not.toHaveBeenCalled() + expect(animationLoop).not.toHaveBeenCalled() + }) + + it('keeps the spinner fallback when workingMode is absent', async () => { + await act(async () => { + renderer = create(createElement(AgentSpinner, { status: 'working' })) + }) + + expect(animationTiming).toHaveBeenCalledOnce() + expect(animationLoop).toHaveBeenCalledOnce() + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index a6308d08be5..3f4c0b7907b 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -697,7 +697,9 @@ describe('useMobileNativeChatController streaming scope', () => { launchAgent: 'claude', agentStatus: { state: 'working', + workingMode: undefined as 'monitoring' | undefined, agentType: 'claude', + lastAssistantMessage: 'Partial reply', providerSession: { id: 'session-1' } }, isActive: true @@ -723,6 +725,7 @@ describe('useMobileNativeChatController streaming scope', () => { beforeEach(() => { viewMode.isTabChatView = () => true + workingTab.agentStatus.workingMode = undefined act(() => { renderer = create(createElement(Harness)) }) @@ -737,6 +740,7 @@ describe('useMobileNativeChatController streaming scope', () => { it('holds the stream scope and liveness while the user peeks at the terminal', () => { expect(controller?.showNativeChat).toBe(true) + expect(controller?.nativeChatAgentWorking).toBe(true) const scopeKey = controller?.nativeChatStreamScopeKey expect(scopeKey).toContain('session-1') expect(controller?.nativeChatStreamLive).toBe(true) @@ -750,6 +754,16 @@ describe('useMobileNativeChatController streaming scope', () => { expect(controller?.nativeChatStreamLive).toBe(true) }) + it('treats passive monitoring as outside the foreground turn', () => { + workingTab.agentStatus.workingMode = 'monitoring' + act(() => renderer?.update(createElement(Harness))) + + expect(controller?.nativeChatAgentWorking).toBe(false) + expect(controller?.nativeChatStreamLive).toBe(false) + expect(controller?.nativeChatStreamingText).toBeUndefined() + expect(controller?.nativeChatSessionOptions?.isWorking).toBe(false) + }) + it('keeps the delayed-send route guard view-gated, unlike the stream scope', () => { const before = answerSendArgs.at(-1)?.streamIdentity expect(before).toBe(controller?.nativeChatStreamScopeKey) diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 27ad8049c1d..ab1319c7847 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -115,11 +115,14 @@ export function useMobileNativeChatController(args: { transcriptSettled: nativeChatSession.status === 'ready' }) - const nativeChatStatus = activeChatResolution ? activeSessionTab?.agentStatus : null - const nativeChatAgentWorking = nativeChatStatus?.state === 'working' + const activeTabStatus = activeSessionTab?.agentStatus + const activeTabAgentWorking = + activeTabStatus?.state === 'working' && activeTabStatus.workingMode !== 'monitoring' + const nativeChatStatus = activeChatResolution ? activeTabStatus : null + const nativeChatAgentWorking = activeChatResolution != null && activeTabAgentWorking // Deliberately not gated on the chat view being visible: the streaming gate // has to tell "hidden mid-turn" from "the turn ended". - const nativeChatStreamLive = activeSessionTab?.agentStatus?.state === 'working' + const nativeChatStreamLive = activeTabAgentWorking // Throttle the streaming bubble: OpenCode emits a status frame per streamed // part, and each one re-renders and re-parses the whole accumulated markdown. const nativeChatStreamingText = useThrottledLatestValue( diff --git a/mobile/src/worktree/agent-row-display.test.ts b/mobile/src/worktree/agent-row-display.test.ts index 80889753def..ee872e40f26 100644 --- a/mobile/src/worktree/agent-row-display.test.ts +++ b/mobile/src/worktree/agent-row-display.test.ts @@ -28,6 +28,9 @@ function row(overrides: Partial = {}): RuntimeWorktreeA describe('agentDotState', () => { it('maps known states through and unknown to idle', () => { expect(agentDotState(row({ state: 'working', updatedAt: 0 }), 0)).toBe('working') + expect( + agentDotState(row({ state: 'working', workingMode: 'monitoring', updatedAt: 0 }), 0) + ).toBe('monitoring') expect(agentDotState(row({ state: 'blocked', updatedAt: 0 }), 0)).toBe('blocked') expect(agentDotState(row({ state: 'waiting', updatedAt: 0 }), 0)).toBe('waiting') expect(agentDotState(row({ state: 'done', updatedAt: 0 }), 0)).toBe('done') @@ -65,6 +68,12 @@ describe('agentDisplayLabel', () => { expect(agentDisplayLabel(row({ state: 'working', prompt: '', updatedAt: 0 }), 0)).toBe( 'Working' ) + expect( + agentDisplayLabel( + row({ state: 'working', workingMode: 'monitoring', prompt: '', updatedAt: 0 }), + 0 + ) + ).toBe('Monitoring background tasks') }) it('falls back to the decayed state label when stale', () => { diff --git a/mobile/src/worktree/agent-row-display.ts b/mobile/src/worktree/agent-row-display.ts index 05391b88b89..c678b522a15 100644 --- a/mobile/src/worktree/agent-row-display.ts +++ b/mobile/src/worktree/agent-row-display.ts @@ -9,23 +9,34 @@ export const AGENT_STATUS_STALE_AFTER_MS = 30 * 60 * 1000 // Mirrors the desktop AgentStateDot vocabulary. The wire `state` is the agent // status state; 'blocked'/'waiting' read as attention states, 'done' as // complete, everything else idle. -export type AgentDotState = 'working' | 'blocked' | 'waiting' | 'done' | 'idle' | 'interrupted' +export type AgentDotState = + | 'working' + | 'monitoring' + | 'blocked' + | 'waiting' + | 'done' + | 'idle' + | 'interrupted' export function agentDotState( - row: Pick, + row: Pick, now: number ): AgentDotState { if (row.interrupted) { return 'interrupted' } switch (row.state) { - case 'working': case 'blocked': case 'waiting': // Why: an agent that exits without a final report would otherwise read as // active forever. Decay a stale active state to idle, matching desktop's // renderer-side staleness decay (worktree-agent-rows.ts). return now - row.updatedAt > AGENT_STATUS_STALE_AFTER_MS ? 'idle' : row.state + case 'working': + if (now - row.updatedAt > AGENT_STATUS_STALE_AFTER_MS) { + return 'idle' + } + return row.workingMode === 'monitoring' ? 'monitoring' : 'working' case 'done': return 'done' } @@ -37,6 +48,8 @@ export function agentStateLabel(state: AgentDotState): string { switch (state) { case 'working': return 'Working' + case 'monitoring': + return 'Monitoring background tasks' case 'blocked': return 'Blocked' case 'waiting': diff --git a/mobile/src/worktree/workspace-list-types.ts b/mobile/src/worktree/workspace-list-types.ts index 4ddb135affe..afa937f8baa 100644 --- a/mobile/src/worktree/workspace-list-types.ts +++ b/mobile/src/worktree/workspace-list-types.ts @@ -1,4 +1,5 @@ import type { ExecutionHostId } from '../../../src/shared/execution-host' +import type { AgentWorkingMode } from '../../../src/shared/agent-status-types' import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types' export type Worktree = { @@ -45,6 +46,7 @@ export type Worktree = { linkedGitLabIssue?: number | null comment?: string status?: 'working' | 'active' | 'permission' | 'done' | 'inactive' + workingMode?: AgentWorkingMode agents?: RuntimeWorktreeAgentRow[] } diff --git a/mobile/src/worktree/worktree-list-snapshot.test.ts b/mobile/src/worktree/worktree-list-snapshot.test.ts index d6d3af1930c..c7c5756160f 100644 --- a/mobile/src/worktree/worktree-list-snapshot.test.ts +++ b/mobile/src/worktree/worktree-list-snapshot.test.ts @@ -160,6 +160,20 @@ describe('areWorktreeListsEqual', () => { expect(areWorktreeListsEqual(first, second)).toBe(false) }) + it('detects monitoring mode changes within working', () => { + const first = [worktree({ agents: [agent({ state: 'working' })] })] + const second = [worktree({ agents: [agent({ state: 'working', workingMode: 'monitoring' })] })] + + expect(areWorktreeListsEqual(first, second)).toBe(false) + }) + + it('detects workspace monitoring mode changes within working', () => { + const first = [worktree({ status: 'working' })] + const second = [worktree({ status: 'working', workingMode: 'monitoring' })] + + expect(areWorktreeListsEqual(first, second)).toBe(false) + }) + it('treats missing and empty agent arrays as equivalent for rendering', () => { const first = [worktree({ agents: undefined })] const second = [worktree({ agents: [] })] diff --git a/mobile/src/worktree/worktree-list-snapshot.ts b/mobile/src/worktree/worktree-list-snapshot.ts index bb9df6cf9af..8fa27c2a197 100644 --- a/mobile/src/worktree/worktree-list-snapshot.ts +++ b/mobile/src/worktree/worktree-list-snapshot.ts @@ -55,6 +55,7 @@ function areWorktreesEqual(left: Worktree, right: Worktree): boolean { (left.linkedGitLabIssue ?? null) === (right.linkedGitLabIssue ?? null) && (left.comment ?? '') === (right.comment ?? '') && (left.status ?? null) === (right.status ?? null) && + (left.workingMode ?? null) === (right.workingMode ?? null) && arePullRequestsEqual(left.linkedPR, right.linkedPR) && areAgentRowsEqual(left.agents ?? [], right.agents ?? []) ) @@ -102,6 +103,7 @@ function areAgentRowsEqual( a.paneKey !== b.paneKey || a.parentPaneKey !== b.parentPaneKey || a.state !== b.state || + a.workingMode !== b.workingMode || a.agentType !== b.agentType || a.prompt !== b.prompt || a.lastAssistantMessage !== b.lastAssistantMessage || diff --git a/package.json b/package.json index a026bdb7824..933741f8cc4 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,7 @@ "test:e2e:posix-profile-index-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-posix-fresh-startup.spec.ts tests/e2e/golden-posix-profile-index-fsync.spec.ts --grep @posix-profile-index-golden --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:windows-fresh-startup-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-windows-fresh-startup.spec.ts --grep @windows-fresh-startup-golden --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:agent-tui-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-agent-tui-launch.spec.ts tests/e2e/golden-shell-after-agent-exit.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "test:e2e:tab-bar-agent-launch-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-tab-bar-agent-launch.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:terminal-rendering-release-evidence": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts tests/e2e/terminal-long-table-scroll-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2", "test:e2e:terminal-perf": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-typing-latency.spec.ts tests/e2e/terminal-foreground-redraw-freeze.spec.ts tests/e2e/terminal-output-scheduler.spec.ts tests/e2e/terminal-hidden-tui-visual-restore.spec.ts tests/e2e/artificial-opencode-terminal-load.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2", "test:e2e:terminal-perf:scale": "pnpm run ensure:electron-runtime && node config/scripts/run-terminal-scale-perf-e2e.mjs", @@ -114,6 +115,7 @@ "test:e2e:source-control-scale": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/source-control-large-file-count.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "win-update-e2e": "node tests/tools/win-update-e2e/run.mjs", "win-crash-survival-e2e": "node tests/tools/win-crash-survival-e2e/run.mjs", + "smoke:windows-pty-native-capability": "node tests/tools/windows-pty-native-capability-smoke/run.mjs", "test:e2e:ssh-codex-artifacts-repro": "node config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs", "test:e2e:headful": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headful", "test:e2e:terminal-ime-native": "node config/scripts/run-terminal-ibus-hangul-e2e.mjs", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ee3caf6950..6d739a42d25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,7 +27,7 @@ patchedDependencies: hash: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673 path: config/patches/lint-staged@16.4.0.patch node-pty@1.1.0: - hash: 710d5c74d1b738c41316e997e7cb860b61147604f98380ccf4f5bb20b6ed0ac5 + hash: 9c665d9e41571341cb8986607d531b953a7f32c0dd60e1e74e633c098d80f2c1 path: config/patches/node-pty@1.1.0.patch importers: @@ -69,7 +69,7 @@ importers: version: 3.3.1 node-pty: specifier: ^1.1.0 - version: 1.1.0(patch_hash=710d5c74d1b738c41316e997e7cb860b61147604f98380ccf4f5bb20b6ed0ac5) + version: 1.1.0(patch_hash=9c665d9e41571341cb8986607d531b953a7f32c0dd60e1e74e633c098d80f2c1) posthog-node: specifier: ^5.33.3 version: 5.33.3 @@ -12046,7 +12046,7 @@ snapshots: node-int64@0.4.0: {} - node-pty@1.1.0(patch_hash=710d5c74d1b738c41316e997e7cb860b61147604f98380ccf4f5bb20b6ed0ac5): + node-pty@1.1.0(patch_hash=9c665d9e41571341cb8986607d531b953a7f32c0dd60e1e74e633c098d80f2c1): dependencies: node-addon-api: 7.1.1 diff --git a/src/cli/handlers/account.test.ts b/src/cli/handlers/account.test.ts index 8642df27ea4..8916b124fbc 100644 --- a/src/cli/handlers/account.test.ts +++ b/src/cli/handlers/account.test.ts @@ -12,6 +12,7 @@ const { resolveCliCommandMock, rmSyncMock, spawnMock, + stdioForWindowsInteractiveChildMock, writeKeychainMock } = vi.hoisted(() => ({ deleteKeychainMock: vi.fn(), @@ -20,6 +21,7 @@ const { resolveCliCommandMock: vi.fn(), rmSyncMock: vi.fn(), spawnMock: vi.fn(), + stdioForWindowsInteractiveChildMock: vi.fn(), writeKeychainMock: vi.fn() })) @@ -45,6 +47,9 @@ vi.mock('../../shared/node-cli-command-resolution', () => ({ getVersionManagerBinPaths: getVersionManagerBinPathsMock, resolveCliCommand: resolveCliCommandMock })) +vi.mock('../../shared/windows-console-input', () => ({ + stdioForWindowsInteractiveChild: stdioForWindowsInteractiveChildMock +})) import { ACCOUNT_HANDLERS } from './account' import type { HandlerContext } from '../dispatch' @@ -105,6 +110,10 @@ describe('account CLI handlers', () => { beforeEach(() => { Object.defineProperty(process, 'platform', originalPlatform) spawnMock.mockReset().mockImplementation(() => successfulChild()) + stdioForWindowsInteractiveChildMock.mockReset().mockImplementation((json: boolean) => ({ + stdio: ['inherit', json ? process.stderr : 'inherit', 'inherit'], + dispose: vi.fn() + })) resolveCliCommandMock.mockReset().mockImplementation((command: string) => command) getVersionManagerBinPathsMock.mockReset().mockReturnValue([]) readKeychainMock.mockReset().mockResolvedValue(null) @@ -159,6 +168,56 @@ describe('account CLI handlers', () => { }) }) + it('passes Windows console device handles to the login child instead of inheriting Electron stdio', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const dispose = vi.fn() + stdioForWindowsInteractiveChildMock.mockReturnValue({ + stdio: [11, 'inherit', 'inherit'], + dispose + }) + + await ACCOUNT_HANDLERS['account add'](context('codex')) + + expect(spawnMock).toHaveBeenCalledWith( + 'codex', + ['login', '--device-auth'], + expect.objectContaining({ stdio: [11, 'inherit', 'inherit'] }) + ) + expect(dispose).toHaveBeenCalledTimes(1) + }) + + it('disposes the Windows console input fd when spawn throws synchronously', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const dispose = vi.fn() + stdioForWindowsInteractiveChildMock.mockReturnValue({ + stdio: [11, 'inherit', 'inherit'], + dispose + }) + spawnMock.mockImplementationOnce(() => { + throw new Error('invalid stdio') + }) + + await expect(ACCOUNT_HANDLERS['account add'](context('codex'))).rejects.toThrow('invalid stdio') + + expect(dispose).toHaveBeenCalledOnce() + }) + + it('keeps JSON login prompts off the CLI stdout envelope when console fds are attached', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + stdioForWindowsInteractiveChildMock.mockReturnValue({ + stdio: [11, process.stderr, 'inherit'], + dispose: vi.fn() + }) + + await ACCOUNT_HANDLERS['account add'](context('codex', true)) + + expect(spawnMock).toHaveBeenCalledWith( + 'codex', + ['login', '--device-auth'], + expect.objectContaining({ stdio: [11, process.stderr, 'inherit'] }) + ) + }) + it('routes Windows package-manager shims through the safe cmd launcher', async () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) resolveCliCommandMock.mockReturnValue('C:\\tools\\codex.cmd') diff --git a/src/cli/handlers/account.ts b/src/cli/handlers/account.ts index 0f7f95bc17e..4f5a23164a9 100644 --- a/src/cli/handlers/account.ts +++ b/src/cli/handlers/account.ts @@ -21,6 +21,7 @@ import { UnsafeWindowsBatchArgumentsError, WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL } from '../../shared/windows-batch-spawn' +import { stdioForWindowsInteractiveChild } from '../../shared/windows-console-input' import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version' import type { RuntimeStatus } from '../../shared/runtime-types' import type { @@ -113,12 +114,18 @@ async function runAgentLoginInTerminal( return } const env = addAgentNodePaths({ ...stripElectronRunAsNode(process.env), ...extraEnv }) - const child = spawn(spawnCmd, spawnArgs, { - // Why: JSON mode reserves stdout for the response envelope while keeping - // the interactive login attached to the user's terminal via stderr. - stdio: ['inherit', json ? process.stderr : 'inherit', 'inherit'], - env - }) + const consoleStdio = stdioForWindowsInteractiveChild(json) + let child: ReturnType + try { + child = spawn(spawnCmd, spawnArgs, { + // Why: JSON mode reserves stdout for the response envelope while keeping + // the interactive login attached to the user's terminal via stderr. + stdio: consoleStdio.stdio, + env + }) + } finally { + consoleStdio.dispose() + } session.child = child child.once('error', (error) => rejectPromise( diff --git a/src/cli/handlers/orchestration-module-boundaries.test.ts b/src/cli/handlers/orchestration-module-boundaries.test.ts new file mode 100644 index 00000000000..baca7e38b41 --- /dev/null +++ b/src/cli/handlers/orchestration-module-boundaries.test.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../format', () => ({ printResult: vi.fn() })) + +import { printResult } from '../format' +import { HANDLER_GROUPS } from '../handler-group-manifest' +import { ORCHESTRATION_HANDLERS } from './orchestration' +import { + ORCHESTRATION_DISPATCH_HANDLER, + ORCHESTRATION_DISPATCH_INSPECTION_HANDLERS +} from './orchestration/dispatch-handlers' +import { getOptionalStructuredMessagePayload } from './orchestration/message-payload' +import { getOptionalPositiveIntegerValueFlag } from './orchestration/numeric-flags' +import { formatWorkerRead, formatWorkerRelease } from './orchestration/worker-output' + +describe('extracted orchestration flag parsing', () => { + it('serializes phase and omits empty CSV entries', () => { + const payload = getOptionalStructuredMessagePayload( + new Map([ + ['phase', 'verification'], + ['files-modified', ' src/a.ts, ,src/b.ts, '] + ]) + ) + + expect(JSON.parse(payload ?? '')).toEqual({ + filesModified: ['src/a.ts', 'src/b.ts'], + phase: 'verification' + }) + }) + + it('rejects unsafe positive integers before dispatch', () => { + expect(() => + getOptionalPositiveIntegerValueFlag( + new Map([['timeout-ms', '9007199254740992']]), + 'timeout-ms' + ) + ).toThrow('Invalid positive safe integer for --timeout-ms') + }) +}) + +it('composes handlers in the canonical command order', () => { + const declared = HANDLER_GROUPS.find((group) => group.name === 'orchestration')?.keys + expect(Object.keys(ORCHESTRATION_HANDLERS)).toEqual(declared) +}) + +describe('extracted orchestration worker formatting', () => { + it('renders terminal tails without changing line boundaries', () => { + expect( + formatWorkerRead({ + dispatchId: 'dispatch_1', + terminal: { tail: ['first', '', 'third'] } + } as never) + ).toBe('first\n\nthird') + }) + + it('renders transcript blocks and survives unserializable tool input', () => { + const circular: { self?: unknown } = {} + circular.self = circular + + expect( + formatWorkerRead({ + source: 'transcript', + transcript: { + messages: [ + { + id: 'message_1', + role: 'assistant', + blocks: [ + { type: 'text', text: 'working' }, + { type: 'tool-call', name: 'inspect', input: circular }, + { type: 'tool-result', output: 'failed', isError: true }, + { type: 'image-ref', url: 'https://example.test/proof.png' } + ], + timestamp: null, + source: 'transcript' + } + ] + } + } as never) + ).toBe( + '[assistant] working\n[tool inspect] [unserializable input]\n[tool result error] failed\n[image] https://example.test/proof.png' + ) + }) + + it('renders release evidence in receipt order', () => { + expect( + formatWorkerRelease({ + dispatchId: 'dispatch_1', + state: 'release_unknown', + reason: 'host_unreachable', + processAction: 'unverifiable', + archive: { source: 'transcript', status: 'saved' }, + lastError: 'transport closed', + recovery: 'Reconnect and retry.' + }) + ).toBe( + 'Worker dispatch_1 terminal [release_unknown] reason=host_unreachable process=unverifiable\n' + + 'archive transcript [saved]\ntransport closed\nReconnect and retry.' + ) + }) +}) + +describe('extracted orchestration dispatch handlers', () => { + const call = vi.fn() + + beforeEach(() => { + call.mockReset() + vi.mocked(printResult).mockReset() + }) + + it('allows a dry run without a recipient and returns only its preamble', async () => { + call.mockResolvedValue({ + result: { dispatch: null, dryRun: true, preamble: 'preview preamble' } + }) + + await ORCHESTRATION_DISPATCH_HANDLER['orchestration dispatch']({ + flags: new Map([ + ['task', 'task_1'], + ['from', 'terminal_coordinator'], + ['dry-run', true], + ['return-preamble', true] + ]), + client: { call }, + cwd: '/repo', + json: false + } as never) + + expect(call).toHaveBeenCalledWith('orchestration.dispatch', { + task: 'task_1', + run: undefined, + to: undefined, + from: 'terminal_coordinator', + inject: undefined, + dryRun: true, + returnPreamble: true, + devMode: false + }) + expect(renderedValue({ dispatch: null, dryRun: true, preamble: 'preview preamble' })).toBe( + 'preview preamble' + ) + }) + + it('does not resolve coordinator identity for a non-preamble dispatch lookup', async () => { + call.mockResolvedValue({ result: { dispatch: null } }) + + await ORCHESTRATION_DISPATCH_INSPECTION_HANDLERS['orchestration dispatch-show']({ + flags: new Map([['task', 'task_1']]), + client: { call }, + cwd: '/repo', + json: false + } as never) + + expect(call).toHaveBeenCalledWith('orchestration.dispatchShow', { + task: 'task_1', + preamble: undefined, + from: undefined, + devMode: false + }) + expect(renderedValue({ dispatch: null })).toBe('No dispatch context found.') + }) +}) + +function renderedValue(value: unknown): string { + const formatter = vi.mocked(printResult).mock.calls[0]?.[2] as (result: unknown) => string + return formatter(value) +} diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts index 43fffc54b8c..f1f1c70bc39 100644 --- a/src/cli/handlers/orchestration.ts +++ b/src/cli/handlers/orchestration.ts @@ -1,1327 +1,32 @@ -/* eslint-disable max-lines -- Why: orchestration CLI handlers share flag-parsing helpers and dispatch/preamble logic; splitting by verb would fragment the RuntimeClient call shape without reducing complexity. */ import type { CommandHandler } from '../dispatch' -import type { RuntimeClient } from '../runtime-client' -import { printResult } from '../format' +import { ORCHESTRATION_CHECK_HANDLER } from './orchestration/message-check-handler' import { - getOptionalPositiveIntegerFlag, - getOptionalStringFlag, - getRequiredStringFlag -} from '../flags' -import { RuntimeClientError } from '../runtime-client' -import { requireWorkerDoneSettlement } from './orchestration-worker-settlement' -import { getTerminalHandle } from '../selectors' -import { - clampOrchestrationAskTimeoutMs, - resolveOrchestrationAskClientTimeoutMs -} from '../../shared/orchestration-ask-timeout' -import { abbreviateOrchestrationTasks } from '../../shared/orchestration-task-summary' -import { parsePositiveSafeIntegerText } from '../../shared/timer-delay' -import type { - OrchestrationWorkerReadResult, - OrchestrationWorkerReadSource -} from '../../shared/orchestration-worker-output' -import type { NativeChatMessage } from '../../shared/native-chat-types' -import type { RuntimeStatus, RuntimeTerminalRead } from '../../shared/runtime-types' -import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../shared/protocol-version' -import { orchestrationMigrationData } from '../../shared/orchestration-rpc-contract' -import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../shared/orchestration-run-pagination' -import { - formatMessageReadOnlyTag, - formatOrchestrationCheckText, - prepareOrchestrationCheckOutput, - type LegacyCompatibilityResult, - type OrchestrationMessageSummary as MessageSummary -} from '../../shared/orchestration-check-output' -import { orchestrationMutationRecoveryError } from '../orchestration-mutation-recovery' - -// Why: 15 s is well under Claude Code's ~2 min Bash-tool silence budget while keeping log volume low. See design doc §3.4. -const DEFAULT_KEEPALIVE_INTERVAL_MS = 15_000 -function getLifecycleGroupRecipientError(type: 'worker_done' | 'heartbeat'): string { - return `${type} messages belong to one exact Dispatch and cannot target a group address.` -} - -// Why: test-only escape hatch so subprocess tests avoid the full 15 s window; bogus values fall back to the default. -function resolveKeepaliveIntervalMs(): number { - const raw = process.env.ORCA_KEEPALIVE_INTERVAL_MS ?? process.env.ORCA_HEARTBEAT_INTERVAL_MS - if (!raw) { - return DEFAULT_KEEPALIVE_INTERVAL_MS - } - const parsed = Number(raw) - if (!Number.isFinite(parsed) || parsed <= 0) { - return DEFAULT_KEEPALIVE_INTERVAL_MS - } - return parsed -} - -function startCheckKeepalive(deadlineMs: number | undefined): () => void { - const startedAt = Date.now() - const interval = setInterval(() => { - const payload = { - _keepalive: true, - // Why: retain the old marker for scripts still filtering it while callers migrate to _keepalive. - _heartbeat: true, - elapsedMs: Date.now() - startedAt, - deadlineMs: deadlineMs ?? null - } - // Why: process.stderr.write is line-flushed per-call in Node; a buffered writer would hold keepalives until exit. See §3.4. - process.stderr.write(`${JSON.stringify(payload)}\n`) - }, resolveKeepaliveIntervalMs()) - if (typeof interval.unref === 'function') { - interval.unref() - } - return () => clearInterval(interval) -} - -// Why: mirrors TaskStatus (orchestration/types.ts) so the CLI surfaces an enum-aware error before the generic RPC message. -const TASK_STATUS_VALUES = [ - 'pending', - 'ready', - 'dispatched', - 'completed', - 'failed', - 'blocked' -] as const - -// Why: mirrors WorkerTerminalListState (orchestration/types.ts) so a bad --terminal-state fails before the RPC. -const WORKER_TERMINAL_LIST_STATES = [ - 'active', - 'reclaimable', - 'retained', - 'release_pending', - 'release_unknown', - 'released' -] as const - -type LifecycleSendResult = - | { - action: 'completed' | 'failed' - authority?: 'run_home' | 'worker_server_legacy' - } - | { action: 'settled'; outcome: 'succeeded' | 'failed'; duplicate?: boolean } - | { action: 'rejected'; code: string; reason: string } - -type SendRecipientWarning = { - code: string - recipient: string - message: string -} - -type OrchestrationSendResult = - | { - message: { id: string; run_id?: string } - lifecycle?: LifecycleSendResult - warnings?: SendRecipientWarning[] - } - | { - messages: { id: string }[] - recipients: number - warnings?: SendRecipientWarning[] - } - | { - relay: { - messageId: string - sequence: number - dispatchId: string - destination?: 'run_home' | 'worker' - accepted: true - } - lifecycle?: LifecycleSendResult - warnings?: SendRecipientWarning[] - } - -function resolveCompatibilityCliCommand(): 'orca' | 'orca-ide' | 'orca-dev' { - const configured = process.env.ORCA_CLI_COMMAND - if (configured === 'orca' || configured === 'orca-ide' || configured === 'orca-dev') { - return configured - } - return process.platform === 'linux' ? 'orca-ide' : 'orca' -} - -function resolvePackagedWindowsCompatibilityCommand(): 'orca' | 'orca-ide' | undefined { - if (process.env.ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER !== '1') { - return undefined - } - const command = process.env.ORCA_CLI_COMMAND - if (command === 'orca' || command === 'orca-ide') { - return command - } - throw new RuntimeClientError( - 'invalid_argument', - 'The packaged Orca launcher did not provide a valid resume command. No question was created.' - ) -} - -async function flushStdout(): Promise { - await new Promise((resolve, reject) => { - process.stdout.write('', (error) => { - if (error) { - reject(error) - } else { - resolve() - } - }) - }) -} - -function getOptionalStructuredMessagePayload( - flags: Map -): string | undefined { - const rawPayload = getOptionalStringFlag(flags, 'payload') - const taskId = getOptionalStringFlag(flags, 'task-id') - const dispatchId = getOptionalStringFlag(flags, 'dispatch-id') - const outcome = getOptionalStringFlag(flags, 'outcome') - const filesModified = getOptionalStringFlag(flags, 'files-modified') - const reportPath = getOptionalStringFlag(flags, 'report-path') - const phase = getOptionalStringFlag(flags, 'phase') - const hasStructuredPayload = - taskId !== undefined || - dispatchId !== undefined || - outcome !== undefined || - filesModified !== undefined || - reportPath !== undefined || - phase !== undefined - if (!hasStructuredPayload) { - return rawPayload - } - if (rawPayload !== undefined) { - throw new RuntimeClientError( - 'invalid_argument', - 'Use either --payload or structured payload flags, not both.' - ) - } - // Why: raw JSON args are fragile in Windows PowerShell; these flags avoid shell-specific quoting. - const payload: Record = {} - if (taskId) { - payload.taskId = taskId - } - if (dispatchId) { - payload.dispatchId = dispatchId - } - if (outcome) { - if (outcome !== 'succeeded' && outcome !== 'failed') { - throw new RuntimeClientError( - 'invalid_argument', - 'Invalid --outcome. Expected succeeded or failed.' - ) - } - payload.outcome = outcome - } - if (filesModified) { - payload.filesModified = filesModified - .split(',') - .map((file) => file.trim()) - .filter(Boolean) - } - if (reportPath) { - payload.reportPath = reportPath - } - if (phase) { - payload.phase = phase - } - return JSON.stringify(payload) -} - -async function resolveOrchestrationTerminalHandle( - flags: Map, - cwd: string, - client: Parameters[0]['client'], - flagName: 'from' | 'terminal', - options: { validateEnvHandle?: boolean } = {} -): Promise { - const explicit = getOptionalStringFlag(flags, flagName) - if (explicit) { - return explicit - } - const envHandle = process.env.ORCA_TERMINAL_HANDLE - if (envHandle && envHandle.length > 0) { - if (flagName === 'from' && options.validateEnvHandle) { - // Why: long-lived shells can retain a stale ORCA_TERMINAL_HANDLE after remint; don't bake it into coordinator preambles. - const live = await isLiveTerminalHandle(envHandle, client) - if (!live) { - const reminted = await resolveOrchestrationPaneTerminalHandle(client) - if (reminted) { - return reminted - } - throwNoActiveSenderTerminal() - } - } - return envHandle - } - if (flagName === 'from') { - return await resolveImplicitOrchestrationSender(flags, cwd, client) - } - return await getTerminalHandle(flags, cwd, client) -} - -async function isLiveTerminalHandle( - handle: string, - client: Parameters[0]['client'] -): Promise { - try { - await client.call('terminal.show', { terminal: handle }) - return true - } catch (err) { - if (isStaleTerminalIdentityError(err)) { - return false - } - throw err - } -} - -function getClientErrorCode(err: unknown): string | undefined { - if (!err || typeof err !== 'object') { - return undefined - } - const code = (err as { code?: unknown }).code - return typeof code === 'string' ? code : undefined -} - -function isStaleTerminalIdentityError(err: unknown): boolean { - const code = getClientErrorCode(err) - return code === 'terminal_handle_stale' || code === 'terminal_gone' -} - -function isNoActiveTerminalError(err: unknown): boolean { - return getClientErrorCode(err) === 'no_active_terminal' -} - -async function resolveOrchestrationPaneTerminalHandle( - client: Parameters[0]['client'], - options: { optional?: boolean } = {} -): Promise { - const paneKey = process.env.ORCA_PANE_KEY - if (!paneKey || paneKey.length === 0) { - return undefined - } - try { - // Why: pane-key reminting preserves caller identity; focus-based active-terminal fallback can point at a different pane. - const response = await client.call<{ terminal: { handle: string } }>('terminal.resolvePane', { - paneKey - }) - return response.result.terminal.handle - } catch (err) { - if ( - isPaneRemintUnavailableError(err) || - (options.optional === true && isOptionalPaneRemintUnavailableError(err)) - ) { - return undefined - } - throw err - } -} - -function isPaneRemintUnavailableError(err: unknown): boolean { - const code = getClientErrorCode(err) - const message = getClientErrorMessage(err) - return ( - code === 'terminal_not_found' || - code === 'terminal_handle_stale' || - code === 'terminal_gone' || - message === 'terminal_not_found' || - message === 'terminal_handle_stale' || - message === 'terminal_gone' - ) -} - -function isOptionalPaneRemintUnavailableError(err: unknown): boolean { - return getClientErrorCode(err) === 'runtime_unavailable' -} - -function getClientErrorMessage(err: unknown): string | undefined { - if (err instanceof Error) { - return err.message - } - if (!err || typeof err !== 'object') { - return undefined - } - const message = (err as { message?: unknown }).message - return typeof message === 'string' ? message : undefined -} - -async function resolveCoordinatorTerminalHandle( - flags: Map, - cwd: string, - client: Parameters[0]['client'] -): Promise { - return await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from', { - validateEnvHandle: true - }) -} - -async function resolveImplicitOrchestrationSender( - flags: Map, - cwd: string, - client: Parameters[0]['client'] -): Promise { - try { - return await getTerminalHandle(flags, cwd, client) - } catch (err) { - if (!isNoActiveTerminalError(err)) { - throw err - } - throwNoActiveSenderTerminal() - } -} - -function throwNoActiveSenderTerminal(): never { - throw new RuntimeClientError( - 'no_active_sender_terminal', - 'Could not determine the sender terminal for this orchestration command. ' + - 'Pass --from or run the command inside a live Orca terminal with ORCA_TERMINAL_HANDLE set.' - ) -} - -function isDevCliInvocation(): boolean { - return ( - process.env.ORCA_DEV_CLI_INVOCATION === '1' || - (process.env.ORCA_USER_DATA_PATH?.includes('orca-dev') ?? false) - ) -} - -function getOptionalPositiveIntegerValueFlag( - flags: Map, - name: string -): number | undefined { - if (!flags.has(name)) { - return undefined - } - const raw = flags.get(name) - if (typeof raw !== 'string' || raw.length === 0) { - throw new RuntimeClientError('invalid_argument', `Missing value for --${name}.`) - } - const value = parsePositiveSafeIntegerText(raw) - if (value === null) { - throw new RuntimeClientError( - 'invalid_argument', - `Invalid positive safe integer for --${name}: ${raw}` - ) - } - return value -} - -function rejectLifecycleGroupRecipient(type: string | undefined, to: string): void { - if ((type === 'worker_done' || type === 'heartbeat') && to.startsWith('@')) { - throw new RuntimeClientError('invalid_argument', getLifecycleGroupRecipientError(type)) - } -} - -function callMutation( - client: RuntimeClient, - flags: Map, - method: string, - params: unknown, - options?: { timeoutMs?: number; orchestrationCapability?: string } -) { - const requestId = getOptionalStringFlag(flags, 'retry-request') - const result = requestId - ? client.call(method, params, { ...options, orchestrationRequestId: requestId }) - : options - ? client.call(method, params, options) - : client.call(method, params) - return result.catch((error) => { - throw orchestrationMutationRecoveryError(error) - }) -} - -type LegacyWorkerReadResult = { - dispatchId: string - terminal: RuntimeTerminalRead -} - -function formatWorkerRead(value: OrchestrationWorkerReadResult | LegacyWorkerReadResult): string { - if (!('source' in value) || value.source === 'terminal') { - return value.terminal.tail.join('\n') - } - return value.transcript.messages.map(formatWorkerTranscriptMessage).join('\n\n') -} - -function formatWorkerTranscriptMessage(message: NativeChatMessage): string { - const blocks = message.blocks.map((block) => { - if (block.type === 'text') { - return block.text - } - if (block.type === 'tool-call') { - return `[tool ${block.name}] ${safeJson(block.input)}` - } - if (block.type === 'tool-result') { - return `[tool result${block.isError ? ' error' : ''}] ${block.output}` - } - return block.url ? `[image] ${block.url}` : `[image omitted]` - }) - return `[${message.role}] ${blocks.join('\n')}`.trimEnd() -} - -type WorkerReleaseReceipt = { - dispatchId: string - state: string - reason?: string - processAction: string - archive: { source: string | null; status: string | null } | null - recovery?: string - lastError?: string -} - -function formatWorkerRelease(value: WorkerReleaseReceipt): string { - const head = `Worker ${value.dispatchId} terminal [${value.state}]` - const lines = [ - `${head}${value.reason ? ` reason=${value.reason}` : ''} process=${value.processAction}` - ] - if (value.archive) { - lines.push(`archive ${value.archive.source ?? 'none'} [${value.archive.status ?? 'unknown'}]`) - } - if (value.lastError) { - lines.push(value.lastError) - } - if (value.recovery) { - lines.push(value.recovery) - } - return lines.join('\n') -} - -function safeJson(value: unknown): string { - try { - return JSON.stringify(value) - } catch { - return '[unserializable input]' - } -} + ORCHESTRATION_DISPATCH_HANDLER, + ORCHESTRATION_DISPATCH_INSPECTION_HANDLERS +} from './orchestration/dispatch-handlers' +import { ORCHESTRATION_GATE_HANDLERS } from './orchestration/gate-handlers' +import { ORCHESTRATION_INBOX_HANDLERS } from './orchestration/message-inbox-handlers' +import { ORCHESTRATION_QUESTION_HANDLER } from './orchestration/question-handler' +import { ORCHESTRATION_RESET_HANDLER } from './orchestration/reset-handler' +import { ORCHESTRATION_RUN_HANDLERS } from './orchestration/run-handlers' +import { ORCHESTRATION_SEND_HANDLER } from './orchestration/message-send-handler' +import { ORCHESTRATION_TASK_HANDLERS } from './orchestration/task-handlers' +import { ORCHESTRATION_WORKER_LAUNCH_HANDLER } from './orchestration/worker-launch-handler' +import { ORCHESTRATION_WORKER_OBSERVATION_HANDLERS } from './orchestration/worker-observation-handlers' +import { ORCHESTRATION_WORKER_TERMINAL_HANDLERS } from './orchestration/worker-terminal-handlers' export const ORCHESTRATION_HANDLERS: Record = { - 'orchestration run-create': async ({ flags, client, cwd, json }) => { - const from = await resolveCoordinatorTerminalHandle(flags, cwd, client) - const result = await callMutation<{ - run: { id: string; objective: string; consumer_generation: number } - }>(client, flags, 'orchestration.runCreate', { - objective: getRequiredStringFlag(flags, 'objective'), - from - }) - printResult(result, json, (r) => `Run ${r.run.id} created and bound: ${r.run.objective}`) - }, - - 'orchestration run-use': async ({ flags, client, cwd, json }) => { - const from = await resolveCoordinatorTerminalHandle(flags, cwd, client) - const result = await callMutation<{ - run: { id: string; objective: string; consumer_generation: number } - }>(client, flags, 'orchestration.runUse', { - id: getRequiredStringFlag(flags, 'id'), - from, - ...(flags.has('takeover-legacy') ? { takeoverLegacy: true } : {}) - }) - printResult(result, json, (r) => `Using Run ${r.run.id}: ${r.run.objective}`) - }, - - 'orchestration run-current': async ({ flags, client, cwd, json }) => { - const from = await resolveCoordinatorTerminalHandle(flags, cwd, client) - const result = await client.call<{ - run: { id: string; objective: string } | null - }>('orchestration.runCurrent', { from }) - printResult(result, json, (r) => - r.run ? `${r.run.id} ${r.run.objective}` : 'No Run is bound to this terminal.' - ) - }, - - 'orchestration run-list': async ({ flags, client, json }) => { - const result = await client.call<{ - runs: { id: string; objective: string; legacy: number }[] - nextCursor: string | null - }>('orchestration.runList', { - limit: getOptionalPositiveIntegerFlag(flags, 'limit') ?? ORCHESTRATION_RUN_PAGE_LIMIT, - cursor: getOptionalStringFlag(flags, 'cursor') - }) - printResult(result, json, (r) => { - const rows = - r.runs.length === 0 - ? 'No Runs found.' - : r.runs - .map( - (run) => `${run.id}${run.legacy ? ' [legacy, inspect only]' : ''} ${run.objective}` - ) - .join('\n') - return r.nextCursor ? `${rows}\nMore Runs: --cursor ${r.nextCursor}` : rows - }) - }, - - 'orchestration run-show': async ({ flags, client, json }) => { - const result = await client.call<{ - run: { - id: string - objective: string - consumer_generation: number - legacy: number - created_at: string - } - }>('orchestration.runShow', { id: getRequiredStringFlag(flags, 'id') }) - printResult( - result, - json, - (r) => - `${r.run.id}${r.run.legacy ? ' [legacy, inspect only]' : ''} ${r.run.objective}\n` + - `consumer generation ${r.run.consumer_generation}; created ${r.run.created_at}` - ) - }, - - 'orchestration send': async ({ flags, client, cwd, json }) => { - const to = getOptionalStringFlag(flags, 'to') - const type = getOptionalStringFlag(flags, 'type') - if (to) { - rejectLifecycleGroupRecipient(type, to) - } - const outcome = getOptionalStringFlag(flags, 'outcome') - if (type !== 'worker_done' && outcome !== undefined) { - throw new RuntimeClientError( - 'invalid_argument', - '--outcome is only valid with --type worker_done.' - ) - } - - if ( - (type === 'worker_done' || type === 'heartbeat') && - !getOptionalStringFlag(flags, 'from') && - !process.env.ORCA_TERMINAL_HANDLE - ) { - // Why: focus isn't lifecycle authority — an identity-less subprocess must fail closed rather than guess the worker. - throwNoActiveSenderTerminal() - } - - // Why: lifecycle senders preserve ORCA_TERMINAL_HANDLE across restarts for older runtimes. - const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') - const sendParams = { - from, - to, - run: getOptionalStringFlag(flags, 'run'), - subject: getRequiredStringFlag(flags, 'subject'), - body: getOptionalStringFlag(flags, 'body'), - type, - priority: getOptionalStringFlag(flags, 'priority'), - threadId: getOptionalStringFlag(flags, 'thread-id'), - payload: getOptionalStructuredMessagePayload(flags), - // Why: pane key is the remint-stable sender identity the runtime verifies lifecycle ownership against; older runtimes strip it. - senderPaneKey: process.env.ORCA_PANE_KEY || undefined, - waitForLifecycleSettlement: type === 'worker_done' ? true : undefined, - devMode: isDevCliInvocation() - } - const dispatchCapability = getOptionalStringFlag(flags, 'dispatch-capability') - const result = await callMutation( - client, - flags, - 'orchestration.send', - sendParams, - dispatchCapability ? { orchestrationCapability: dispatchCapability } : undefined - ) - await requireWorkerDoneSettlement(client, type, sendParams.payload, result.result) - if ('lifecycle' in result.result && result.result.lifecycle?.action === 'rejected') { - throw new RuntimeClientError(result.result.lifecycle.code, result.result.lifecycle.reason) - } - printResult(result, json, (r) => { - const warnings = 'warnings' in r ? (r.warnings ?? []) : [] - const withWarnings = (line: string): string => - warnings.length > 0 - ? [line, ...warnings.map((warning) => `Warning: ${warning.message}`)].join('\n') - : line - if ('message' in r) { - return withWarnings(`Sent ${r.message.id}`) - } - if ('relay' in r) { - if (r.relay.destination === 'worker') { - return withWarnings( - `Queued ${r.relay.messageId} for worker Dispatch ${r.relay.dispatchId}` - ) - } - return withWarnings( - `Queued ${r.relay.messageId} for Run home (Dispatch ${r.relay.dispatchId})` - ) - } - return withWarnings(`Sent ${r.messages.length} messages to ${r.recipients} recipients`) - }) - }, - - 'orchestration check': async ({ flags, client, cwd, json }) => { - const wait = flags.has('wait') - const peek = flags.has('peek') - // Why: enforce mode exclusivity client-side — older runtimes strip unknown peek and run --unread --peek as destructive mark-read. - if ([flags.has('unread'), peek, flags.has('all')].filter(Boolean).length > 1) { - throw new RuntimeClientError( - 'invalid_argument', - 'Choose at most one message read mode: --unread, --peek, or --all.' - ) - } - const timeoutMs = getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms') - const explicitTerminal = getOptionalStringFlag(flags, 'terminal') - const terminal = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'terminal') - - // Why: Claude Code auto-backgrounds subprocesses silent ~2 min; emit JSON keepalives to stderr (stdout stays one payload). See §3.4. - const stopKeepalive = wait ? startCheckKeepalive(timeoutMs) : null - type CheckResult = { - messages: MessageSummary[] - count: number - formatted?: string - deliveryId?: string | null - runId?: string - timedOut?: boolean - cancelled?: boolean - connectionLost?: boolean - legacyCompatibility?: LegacyCompatibilityResult - } - let result: Awaited>> - try { - result = await callMutation(client, flags, 'orchestration.check', { - terminal, - terminalPaneKey: explicitTerminal ? undefined : process.env.ORCA_PANE_KEY || undefined, - // Why: peek also sends unread:false so pre-peek runtimes degrade to non-consuming all mode instead of destructive mark-read. - unread: flags.has('unread') ? true : peek ? false : undefined, - peek: peek ? true : undefined, - all: flags.has('all') ? true : undefined, - types: getOptionalStringFlag(flags, 'types'), - format: flags.has('format') ? true : undefined, - inject: flags.has('inject') ? true : undefined, - compatibilityCliCommand: resolveCompatibilityCliCommand(), - run: getOptionalStringFlag(flags, 'run'), - ack: getOptionalStringFlag(flags, 'ack'), - wait: wait ? true : undefined, - timeoutMs - }) - } finally { - stopKeepalive?.() - } - if (peek) { - const rawRowCount = result.result.messages.length - const unreadOnly = result.result.messages.filter((m) => m.read !== 1) - const removedReadRows = unreadOnly.length !== rawRowCount - // Why: read rows mean a pre-peek runtime ran the all mode and returned instead of blocking; can't honor --wait, so fail loudly. - if (wait && removedReadRows && unreadOnly.length === 0) { - throw new RuntimeClientError( - 'peek_wait_unsupported', - 'The connected runtime does not support --peek with --wait; upgrade the runtime or use --wait without --peek.' - ) - } - // Why: pre-peek runtimes cap the all mode at 100 rows; a full page may hide older unread — warn on stderr. - if (removedReadRows && rawRowCount >= 100) { - console.error( - 'Warning: this runtime returned only its newest 100 messages for --peek; older unread messages may be missing. Upgrade the runtime for exact peek results.' - ) - } - result = { - ...result, - result: { - ...result.result, - // Why: a pre-peek runtime builds `formatted` from all rows; drop it so output matches the filtered peek set. - ...(removedReadRows ? { formatted: undefined } : {}), - messages: unreadOnly, - count: unreadOnly.length - } - } - } - result = { - ...result, - result: prepareOrchestrationCheckOutput(result.result, terminal, flags.has('format')) - } - printResult(result, json, (r) => formatOrchestrationCheckText(r, terminal)) - const compatibilityAck = result.result.legacyCompatibility?.ackMessageIds - if (compatibilityAck && compatibilityAck.length > 0) { - await flushStdout() - await client.call('orchestration.check', { - terminal, - compatibilityAck: JSON.stringify({ - messageIds: compatibilityAck, - types: getOptionalStringFlag(flags, 'types') - ?.split(',') - .map((type) => type.trim()) - .filter(Boolean) - }) - }) - } - }, - - 'orchestration reply': async ({ flags, client, cwd, json }) => { - const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') - const result = await callMutation<{ message: { id: string } }>( - client, - flags, - 'orchestration.reply', - { - id: getRequiredStringFlag(flags, 'id'), - body: getRequiredStringFlag(flags, 'body'), - run: getOptionalStringFlag(flags, 'run'), - from - } - ) - printResult(result, json, (r) => `Replied ${r.message.id}`) - }, - - 'orchestration inbox': async ({ flags, client, json }) => { - const full = flags.has('full') - const result = await client.call<{ - messages: MessageSummary[] - count: number - }>('orchestration.inbox', { - limit: getOptionalPositiveIntegerFlag(flags, 'limit'), - terminal: getOptionalStringFlag(flags, 'terminal') - }) - printResult(result, json, (r) => { - if (r.count === 0) { - return 'No messages.' - } - // Why: default output omits body/payload for at-a-glance sweeps; --full prints them for auditing. - return r.messages - .map((m) => { - const head = `${m.id}${formatMessageReadOnlyTag(m)} ${m.from_handle} -> ${m.to_handle ?? '?'}: "${m.subject}"` - if (!full) { - return head - } - const parts = [head] - if (m.body && m.body.length > 0) { - parts.push(m.body) - } - if (m.payload) { - parts.push(`[payload] ${m.payload}`) - } - return parts.join('\n') - }) - .join(full ? '\n\n' : '\n') - }) - }, - - 'orchestration task-create': async ({ flags, client, cwd, json }) => { - const callerTerminalHandle = await resolveCoordinatorTerminalHandle(flags, cwd, client) - const result = await callMutation<{ task: { id: string; status: string } }>( - client, - flags, - 'orchestration.taskCreate', - { - spec: getRequiredStringFlag(flags, 'spec'), - taskTitle: getOptionalStringFlag(flags, 'task-title'), - displayName: getOptionalStringFlag(flags, 'display-name'), - deps: getOptionalStringFlag(flags, 'deps'), - parent: getOptionalStringFlag(flags, 'parent'), - run: getOptionalStringFlag(flags, 'run'), - callerTerminalHandle - } - ) - printResult(result, json, (r) => `Created ${r.task.id} [${r.task.status}]`) - }, - - 'orchestration task-list': async ({ flags, client, cwd, json }) => { - const brief = flags.has('brief') - const run = getOptionalStringFlag(flags, 'run') - const callerTerminalHandle = run - ? undefined - : await resolveCoordinatorTerminalHandle(flags, cwd, client) - const result = await client.call<{ - tasks: { - id: string - spec: string - task_title?: string | null - display_name?: string | null - status: string - assignee_handle?: string | null - dispatch_id?: string | null - spec_truncated?: boolean - }[] - count: number - runId?: string - legacyReadOnly?: boolean - }>('orchestration.taskList', { - status: getOptionalStringFlag(flags, 'status'), - ready: flags.has('ready') ? true : undefined, - brief: brief ? true : undefined, - run, - callerTerminalHandle - }) - // Why: only older runtimes (no spec_truncated) skip server-side abbreviation and need this client-side fallback. - const needsClientAbbreviation = - brief && result.result.tasks.some((task) => task.spec_truncated === undefined) - const output = needsClientAbbreviation - ? { - ...result, - result: { ...result.result, tasks: abbreviateOrchestrationTasks(result.result.tasks) } - } - : result - printResult(output, json, (r) => { - if (r.count === 0) { - return r.legacyReadOnly ? 'No legacy tasks (read-only).' : 'No tasks.' - } - const tasks = r.tasks - .map((t) => { - const label = t.display_name ?? t.task_title ?? t.spec - const head = `${t.id} [${t.status}] ${label.slice(0, 60)}` - if (t.status === 'dispatched' && t.assignee_handle) { - return `${head} -> ${t.assignee_handle} (${t.dispatch_id ?? '?'})` - } - return head - }) - .join('\n') - return r.legacyReadOnly ? `Legacy Run ${r.runId} (read-only)\n${tasks}` : tasks - }) - }, - - 'orchestration task-update': async ({ flags, client, cwd, json }) => { - const status = getRequiredStringFlag(flags, 'status') - if (!TASK_STATUS_VALUES.includes(status as (typeof TASK_STATUS_VALUES)[number])) { - throw new RuntimeClientError( - 'invalid_argument', - `invalid status '${status}', expected one of: ${TASK_STATUS_VALUES.join(', ')}` - ) - } - const result = await callMutation<{ task: { id: string; status: string } }>( - client, - flags, - 'orchestration.taskUpdate', - { - id: getRequiredStringFlag(flags, 'id'), - status, - result: getOptionalStringFlag(flags, 'result'), - run: getOptionalStringFlag(flags, 'run'), - callerTerminalHandle: await resolveCoordinatorTerminalHandle(flags, cwd, client) - } - ) - printResult(result, json, (r) => `Updated ${r.task.id} -> ${r.task.status}`) - }, - - 'orchestration worker-start': async ({ flags, client, cwd, json }) => { - const model = getOptionalStringFlag(flags, 'model') - const effort = getOptionalStringFlag(flags, 'effort') - if (model || effort) { - const status = await client.call('status.get') - if ( - !status.result.capabilities?.includes( - ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY - ) - ) { - throw new RuntimeClientError( - 'incompatible_runtime', - 'The connected Orca runtime does not support worker model or effort overrides. Update or restart Orca and try again.' - ) - } - } - const result = await callMutation<{ - runId: string - taskId: string - dispatchId: string - state: string - failedStage?: string - lastError?: string - warning?: string - effects: unknown[] - residualResources: unknown[] - }>(client, flags, 'orchestration.workerStart', { - task: getRequiredStringFlag(flags, 'task'), - on: getOptionalStringFlag(flags, 'on'), - worktree: getOptionalStringFlag(flags, 'worktree'), - name: getOptionalStringFlag(flags, 'name'), - repo: getOptionalStringFlag(flags, 'repo'), - baseBranch: getOptionalStringFlag(flags, 'base-branch'), - displayName: getOptionalStringFlag(flags, 'display-name'), - comment: getOptionalStringFlag(flags, 'comment'), - setup: getOptionalStringFlag(flags, 'setup'), - agent: getOptionalStringFlag(flags, 'agent'), - model, - effort, - terminal: getOptionalStringFlag(flags, 'terminal'), - retryOf: getOptionalStringFlag(flags, 'retry-of'), - timeoutMs: getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms'), - run: getOptionalStringFlag(flags, 'run'), - from: await resolveCoordinatorTerminalHandle(flags, cwd, client), - devMode: isDevCliInvocation() - }) - if (result.result.state !== 'ready') { - process.exitCode = 1 - } - printResult(result, json, (worker) => { - const base = `Worker ${worker.dispatchId} [${worker.state}] for ${worker.taskId}` - if (worker.lastError) { - return `${base}\n${worker.failedStage ?? 'start'}: ${worker.lastError}` - } - return worker.warning ? `${base}\nWarning: ${worker.warning}` : base - }) - }, - - 'orchestration worker-show': async ({ flags, client, json }) => { - const result = await client.call<{ - dispatch: { id: string; task_id: string; status: string } - worker: { state: string; stage: string; agent_terminal_handle: string | null } - observation?: { agentWait?: { source: string; reason?: string } | null } - }>('orchestration.workerShow', { - dispatch: getRequiredStringFlag(flags, 'dispatch') - }) - printResult(result, json, (value) => { - const base = `${value.dispatch.id} task=${value.dispatch.task_id} [${value.worker.state}] stage=${value.worker.stage}` - // Why a whole line and not a suffix: this is the one state where the lane is healthy - // and still needs a person, so it must not read as another status token. An absent - // field is unknown, which must not print the same as an evaluated "none". - if (value.observation === undefined || !('agentWait' in value.observation)) { - return `${base}\nInteractive wait: unknown (not evaluated)` - } - const wait = value.observation.agentWait - return wait - ? `${base}\nWaiting on a human: ${wait.reason ?? 'interactive prompt'} (via ${wait.source})` - : `${base}\nInteractive wait: none` - }) - }, - - 'orchestration worker-read': async ({ flags, client, json }) => { - const cursorFlag = getOptionalStringFlag(flags, 'cursor') - const cursor = - cursorFlag !== undefined && /^\d+$/.test(cursorFlag) - ? Number.parseInt(cursorFlag, 10) - : cursorFlag - const source = getOptionalStringFlag(flags, 'source') - if (source && !['auto', 'transcript', 'terminal'].includes(source)) { - throw new RuntimeClientError( - 'invalid_argument', - '--source must be auto, transcript, or terminal' - ) - } - const result = await client.call( - 'orchestration.workerRead', - { - dispatch: getRequiredStringFlag(flags, 'dispatch'), - cursor, - limit: getOptionalPositiveIntegerFlag(flags, 'limit'), - source: source as OrchestrationWorkerReadSource | undefined - } - ) - printResult(result, json, formatWorkerRead) - }, - - 'orchestration worker-stop': async ({ flags, client, json }) => { - const result = await callMutation<{ - dispatchId: string - state: string - processAction: string - lastError?: string - warning?: string - }>(client, flags, 'orchestration.workerStop', { - dispatch: getRequiredStringFlag(flags, 'dispatch') - }) - if (result.result.state === 'stop_unknown') { - process.exitCode = 1 - } - printResult( - result, - json, - (value) => - `Worker ${value.dispatchId} [${value.state}] process=${value.processAction}${value.lastError ? `\n${value.lastError}` : ''}${value.warning ? `\nWarning: ${value.warning}` : ''}` - ) - }, - - 'orchestration worker-abandon': async ({ flags, client, json }) => { - const result = await callMutation<{ - dispatchId: string - state: string - warning: string - }>(client, flags, 'orchestration.workerAbandon', { - dispatch: getRequiredStringFlag(flags, 'dispatch') - }) - printResult( - result, - json, - (value) => `Worker ${value.dispatchId} [${value.state}]\nWarning: ${value.warning}` - ) - }, - - 'orchestration worker-release': async ({ flags, client, json }) => { - const result = await callMutation( - client, - flags, - 'orchestration.workerRelease', - { dispatch: getRequiredStringFlag(flags, 'dispatch') } - ) - // Why: only an unprovable close is a failure; retained/pending/already-released are settled answers. - if (result.result.state === 'release_unknown') { - process.exitCode = 1 - } - printResult(result, json, formatWorkerRelease) - }, - - 'orchestration worker-retain': async ({ flags, client, json }) => { - const result = await callMutation( - client, - flags, - 'orchestration.workerRetain', - { dispatch: getRequiredStringFlag(flags, 'dispatch') } - ) - if (result.result.state === 'release_unknown') { - process.exitCode = 1 - } - printResult(result, json, formatWorkerRelease) - }, - - 'orchestration worker-list': async ({ flags, client, json }) => { - const terminalState = getOptionalStringFlag(flags, 'terminal-state') - if ( - terminalState && - !WORKER_TERMINAL_LIST_STATES.includes( - terminalState as (typeof WORKER_TERMINAL_LIST_STATES)[number] - ) - ) { - throw new RuntimeClientError( - 'invalid_argument', - `invalid --terminal-state '${terminalState}', expected one of: ${WORKER_TERMINAL_LIST_STATES.join(', ')}` - ) - } - const result = await client.call<{ - workers: { - dispatchId: string - taskId: string - runId: string - workerState: string - dispatchStatus: string - agentTerminalHandle: string | null - terminalState: string | null - resource: unknown - }[] - counts: Record - }>('orchestration.workerList', { - run: getOptionalStringFlag(flags, 'run'), - terminalState - }) - printResult(result, json, (r) => { - if (r.workers.length === 0) { - return 'No workers found.' - } - const rows = r.workers - .map( - (w) => - `${w.dispatchId} task=${w.taskId} [${w.workerState}] terminal=${w.terminalState ?? 'none'}` - ) - .join('\n') - const counts = Object.entries(r.counts) - .map(([state, count]) => `${state}=${count}`) - .join(' ') - return counts ? `${rows}\nTerminals: ${counts}` : rows - }) - }, - - 'orchestration dispatch': async ({ flags, client, cwd, json }) => { - const from = await resolveCoordinatorTerminalHandle(flags, cwd, client) - const dryRun = flags.has('dry-run') ? true : undefined - const returnPreamble = flags.has('return-preamble') ? true : undefined - // Why: --to is only required for non-dry-run; the RPC handler re-enforces. - const to = dryRun ? getOptionalStringFlag(flags, 'to') : getRequiredStringFlag(flags, 'to') - const result = await callMutation<{ - dispatch: { id: string; task_id: string; status: string } | null - injected?: boolean - dryRun?: boolean - preamble?: string - }>(client, flags, 'orchestration.dispatch', { - task: getRequiredStringFlag(flags, 'task'), - run: getOptionalStringFlag(flags, 'run'), - to, - from, - inject: flags.has('inject') ? true : undefined, - dryRun, - returnPreamble, - devMode: isDevCliInvocation() - }) - printResult(result, json, (r) => { - if (r.dryRun) { - return r.preamble ?? '' - } - const base = `Dispatched ${r.dispatch?.task_id} -> ${r.dispatch?.id} [${r.dispatch?.status}]` - return r.preamble ? `${base}\n\n--- Preamble ---\n${r.preamble}` : base - }) - }, - - 'orchestration ask': async ({ flags, client, cwd, json }) => { - const parsedTimeoutMs = getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms') - const timeoutMs = clampOrchestrationAskTimeoutMs(parsedTimeoutMs) - const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') - const question = getOptionalStringFlag(flags, 'question') - const resume = getOptionalStringFlag(flags, 'resume') - if ((question ? 1 : 0) + (resume ? 1 : 0) !== 1) { - throw new RuntimeClientError( - 'invalid_argument', - 'Choose exactly one of --question or --resume.' - ) - } - if (resume && flags.has('options')) { - throw new RuntimeClientError( - 'invalid_argument', - '--options is only valid when creating a new question.' - ) - } - const result = await callMutation<{ - answer: string | null - messageId: string | null - threadId: string - timedOut: boolean - timeoutMs?: number - cancelled?: boolean - connectionLost?: boolean - answerMessageId?: string | null - legacyCompatibility?: LegacyCompatibilityResult - }>( - client, - flags, - 'orchestration.ask', - { - to: getOptionalStringFlag(flags, 'to'), - run: getOptionalStringFlag(flags, 'run'), - question, - resume, - options: getOptionalStringFlag(flags, 'options'), - timeoutMs: parsedTimeoutMs === undefined ? undefined : timeoutMs, - from, - compatibilityCliCommand: resolveCompatibilityCliCommand(), - compatibilityWindowsCommand: resolvePackagedWindowsCompatibilityCommand() - }, - // Why: extend past timeoutMs so the RPC transport's 60s default doesn't abort before the runtime's own timeout resolves. - { - timeoutMs: resolveOrchestrationAskClientTimeoutMs(parsedTimeoutMs), - orchestrationCapability: getOptionalStringFlag(flags, 'dispatch-capability') - } - ) - // Why: bypass printResult so --json emits a bare JSON object (no envelope) pipeable via `jq -r .answer`, unlike other verbs. - if (json) { - console.log(JSON.stringify(result.result)) - } else if (result.result.legacyCompatibility?.resumeRequired) { - console.log(`Question ${result.result.messageId} committed.`) - console.log(`Resume with: ${result.result.legacyCompatibility.resumeCommand}`) - } else if (result.result.answer !== null) { - console.log(result.result.answer) - } - if (result.result.legacyCompatibility?.resumeRequired) { - await flushStdout() - process.exitCode = 75 - return - } - const answerAck = result.result.legacyCompatibility?.answerAcknowledgement - if (answerAck && result.result.answer !== null) { - await flushStdout() - await client.call('orchestration.check', { - terminal: from, - compatibilityQuestionAck: JSON.stringify(answerAck) - }) - } - if (result.result.timedOut) { - if (!json) { - // Why: report the server's effective budget — it clamps large values, so the requested one would overstate the wait. - const waitedMs = result.result.timeoutMs ?? timeoutMs - console.error(`ask timeout after ${waitedMs}ms (thread ${result.result.threadId})`) - } - process.exitCode = 1 - } - if (result.result.cancelled) { - if (!json) { - console.error( - result.result.connectionLost - ? `ask connection closed (question ${result.result.messageId})` - : `ask cancelled (question ${result.result.messageId})` - ) - } - process.exitCode = 1 - } - }, - - 'orchestration dispatch-show': async ({ flags, client, cwd, json }) => { - const showPreamble = flags.has('preamble') ? true : undefined - // Why: resolve --from so the previewed preamble embeds a real coordinator handle like an actual dispatch. - const from = showPreamble - ? await resolveCoordinatorTerminalHandle(flags, cwd, client) - : undefined - const result = await client.call<{ - dispatch: { id: string; task_id: string; status: string } | null - preamble?: string - }>('orchestration.dispatchShow', { - task: getRequiredStringFlag(flags, 'task'), - preamble: showPreamble, - from, - devMode: isDevCliInvocation() - }) - printResult(result, json, (r) => { - if (r.preamble && showPreamble) { - return r.preamble - } - if (!r.dispatch) { - return 'No dispatch context found.' - } - return `${r.dispatch.id} task=${r.dispatch.task_id} [${r.dispatch.status}]` - }) - }, - - 'orchestration coordinator-start': async () => { - throw new RuntimeClientError( - 'orchestration_migration_required', - 'The legacy automatic coordinator command is retired. No effects were applied.', - orchestrationMigrationData('command_retired') - ) - }, - - 'orchestration coordinator-stop': async () => { - throw new RuntimeClientError( - 'orchestration_migration_required', - 'The legacy automatic coordinator command is retired. No effects were applied.', - orchestrationMigrationData('command_retired') - ) - }, - - 'orchestration gate-create': async ({ flags, client, cwd, json }) => { - const result = await callMutation<{ - gate: { id: string; task_id: string; status: string } - }>(client, flags, 'orchestration.gateCreate', { - task: getRequiredStringFlag(flags, 'task'), - question: getRequiredStringFlag(flags, 'question'), - options: getOptionalStringFlag(flags, 'options'), - // Why: gates are Run-scoped, so the coordinator handle is the caller identity the runtime authorizes against. - from: await resolveCoordinatorTerminalHandle(flags, cwd, client) - }) - printResult( - result, - json, - (r) => `Gate ${r.gate.id} created for task ${r.gate.task_id} [${r.gate.status}]` - ) - }, - - 'orchestration gate-resolve': async ({ flags, client, cwd, json }) => { - const result = await callMutation<{ - gate: { id: string; task_id: string; status: string; resolution: string } - }>(client, flags, 'orchestration.gateResolve', { - id: getRequiredStringFlag(flags, 'id'), - resolution: getRequiredStringFlag(flags, 'resolution'), - from: await resolveCoordinatorTerminalHandle(flags, cwd, client) - }) - printResult(result, json, (r) => `Gate ${r.gate.id} resolved: ${r.gate.resolution}`) - }, - - 'orchestration gate-list': async ({ flags, client, cwd, json }) => { - const run = getOptionalStringFlag(flags, 'run') - // Why: named runs remain inspectable without a pane; only implicit runs resolve identity. - const from = run ? undefined : await resolveCoordinatorTerminalHandle(flags, cwd, client) - const result = await client.call<{ - gates: { id: string; task_id: string; question: string; status: string }[] - count: number - runId?: string - }>('orchestration.gateList', { - task: getOptionalStringFlag(flags, 'task'), - status: getOptionalStringFlag(flags, 'status'), - run, - from - }) - printResult(result, json, (r) => { - if (r.gates.length === 0) { - return 'No gates found.' - } - return r.gates - .map((g) => `${g.id} task=${g.task_id} [${g.status}] "${g.question}"`) - .join('\n') - }) - }, - - 'orchestration reset': async ({ flags, client, json }) => { - const scopeCount = [flags.has('all'), flags.has('tasks'), flags.has('messages')].filter( - Boolean - ).length - if (scopeCount !== 1) { - throw new RuntimeClientError( - 'invalid_argument', - 'Choose exactly one reset scope: --all, --tasks, or --messages.' - ) - } - const result = await callMutation<{ reset: string }>(client, flags, 'orchestration.reset', { - all: flags.has('all') ? true : undefined, - tasks: flags.has('tasks') ? true : undefined, - messages: flags.has('messages') ? true : undefined - }) - printResult(result, json, (r) => `Reset: ${r.reset}`) - } + ...ORCHESTRATION_RUN_HANDLERS, + ...ORCHESTRATION_SEND_HANDLER, + ...ORCHESTRATION_CHECK_HANDLER, + ...ORCHESTRATION_INBOX_HANDLERS, + ...ORCHESTRATION_TASK_HANDLERS, + ...ORCHESTRATION_WORKER_LAUNCH_HANDLER, + ...ORCHESTRATION_WORKER_OBSERVATION_HANDLERS, + ...ORCHESTRATION_WORKER_TERMINAL_HANDLERS, + ...ORCHESTRATION_DISPATCH_HANDLER, + ...ORCHESTRATION_QUESTION_HANDLER, + ...ORCHESTRATION_DISPATCH_INSPECTION_HANDLERS, + ...ORCHESTRATION_GATE_HANDLERS, + ...ORCHESTRATION_RESET_HANDLER } diff --git a/src/cli/handlers/orchestration/check-keepalive.ts b/src/cli/handlers/orchestration/check-keepalive.ts new file mode 100644 index 00000000000..a87f6065428 --- /dev/null +++ b/src/cli/handlers/orchestration/check-keepalive.ts @@ -0,0 +1,32 @@ +const DEFAULT_KEEPALIVE_INTERVAL_MS = 15_000 + +// Why: test-only escape hatch so subprocess tests avoid the full 15 s window; bogus values fall back to the default. +function resolveKeepaliveIntervalMs(): number { + const raw = process.env.ORCA_KEEPALIVE_INTERVAL_MS ?? process.env.ORCA_HEARTBEAT_INTERVAL_MS + if (!raw) { + return DEFAULT_KEEPALIVE_INTERVAL_MS + } + const parsed = Number(raw) + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_KEEPALIVE_INTERVAL_MS + } + return parsed +} + +export function startCheckKeepalive(deadlineMs: number | undefined): () => void { + const startedAt = Date.now() + const interval = setInterval(() => { + const payload = { + _keepalive: true, + // Why: retain the old marker for scripts still filtering it while callers migrate to _keepalive. + _heartbeat: true, + elapsedMs: Date.now() - startedAt, + deadlineMs: deadlineMs ?? null + } + process.stderr.write(`${JSON.stringify(payload)}\n`) + }, resolveKeepaliveIntervalMs()) + if (typeof interval.unref === 'function') { + interval.unref() + } + return () => clearInterval(interval) +} diff --git a/src/cli/handlers/orchestration/dispatch-handlers.ts b/src/cli/handlers/orchestration/dispatch-handlers.ts new file mode 100644 index 00000000000..afe79ab8e2f --- /dev/null +++ b/src/cli/handlers/orchestration/dispatch-handlers.ts @@ -0,0 +1,84 @@ +import type { CommandHandler } from '../../dispatch' +import { printResult } from '../../format' +import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags' +import { RuntimeClientError } from '../../runtime-client' +import { orchestrationMigrationData } from '../../../shared/orchestration-rpc-contract' +import { callOrchestrationMutation } from './mutation-request' +import { isDevCliInvocation } from './runtime-compatibility' +import { resolveCoordinatorTerminalHandle } from './terminal-identity' + +export const ORCHESTRATION_DISPATCH_HANDLER: Record = { + 'orchestration dispatch': async ({ flags, client, cwd, json }) => { + const from = await resolveCoordinatorTerminalHandle(flags, cwd, client) + const dryRun = flags.has('dry-run') ? true : undefined + const returnPreamble = flags.has('return-preamble') ? true : undefined + // Why: --to is only required for non-dry-run; the RPC handler re-enforces. + const to = dryRun ? getOptionalStringFlag(flags, 'to') : getRequiredStringFlag(flags, 'to') + const result = await callOrchestrationMutation<{ + dispatch: { id: string; task_id: string; status: string } | null + injected?: boolean + dryRun?: boolean + preamble?: string + }>(client, flags, 'orchestration.dispatch', { + task: getRequiredStringFlag(flags, 'task'), + run: getOptionalStringFlag(flags, 'run'), + to, + from, + inject: flags.has('inject') ? true : undefined, + dryRun, + returnPreamble, + devMode: isDevCliInvocation() + }) + printResult(result, json, (value) => { + if (value.dryRun) { + return value.preamble ?? '' + } + const base = `Dispatched ${value.dispatch?.task_id} -> ${value.dispatch?.id} [${value.dispatch?.status}]` + return value.preamble ? `${base}\n\n--- Preamble ---\n${value.preamble}` : base + }) + } +} + +export const ORCHESTRATION_DISPATCH_INSPECTION_HANDLERS: Record = { + 'orchestration dispatch-show': async ({ flags, client, cwd, json }) => { + const showPreamble = flags.has('preamble') ? true : undefined + // Why: a preview must embed the same real coordinator handle as an actual dispatch. + const from = showPreamble + ? await resolveCoordinatorTerminalHandle(flags, cwd, client) + : undefined + const result = await client.call<{ + dispatch: { id: string; task_id: string; status: string } | null + preamble?: string + }>('orchestration.dispatchShow', { + task: getRequiredStringFlag(flags, 'task'), + preamble: showPreamble, + from, + devMode: isDevCliInvocation() + }) + printResult(result, json, (value) => { + if (value.preamble && showPreamble) { + return value.preamble + } + if (!value.dispatch) { + return 'No dispatch context found.' + } + return `${value.dispatch.id} task=${value.dispatch.task_id} [${value.dispatch.status}]` + }) + }, + + 'orchestration coordinator-start': async () => { + throw new RuntimeClientError( + 'orchestration_migration_required', + 'The legacy automatic coordinator command is retired. No effects were applied.', + orchestrationMigrationData('command_retired') + ) + }, + + 'orchestration coordinator-stop': async () => { + throw new RuntimeClientError( + 'orchestration_migration_required', + 'The legacy automatic coordinator command is retired. No effects were applied.', + orchestrationMigrationData('command_retired') + ) + } +} diff --git a/src/cli/handlers/orchestration/gate-handlers.ts b/src/cli/handlers/orchestration/gate-handlers.ts new file mode 100644 index 00000000000..27d3cc1e3b7 --- /dev/null +++ b/src/cli/handlers/orchestration/gate-handlers.ts @@ -0,0 +1,60 @@ +import type { CommandHandler } from '../../dispatch' +import { printResult } from '../../format' +import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags' +import { callOrchestrationMutation } from './mutation-request' +import { resolveCoordinatorTerminalHandle } from './terminal-identity' + +export const ORCHESTRATION_GATE_HANDLERS: Record = { + 'orchestration gate-create': async ({ flags, client, cwd, json }) => { + const result = await callOrchestrationMutation<{ + gate: { id: string; task_id: string; status: string } + }>(client, flags, 'orchestration.gateCreate', { + task: getRequiredStringFlag(flags, 'task'), + question: getRequiredStringFlag(flags, 'question'), + options: getOptionalStringFlag(flags, 'options'), + // Why: gates are Run-scoped, so the coordinator handle is the authorized caller identity. + from: await resolveCoordinatorTerminalHandle(flags, cwd, client) + }) + printResult( + result, + json, + (value) => + `Gate ${value.gate.id} created for task ${value.gate.task_id} [${value.gate.status}]` + ) + }, + + 'orchestration gate-resolve': async ({ flags, client, cwd, json }) => { + const result = await callOrchestrationMutation<{ + gate: { id: string; task_id: string; status: string; resolution: string } + }>(client, flags, 'orchestration.gateResolve', { + id: getRequiredStringFlag(flags, 'id'), + resolution: getRequiredStringFlag(flags, 'resolution'), + from: await resolveCoordinatorTerminalHandle(flags, cwd, client) + }) + printResult(result, json, (value) => `Gate ${value.gate.id} resolved: ${value.gate.resolution}`) + }, + + 'orchestration gate-list': async ({ flags, client, cwd, json }) => { + const run = getOptionalStringFlag(flags, 'run') + // Why: named runs remain inspectable without a pane; only implicit runs resolve identity. + const from = run ? undefined : await resolveCoordinatorTerminalHandle(flags, cwd, client) + const result = await client.call<{ + gates: { id: string; task_id: string; question: string; status: string }[] + count: number + runId?: string + }>('orchestration.gateList', { + task: getOptionalStringFlag(flags, 'task'), + status: getOptionalStringFlag(flags, 'status'), + run, + from + }) + printResult(result, json, (value) => { + if (value.gates.length === 0) { + return 'No gates found.' + } + return value.gates + .map((gate) => `${gate.id} task=${gate.task_id} [${gate.status}] "${gate.question}"`) + .join('\n') + }) + } +} diff --git a/src/cli/handlers/orchestration/message-check-handler.ts b/src/cli/handlers/orchestration/message-check-handler.ts new file mode 100644 index 00000000000..f2af827ee04 --- /dev/null +++ b/src/cli/handlers/orchestration/message-check-handler.ts @@ -0,0 +1,120 @@ +import type { CommandHandler } from '../../dispatch' +import { printResult } from '../../format' +import { getOptionalStringFlag } from '../../flags' +import { RuntimeClientError } from '../../runtime-client' +import type { RuntimeRpcSuccess } from '../../runtime-client' +import { + formatOrchestrationCheckText, + prepareOrchestrationCheckOutput, + type LegacyCompatibilityResult, + type OrchestrationMessageSummary as MessageSummary +} from '../../../shared/orchestration-check-output' +import { startCheckKeepalive } from './check-keepalive' +import { callOrchestrationMutation } from './mutation-request' +import { getOptionalPositiveIntegerValueFlag } from './numeric-flags' +import { flushOrchestrationStdout, resolveCompatibilityCliCommand } from './runtime-compatibility' +import { resolveOrchestrationTerminalHandle } from './terminal-identity' + +type CheckResult = { + messages: MessageSummary[] + count: number + formatted?: string + deliveryId?: string | null + runId?: string + timedOut?: boolean + cancelled?: boolean + connectionLost?: boolean + legacyCompatibility?: LegacyCompatibilityResult +} + +export const ORCHESTRATION_CHECK_HANDLER: Record = { + 'orchestration check': async ({ flags, client, cwd, json }) => { + const wait = flags.has('wait') + const peek = flags.has('peek') + // Why: older runtimes strip unknown peek and run --unread --peek as destructive mark-read. + if ([flags.has('unread'), peek, flags.has('all')].filter(Boolean).length > 1) { + throw new RuntimeClientError( + 'invalid_argument', + 'Choose at most one message read mode: --unread, --peek, or --all.' + ) + } + const timeoutMs = getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms') + const explicitTerminal = getOptionalStringFlag(flags, 'terminal') + const terminal = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'terminal') + const stopKeepalive = wait ? startCheckKeepalive(timeoutMs) : null + let result: Awaited>> + try { + result = await callOrchestrationMutation(client, flags, 'orchestration.check', { + terminal, + terminalPaneKey: explicitTerminal ? undefined : process.env.ORCA_PANE_KEY || undefined, + // Why: old runtimes degrade peek to non-consuming all mode instead of destructive mark-read. + unread: flags.has('unread') ? true : peek ? false : undefined, + peek: peek ? true : undefined, + all: flags.has('all') ? true : undefined, + types: getOptionalStringFlag(flags, 'types'), + format: flags.has('format') ? true : undefined, + inject: flags.has('inject') ? true : undefined, + compatibilityCliCommand: resolveCompatibilityCliCommand(), + run: getOptionalStringFlag(flags, 'run'), + ack: getOptionalStringFlag(flags, 'ack'), + wait: wait ? true : undefined, + timeoutMs + }) + } finally { + stopKeepalive?.() + } + if (peek) { + result = filterLegacyPeekResult(result, wait) + } + result = { + ...result, + result: prepareOrchestrationCheckOutput(result.result, terminal, flags.has('format')) + } + printResult(result, json, (value) => formatOrchestrationCheckText(value, terminal)) + const compatibilityAck = result.result.legacyCompatibility?.ackMessageIds + if (compatibilityAck && compatibilityAck.length > 0) { + await flushOrchestrationStdout() + await client.call('orchestration.check', { + terminal, + compatibilityAck: JSON.stringify({ + messageIds: compatibilityAck, + types: getOptionalStringFlag(flags, 'types') + ?.split(',') + .map((type) => type.trim()) + .filter(Boolean) + }) + }) + } + } +} + +function filterLegacyPeekResult( + result: RuntimeRpcSuccess, + wait: boolean +): RuntimeRpcSuccess { + const rawRowCount = result.result.messages.length + const unreadOnly = result.result.messages.filter((message) => message.read !== 1) + const removedReadRows = unreadOnly.length !== rawRowCount + // Why: read rows prove a pre-peek runtime cannot honor wait, so fail instead of returning early. + if (wait && removedReadRows && unreadOnly.length === 0) { + throw new RuntimeClientError( + 'peek_wait_unsupported', + 'The connected runtime does not support --peek with --wait; upgrade the runtime or use --wait without --peek.' + ) + } + if (removedReadRows && rawRowCount >= 100) { + console.error( + 'Warning: this runtime returned only its newest 100 messages for --peek; older unread messages may be missing. Upgrade the runtime for exact peek results.' + ) + } + return { + ...result, + result: { + ...result.result, + // Why: a pre-peek runtime formats all rows, which no longer matches the filtered set. + ...(removedReadRows ? { formatted: undefined } : {}), + messages: unreadOnly, + count: unreadOnly.length + } + } +} diff --git a/src/cli/handlers/orchestration/message-inbox-handlers.ts b/src/cli/handlers/orchestration/message-inbox-handlers.ts new file mode 100644 index 00000000000..c15c4edfafa --- /dev/null +++ b/src/cli/handlers/orchestration/message-inbox-handlers.ts @@ -0,0 +1,64 @@ +import type { CommandHandler } from '../../dispatch' +import { printResult } from '../../format' +import { + getOptionalPositiveIntegerFlag, + getOptionalStringFlag, + getRequiredStringFlag +} from '../../flags' +import { + formatMessageReadOnlyTag, + type OrchestrationMessageSummary as MessageSummary +} from '../../../shared/orchestration-check-output' +import { callOrchestrationMutation } from './mutation-request' +import { resolveOrchestrationTerminalHandle } from './terminal-identity' + +export const ORCHESTRATION_INBOX_HANDLERS: Record = { + 'orchestration reply': async ({ flags, client, cwd, json }) => { + const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') + const result = await callOrchestrationMutation<{ message: { id: string } }>( + client, + flags, + 'orchestration.reply', + { + id: getRequiredStringFlag(flags, 'id'), + body: getRequiredStringFlag(flags, 'body'), + run: getOptionalStringFlag(flags, 'run'), + from + } + ) + printResult(result, json, (value) => `Replied ${value.message.id}`) + }, + + 'orchestration inbox': async ({ flags, client, json }) => { + const full = flags.has('full') + const result = await client.call<{ + messages: MessageSummary[] + count: number + }>('orchestration.inbox', { + limit: getOptionalPositiveIntegerFlag(flags, 'limit'), + terminal: getOptionalStringFlag(flags, 'terminal') + }) + printResult(result, json, (value) => { + if (value.count === 0) { + return 'No messages.' + } + // Why: default output omits body/payload for at-a-glance sweeps; --full prints them for auditing. + return value.messages + .map((message) => { + const head = `${message.id}${formatMessageReadOnlyTag(message)} ${message.from_handle} -> ${message.to_handle ?? '?'}: "${message.subject}"` + if (!full) { + return head + } + const parts = [head] + if (message.body && message.body.length > 0) { + parts.push(message.body) + } + if (message.payload) { + parts.push(`[payload] ${message.payload}`) + } + return parts.join('\n') + }) + .join(full ? '\n\n' : '\n') + }) + } +} diff --git a/src/cli/handlers/orchestration/message-payload.ts b/src/cli/handlers/orchestration/message-payload.ts new file mode 100644 index 00000000000..3c1e36b2ed8 --- /dev/null +++ b/src/cli/handlers/orchestration/message-payload.ts @@ -0,0 +1,60 @@ +import { getOptionalStringFlag } from '../../flags' +import { RuntimeClientError } from '../../runtime-client' + +export function getOptionalStructuredMessagePayload( + flags: Map +): string | undefined { + const rawPayload = getOptionalStringFlag(flags, 'payload') + const taskId = getOptionalStringFlag(flags, 'task-id') + const dispatchId = getOptionalStringFlag(flags, 'dispatch-id') + const outcome = getOptionalStringFlag(flags, 'outcome') + const filesModified = getOptionalStringFlag(flags, 'files-modified') + const reportPath = getOptionalStringFlag(flags, 'report-path') + const phase = getOptionalStringFlag(flags, 'phase') + const hasStructuredPayload = + taskId !== undefined || + dispatchId !== undefined || + outcome !== undefined || + filesModified !== undefined || + reportPath !== undefined || + phase !== undefined + if (!hasStructuredPayload) { + return rawPayload + } + if (rawPayload !== undefined) { + throw new RuntimeClientError( + 'invalid_argument', + 'Use either --payload or structured payload flags, not both.' + ) + } + // Why: raw JSON args are fragile in Windows PowerShell; these flags avoid shell-specific quoting. + const payload: Record = {} + if (taskId) { + payload.taskId = taskId + } + if (dispatchId) { + payload.dispatchId = dispatchId + } + if (outcome) { + if (outcome !== 'succeeded' && outcome !== 'failed') { + throw new RuntimeClientError( + 'invalid_argument', + 'Invalid --outcome. Expected succeeded or failed.' + ) + } + payload.outcome = outcome + } + if (filesModified) { + payload.filesModified = filesModified + .split(',') + .map((file) => file.trim()) + .filter(Boolean) + } + if (reportPath) { + payload.reportPath = reportPath + } + if (phase) { + payload.phase = phase + } + return JSON.stringify(payload) +} diff --git a/src/cli/handlers/orchestration/message-send-handler.ts b/src/cli/handlers/orchestration/message-send-handler.ts new file mode 100644 index 00000000000..088d1361e69 --- /dev/null +++ b/src/cli/handlers/orchestration/message-send-handler.ts @@ -0,0 +1,137 @@ +import type { CommandHandler } from '../../dispatch' +import { printResult } from '../../format' +import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags' +import { RuntimeClientError } from '../../runtime-client' +import { requireWorkerDoneSettlement } from '../orchestration-worker-settlement' +import { getOptionalStructuredMessagePayload } from './message-payload' +import { callOrchestrationMutation } from './mutation-request' +import { isDevCliInvocation } from './runtime-compatibility' +import { + resolveOrchestrationTerminalHandle, + throwNoActiveSenderTerminal +} from './terminal-identity' + +type LifecycleSendResult = + | { + action: 'completed' | 'failed' + authority?: 'run_home' | 'worker_server_legacy' + } + | { action: 'settled'; outcome: 'succeeded' | 'failed'; duplicate?: boolean } + | { action: 'rejected'; code: string; reason: string } + +type SendRecipientWarning = { + code: string + recipient: string + message: string +} + +type OrchestrationSendResult = + | { + message: { id: string; run_id?: string } + lifecycle?: LifecycleSendResult + warnings?: SendRecipientWarning[] + } + | { + messages: { id: string }[] + recipients: number + warnings?: SendRecipientWarning[] + } + | { + relay: { + messageId: string + sequence: number + dispatchId: string + destination?: 'run_home' | 'worker' + accepted: true + } + lifecycle?: LifecycleSendResult + warnings?: SendRecipientWarning[] + } + +function getLifecycleGroupRecipientError(type: 'worker_done' | 'heartbeat'): string { + return `${type} messages belong to one exact Dispatch and cannot target a group address.` +} + +function rejectLifecycleGroupRecipient(type: string | undefined, to: string): void { + if ((type === 'worker_done' || type === 'heartbeat') && to.startsWith('@')) { + throw new RuntimeClientError('invalid_argument', getLifecycleGroupRecipientError(type)) + } +} + +export const ORCHESTRATION_SEND_HANDLER: Record = { + 'orchestration send': async ({ flags, client, cwd, json }) => { + const to = getOptionalStringFlag(flags, 'to') + const type = getOptionalStringFlag(flags, 'type') + if (to) { + rejectLifecycleGroupRecipient(type, to) + } + const outcome = getOptionalStringFlag(flags, 'outcome') + if (type !== 'worker_done' && outcome !== undefined) { + throw new RuntimeClientError( + 'invalid_argument', + '--outcome is only valid with --type worker_done.' + ) + } + if ( + (type === 'worker_done' || type === 'heartbeat') && + !getOptionalStringFlag(flags, 'from') && + !process.env.ORCA_TERMINAL_HANDLE + ) { + // Why: focus isn't lifecycle authority — an identity-less subprocess must fail closed rather than guess the worker. + throwNoActiveSenderTerminal() + } + + // Why: lifecycle senders preserve ORCA_TERMINAL_HANDLE across restarts for older runtimes. + const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') + const sendParams = { + from, + to, + run: getOptionalStringFlag(flags, 'run'), + subject: getRequiredStringFlag(flags, 'subject'), + body: getOptionalStringFlag(flags, 'body'), + type, + priority: getOptionalStringFlag(flags, 'priority'), + threadId: getOptionalStringFlag(flags, 'thread-id'), + payload: getOptionalStructuredMessagePayload(flags), + // Why: pane key is the remint-stable sender identity the runtime verifies lifecycle ownership against; older runtimes strip it. + senderPaneKey: process.env.ORCA_PANE_KEY || undefined, + waitForLifecycleSettlement: type === 'worker_done' ? true : undefined, + devMode: isDevCliInvocation() + } + const dispatchCapability = getOptionalStringFlag(flags, 'dispatch-capability') + const result = await callOrchestrationMutation( + client, + flags, + 'orchestration.send', + sendParams, + dispatchCapability ? { orchestrationCapability: dispatchCapability } : undefined + ) + await requireWorkerDoneSettlement(client, type, sendParams.payload, result.result) + if ('lifecycle' in result.result && result.result.lifecycle?.action === 'rejected') { + throw new RuntimeClientError(result.result.lifecycle.code, result.result.lifecycle.reason) + } + printResult(result, json, (value) => { + const warnings = 'warnings' in value ? (value.warnings ?? []) : [] + const withWarnings = (line: string): string => + warnings.length > 0 + ? [line, ...warnings.map((warning) => `Warning: ${warning.message}`)].join('\n') + : line + if ('message' in value) { + return withWarnings(`Sent ${value.message.id}`) + } + if ('relay' in value) { + if (value.relay.destination === 'worker') { + return withWarnings( + `Queued ${value.relay.messageId} for worker Dispatch ${value.relay.dispatchId}` + ) + } + return withWarnings( + `Queued ${value.relay.messageId} for Run home (Dispatch ${value.relay.dispatchId})` + ) + } + return withWarnings( + `Sent ${value.messages.length} messages to ${value.recipients} recipients` + ) + }) + } +} diff --git a/src/cli/handlers/orchestration/mutation-request.ts b/src/cli/handlers/orchestration/mutation-request.ts new file mode 100644 index 00000000000..98b44e4046c --- /dev/null +++ b/src/cli/handlers/orchestration/mutation-request.ts @@ -0,0 +1,21 @@ +import type { RuntimeClient } from '../../runtime-client' +import { getOptionalStringFlag } from '../../flags' +import { orchestrationMutationRecoveryError } from '../../orchestration-mutation-recovery' + +export function callOrchestrationMutation( + client: RuntimeClient, + flags: Map, + method: string, + params: unknown, + options?: { timeoutMs?: number; orchestrationCapability?: string } +) { + const requestId = getOptionalStringFlag(flags, 'retry-request') + const result = requestId + ? client.call(method, params, { ...options, orchestrationRequestId: requestId }) + : options + ? client.call(method, params, options) + : client.call(method, params) + return result.catch((error) => { + throw orchestrationMutationRecoveryError(error) + }) +} diff --git a/src/cli/handlers/orchestration/numeric-flags.ts b/src/cli/handlers/orchestration/numeric-flags.ts new file mode 100644 index 00000000000..9d3a142150c --- /dev/null +++ b/src/cli/handlers/orchestration/numeric-flags.ts @@ -0,0 +1,23 @@ +import { parsePositiveSafeIntegerText } from '../../../shared/timer-delay' +import { RuntimeClientError } from '../../runtime-client' + +export function getOptionalPositiveIntegerValueFlag( + flags: Map, + name: string +): number | undefined { + if (!flags.has(name)) { + return undefined + } + const raw = flags.get(name) + if (typeof raw !== 'string' || raw.length === 0) { + throw new RuntimeClientError('invalid_argument', `Missing value for --${name}.`) + } + const value = parsePositiveSafeIntegerText(raw) + if (value === null) { + throw new RuntimeClientError( + 'invalid_argument', + `Invalid positive safe integer for --${name}: ${raw}` + ) + } + return value +} diff --git a/src/cli/handlers/orchestration/question-handler.ts b/src/cli/handlers/orchestration/question-handler.ts new file mode 100644 index 00000000000..1801f7f9b3c --- /dev/null +++ b/src/cli/handlers/orchestration/question-handler.ts @@ -0,0 +1,109 @@ +import type { CommandHandler } from '../../dispatch' +import { getOptionalStringFlag } from '../../flags' +import { RuntimeClientError } from '../../runtime-client' +import { + clampOrchestrationAskTimeoutMs, + resolveOrchestrationAskClientTimeoutMs +} from '../../../shared/orchestration-ask-timeout' +import type { LegacyCompatibilityResult } from '../../../shared/orchestration-check-output' +import { callOrchestrationMutation } from './mutation-request' +import { getOptionalPositiveIntegerValueFlag } from './numeric-flags' +import { + flushOrchestrationStdout, + resolveCompatibilityCliCommand, + resolvePackagedWindowsCompatibilityCommand +} from './runtime-compatibility' +import { resolveOrchestrationTerminalHandle } from './terminal-identity' + +export const ORCHESTRATION_QUESTION_HANDLER: Record = { + 'orchestration ask': async ({ flags, client, cwd, json }) => { + const parsedTimeoutMs = getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms') + const timeoutMs = clampOrchestrationAskTimeoutMs(parsedTimeoutMs) + const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') + const question = getOptionalStringFlag(flags, 'question') + const resume = getOptionalStringFlag(flags, 'resume') + if ((question ? 1 : 0) + (resume ? 1 : 0) !== 1) { + throw new RuntimeClientError( + 'invalid_argument', + 'Choose exactly one of --question or --resume.' + ) + } + if (resume && flags.has('options')) { + throw new RuntimeClientError( + 'invalid_argument', + '--options is only valid when creating a new question.' + ) + } + const result = await callOrchestrationMutation<{ + answer: string | null + messageId: string | null + threadId: string + timedOut: boolean + timeoutMs?: number + cancelled?: boolean + connectionLost?: boolean + answerMessageId?: string | null + legacyCompatibility?: LegacyCompatibilityResult + }>( + client, + flags, + 'orchestration.ask', + { + to: getOptionalStringFlag(flags, 'to'), + run: getOptionalStringFlag(flags, 'run'), + question, + resume, + options: getOptionalStringFlag(flags, 'options'), + timeoutMs: parsedTimeoutMs === undefined ? undefined : timeoutMs, + from, + compatibilityCliCommand: resolveCompatibilityCliCommand(), + compatibilityWindowsCommand: resolvePackagedWindowsCompatibilityCommand() + }, + // Why: extend past timeoutMs so transport does not abort before the runtime resolves its timeout. + { + timeoutMs: resolveOrchestrationAskClientTimeoutMs(parsedTimeoutMs), + orchestrationCapability: getOptionalStringFlag(flags, 'dispatch-capability') + } + ) + // Why: ask JSON is intentionally a bare object for `jq -r .answer`, unlike other verbs. + if (json) { + console.log(JSON.stringify(result.result)) + } else if (result.result.legacyCompatibility?.resumeRequired) { + console.log(`Question ${result.result.messageId} committed.`) + console.log(`Resume with: ${result.result.legacyCompatibility.resumeCommand}`) + } else if (result.result.answer !== null) { + console.log(result.result.answer) + } + if (result.result.legacyCompatibility?.resumeRequired) { + await flushOrchestrationStdout() + process.exitCode = 75 + return + } + const answerAck = result.result.legacyCompatibility?.answerAcknowledgement + if (answerAck && result.result.answer !== null) { + await flushOrchestrationStdout() + await client.call('orchestration.check', { + terminal: from, + compatibilityQuestionAck: JSON.stringify(answerAck) + }) + } + if (result.result.timedOut) { + if (!json) { + // Why: report the server's clamped effective budget rather than overstating the wait. + const waitedMs = result.result.timeoutMs ?? timeoutMs + console.error(`ask timeout after ${waitedMs}ms (thread ${result.result.threadId})`) + } + process.exitCode = 1 + } + if (result.result.cancelled) { + if (!json) { + console.error( + result.result.connectionLost + ? `ask connection closed (question ${result.result.messageId})` + : `ask cancelled (question ${result.result.messageId})` + ) + } + process.exitCode = 1 + } + } +} diff --git a/src/cli/handlers/orchestration/reset-handler.ts b/src/cli/handlers/orchestration/reset-handler.ts new file mode 100644 index 00000000000..536cc9e8933 --- /dev/null +++ b/src/cli/handlers/orchestration/reset-handler.ts @@ -0,0 +1,29 @@ +import type { CommandHandler } from '../../dispatch' +import { printResult } from '../../format' +import { RuntimeClientError } from '../../runtime-client' +import { callOrchestrationMutation } from './mutation-request' + +export const ORCHESTRATION_RESET_HANDLER: Record = { + 'orchestration reset': async ({ flags, client, json }) => { + const scopeCount = [flags.has('all'), flags.has('tasks'), flags.has('messages')].filter( + Boolean + ).length + if (scopeCount !== 1) { + throw new RuntimeClientError( + 'invalid_argument', + 'Choose exactly one reset scope: --all, --tasks, or --messages.' + ) + } + const result = await callOrchestrationMutation<{ reset: string }>( + client, + flags, + 'orchestration.reset', + { + all: flags.has('all') ? true : undefined, + tasks: flags.has('tasks') ? true : undefined, + messages: flags.has('messages') ? true : undefined + } + ) + printResult(result, json, (value) => `Reset: ${value.reset}`) + } +} diff --git a/src/cli/handlers/orchestration/run-handlers.ts b/src/cli/handlers/orchestration/run-handlers.ts new file mode 100644 index 00000000000..7c77913df15 --- /dev/null +++ b/src/cli/handlers/orchestration/run-handlers.ts @@ -0,0 +1,85 @@ +import type { CommandHandler } from '../../dispatch' +import { printResult } from '../../format' +import { + getOptionalPositiveIntegerFlag, + getOptionalStringFlag, + getRequiredStringFlag +} from '../../flags' +import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../../shared/orchestration-run-pagination' +import { callOrchestrationMutation } from './mutation-request' +import { resolveCoordinatorTerminalHandle } from './terminal-identity' + +export const ORCHESTRATION_RUN_HANDLERS: Record = { + 'orchestration run-create': async ({ flags, client, cwd, json }) => { + const from = await resolveCoordinatorTerminalHandle(flags, cwd, client) + const result = await callOrchestrationMutation<{ + run: { id: string; objective: string; consumer_generation: number } + }>(client, flags, 'orchestration.runCreate', { + objective: getRequiredStringFlag(flags, 'objective'), + from + }) + printResult(result, json, (r) => `Run ${r.run.id} created and bound: ${r.run.objective}`) + }, + + 'orchestration run-use': async ({ flags, client, cwd, json }) => { + const from = await resolveCoordinatorTerminalHandle(flags, cwd, client) + const result = await callOrchestrationMutation<{ + run: { id: string; objective: string; consumer_generation: number } + }>(client, flags, 'orchestration.runUse', { + id: getRequiredStringFlag(flags, 'id'), + from, + ...(flags.has('takeover-legacy') ? { takeoverLegacy: true } : {}) + }) + printResult(result, json, (r) => `Using Run ${r.run.id}: ${r.run.objective}`) + }, + + 'orchestration run-current': async ({ flags, client, cwd, json }) => { + const from = await resolveCoordinatorTerminalHandle(flags, cwd, client) + const result = await client.call<{ + run: { id: string; objective: string } | null + }>('orchestration.runCurrent', { from }) + printResult(result, json, (r) => + r.run ? `${r.run.id} ${r.run.objective}` : 'No Run is bound to this terminal.' + ) + }, + + 'orchestration run-list': async ({ flags, client, json }) => { + const result = await client.call<{ + runs: { id: string; objective: string; legacy: number }[] + nextCursor: string | null + }>('orchestration.runList', { + limit: getOptionalPositiveIntegerFlag(flags, 'limit') ?? ORCHESTRATION_RUN_PAGE_LIMIT, + cursor: getOptionalStringFlag(flags, 'cursor') + }) + printResult(result, json, (r) => { + const rows = + r.runs.length === 0 + ? 'No Runs found.' + : r.runs + .map( + (run) => `${run.id}${run.legacy ? ' [legacy, inspect only]' : ''} ${run.objective}` + ) + .join('\n') + return r.nextCursor ? `${rows}\nMore Runs: --cursor ${r.nextCursor}` : rows + }) + }, + + 'orchestration run-show': async ({ flags, client, json }) => { + const result = await client.call<{ + run: { + id: string + objective: string + consumer_generation: number + legacy: number + created_at: string + } + }>('orchestration.runShow', { id: getRequiredStringFlag(flags, 'id') }) + printResult( + result, + json, + (r) => + `${r.run.id}${r.run.legacy ? ' [legacy, inspect only]' : ''} ${r.run.objective}\n` + + `consumer generation ${r.run.consumer_generation}; created ${r.run.created_at}` + ) + } +} diff --git a/src/cli/handlers/orchestration/runtime-compatibility.ts b/src/cli/handlers/orchestration/runtime-compatibility.ts new file mode 100644 index 00000000000..e4076534daa --- /dev/null +++ b/src/cli/handlers/orchestration/runtime-compatibility.ts @@ -0,0 +1,42 @@ +import { RuntimeClientError } from '../../runtime-client' + +export function resolveCompatibilityCliCommand(): 'orca' | 'orca-ide' | 'orca-dev' { + const configured = process.env.ORCA_CLI_COMMAND + if (configured === 'orca' || configured === 'orca-ide' || configured === 'orca-dev') { + return configured + } + return process.platform === 'linux' ? 'orca-ide' : 'orca' +} + +export function resolvePackagedWindowsCompatibilityCommand(): 'orca' | 'orca-ide' | undefined { + if (process.env.ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER !== '1') { + return undefined + } + const command = process.env.ORCA_CLI_COMMAND + if (command === 'orca' || command === 'orca-ide') { + return command + } + throw new RuntimeClientError( + 'invalid_argument', + 'The packaged Orca launcher did not provide a valid resume command. No question was created.' + ) +} + +export async function flushOrchestrationStdout(): Promise { + await new Promise((resolve, reject) => { + process.stdout.write('', (error) => { + if (error) { + reject(error) + } else { + resolve() + } + }) + }) +} + +export function isDevCliInvocation(): boolean { + return ( + process.env.ORCA_DEV_CLI_INVOCATION === '1' || + (process.env.ORCA_USER_DATA_PATH?.includes('orca-dev') ?? false) + ) +} diff --git a/src/cli/handlers/orchestration/task-handlers.ts b/src/cli/handlers/orchestration/task-handlers.ts new file mode 100644 index 00000000000..c4c943261e8 --- /dev/null +++ b/src/cli/handlers/orchestration/task-handlers.ts @@ -0,0 +1,114 @@ +import type { CommandHandler } from '../../dispatch' +import { printResult } from '../../format' +import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags' +import { RuntimeClientError } from '../../runtime-client' +import { abbreviateOrchestrationTasks } from '../../../shared/orchestration-task-summary' +import { callOrchestrationMutation } from './mutation-request' +import { resolveCoordinatorTerminalHandle } from './terminal-identity' + +const TASK_STATUS_VALUES = [ + 'pending', + 'ready', + 'dispatched', + 'completed', + 'failed', + 'blocked' +] as const + +export const ORCHESTRATION_TASK_HANDLERS: Record = { + 'orchestration task-create': async ({ flags, client, cwd, json }) => { + const callerTerminalHandle = await resolveCoordinatorTerminalHandle(flags, cwd, client) + const result = await callOrchestrationMutation<{ task: { id: string; status: string } }>( + client, + flags, + 'orchestration.taskCreate', + { + spec: getRequiredStringFlag(flags, 'spec'), + taskTitle: getOptionalStringFlag(flags, 'task-title'), + displayName: getOptionalStringFlag(flags, 'display-name'), + deps: getOptionalStringFlag(flags, 'deps'), + parent: getOptionalStringFlag(flags, 'parent'), + run: getOptionalStringFlag(flags, 'run'), + callerTerminalHandle + } + ) + printResult(result, json, (r) => `Created ${r.task.id} [${r.task.status}]`) + }, + + 'orchestration task-list': async ({ flags, client, cwd, json }) => { + const brief = flags.has('brief') + const run = getOptionalStringFlag(flags, 'run') + const callerTerminalHandle = run + ? undefined + : await resolveCoordinatorTerminalHandle(flags, cwd, client) + const result = await client.call<{ + tasks: { + id: string + spec: string + task_title?: string | null + display_name?: string | null + status: string + assignee_handle?: string | null + dispatch_id?: string | null + spec_truncated?: boolean + }[] + count: number + runId?: string + legacyReadOnly?: boolean + }>('orchestration.taskList', { + status: getOptionalStringFlag(flags, 'status'), + ready: flags.has('ready') ? true : undefined, + brief: brief ? true : undefined, + run, + callerTerminalHandle + }) + // Why: only older runtimes (no spec_truncated) skip server-side abbreviation and need this client-side fallback. + const needsClientAbbreviation = + brief && result.result.tasks.some((task) => task.spec_truncated === undefined) + const output = needsClientAbbreviation + ? { + ...result, + result: { ...result.result, tasks: abbreviateOrchestrationTasks(result.result.tasks) } + } + : result + printResult(output, json, (r) => { + if (r.count === 0) { + return r.legacyReadOnly ? 'No legacy tasks (read-only).' : 'No tasks.' + } + const tasks = r.tasks + .map((task) => { + const label = task.display_name ?? task.task_title ?? task.spec + const head = `${task.id} [${task.status}] ${label.slice(0, 60)}` + if (task.status === 'dispatched' && task.assignee_handle) { + return `${head} -> ${task.assignee_handle} (${task.dispatch_id ?? '?'})` + } + return head + }) + .join('\n') + return r.legacyReadOnly ? `Legacy Run ${r.runId} (read-only)\n${tasks}` : tasks + }) + }, + + 'orchestration task-update': async ({ flags, client, cwd, json }) => { + const status = getRequiredStringFlag(flags, 'status') + if (!TASK_STATUS_VALUES.includes(status as (typeof TASK_STATUS_VALUES)[number])) { + throw new RuntimeClientError( + 'invalid_argument', + `invalid status '${status}', expected one of: ${TASK_STATUS_VALUES.join(', ')}` + ) + } + const result = await callOrchestrationMutation<{ task: { id: string; status: string } }>( + client, + flags, + 'orchestration.taskUpdate', + { + id: getRequiredStringFlag(flags, 'id'), + status, + result: getOptionalStringFlag(flags, 'result'), + run: getOptionalStringFlag(flags, 'run'), + callerTerminalHandle: await resolveCoordinatorTerminalHandle(flags, cwd, client) + } + ) + printResult(result, json, (r) => `Updated ${r.task.id} -> ${r.task.status}`) + } +} diff --git a/src/cli/handlers/orchestration/terminal-identity.ts b/src/cli/handlers/orchestration/terminal-identity.ts new file mode 100644 index 00000000000..5da11afac1d --- /dev/null +++ b/src/cli/handlers/orchestration/terminal-identity.ts @@ -0,0 +1,151 @@ +import type { RuntimeClient } from '../../runtime-client' +import { getOptionalStringFlag } from '../../flags' +import { RuntimeClientError } from '../../runtime-client' +import { getTerminalHandle } from '../../selectors' + +export async function resolveOrchestrationTerminalHandle( + flags: Map, + cwd: string, + client: RuntimeClient, + flagName: 'from' | 'terminal', + options: { validateEnvHandle?: boolean } = {} +): Promise { + const explicit = getOptionalStringFlag(flags, flagName) + if (explicit) { + return explicit + } + const envHandle = process.env.ORCA_TERMINAL_HANDLE + if (envHandle && envHandle.length > 0) { + if (flagName === 'from' && options.validateEnvHandle) { + // Why: long-lived shells can retain a stale ORCA_TERMINAL_HANDLE after remint; don't bake it into coordinator preambles. + const live = await isLiveTerminalHandle(envHandle, client) + if (!live) { + const reminted = await resolveOrchestrationPaneTerminalHandle(client) + if (reminted) { + return reminted + } + throwNoActiveSenderTerminal() + } + } + return envHandle + } + if (flagName === 'from') { + return await resolveImplicitOrchestrationSender(flags, cwd, client) + } + return await getTerminalHandle(flags, cwd, client) +} + +async function isLiveTerminalHandle(handle: string, client: RuntimeClient): Promise { + try { + await client.call('terminal.show', { terminal: handle }) + return true + } catch (err) { + if (isStaleTerminalIdentityError(err)) { + return false + } + throw err + } +} + +function getClientErrorCode(err: unknown): string | undefined { + if (!err || typeof err !== 'object') { + return undefined + } + const code = (err as { code?: unknown }).code + return typeof code === 'string' ? code : undefined +} + +function isStaleTerminalIdentityError(err: unknown): boolean { + const code = getClientErrorCode(err) + return code === 'terminal_handle_stale' || code === 'terminal_gone' +} + +function isNoActiveTerminalError(err: unknown): boolean { + return getClientErrorCode(err) === 'no_active_terminal' +} + +async function resolveOrchestrationPaneTerminalHandle( + client: RuntimeClient, + options: { optional?: boolean } = {} +): Promise { + const paneKey = process.env.ORCA_PANE_KEY + if (!paneKey || paneKey.length === 0) { + return undefined + } + try { + // Why: pane-key reminting preserves caller identity; focus-based active-terminal fallback can point at a different pane. + const response = await client.call<{ terminal: { handle: string } }>('terminal.resolvePane', { + paneKey + }) + return response.result.terminal.handle + } catch (err) { + if ( + isPaneRemintUnavailableError(err) || + (options.optional === true && isOptionalPaneRemintUnavailableError(err)) + ) { + return undefined + } + throw err + } +} + +function isPaneRemintUnavailableError(err: unknown): boolean { + const code = getClientErrorCode(err) + const message = getClientErrorMessage(err) + return ( + code === 'terminal_not_found' || + code === 'terminal_handle_stale' || + code === 'terminal_gone' || + message === 'terminal_not_found' || + message === 'terminal_handle_stale' || + message === 'terminal_gone' + ) +} + +function isOptionalPaneRemintUnavailableError(err: unknown): boolean { + return getClientErrorCode(err) === 'runtime_unavailable' +} + +function getClientErrorMessage(err: unknown): string | undefined { + if (err instanceof Error) { + return err.message + } + if (!err || typeof err !== 'object') { + return undefined + } + const message = (err as { message?: unknown }).message + return typeof message === 'string' ? message : undefined +} + +export async function resolveCoordinatorTerminalHandle( + flags: Map, + cwd: string, + client: RuntimeClient +): Promise { + return await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from', { + validateEnvHandle: true + }) +} + +async function resolveImplicitOrchestrationSender( + flags: Map, + cwd: string, + client: RuntimeClient +): Promise { + try { + return await getTerminalHandle(flags, cwd, client) + } catch (err) { + if (!isNoActiveTerminalError(err)) { + throw err + } + throwNoActiveSenderTerminal() + } +} + +export function throwNoActiveSenderTerminal(): never { + throw new RuntimeClientError( + 'no_active_sender_terminal', + 'Could not determine the sender terminal for this orchestration command. ' + + 'Pass --from or run the command inside a live Orca terminal with ORCA_TERMINAL_HANDLE set.' + ) +} diff --git a/src/cli/handlers/orchestration/worker-launch-handler.ts b/src/cli/handlers/orchestration/worker-launch-handler.ts new file mode 100644 index 00000000000..a6161e61ef0 --- /dev/null +++ b/src/cli/handlers/orchestration/worker-launch-handler.ts @@ -0,0 +1,70 @@ +import type { CommandHandler } from '../../dispatch' +import { printResult } from '../../format' +import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags' +import { RuntimeClientError } from '../../runtime-client' +import type { RuntimeStatus } from '../../../shared/runtime-types' +import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { callOrchestrationMutation } from './mutation-request' +import { getOptionalPositiveIntegerValueFlag } from './numeric-flags' +import { isDevCliInvocation } from './runtime-compatibility' +import { resolveCoordinatorTerminalHandle } from './terminal-identity' + +export const ORCHESTRATION_WORKER_LAUNCH_HANDLER: Record = { + 'orchestration worker-start': async ({ flags, client, cwd, json }) => { + const model = getOptionalStringFlag(flags, 'model') + const effort = getOptionalStringFlag(flags, 'effort') + if (model || effort) { + const status = await client.call('status.get') + if ( + !status.result.capabilities?.includes( + ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY + ) + ) { + throw new RuntimeClientError( + 'incompatible_runtime', + 'The connected Orca runtime does not support worker model or effort overrides. Update or restart Orca and try again.' + ) + } + } + const result = await callOrchestrationMutation<{ + runId: string + taskId: string + dispatchId: string + state: string + failedStage?: string + lastError?: string + warning?: string + effects: unknown[] + residualResources: unknown[] + }>(client, flags, 'orchestration.workerStart', { + task: getRequiredStringFlag(flags, 'task'), + on: getOptionalStringFlag(flags, 'on'), + worktree: getOptionalStringFlag(flags, 'worktree'), + name: getOptionalStringFlag(flags, 'name'), + repo: getOptionalStringFlag(flags, 'repo'), + baseBranch: getOptionalStringFlag(flags, 'base-branch'), + displayName: getOptionalStringFlag(flags, 'display-name'), + comment: getOptionalStringFlag(flags, 'comment'), + setup: getOptionalStringFlag(flags, 'setup'), + agent: getOptionalStringFlag(flags, 'agent'), + model, + effort, + terminal: getOptionalStringFlag(flags, 'terminal'), + retryOf: getOptionalStringFlag(flags, 'retry-of'), + timeoutMs: getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms'), + run: getOptionalStringFlag(flags, 'run'), + from: await resolveCoordinatorTerminalHandle(flags, cwd, client), + devMode: isDevCliInvocation() + }) + if (result.result.state !== 'ready') { + process.exitCode = 1 + } + printResult(result, json, (worker) => { + const base = `Worker ${worker.dispatchId} [${worker.state}] for ${worker.taskId}` + if (worker.lastError) { + return `${base}\n${worker.failedStage ?? 'start'}: ${worker.lastError}` + } + return worker.warning ? `${base}\nWarning: ${worker.warning}` : base + }) + } +} diff --git a/src/cli/handlers/orchestration/worker-observation-handlers.ts b/src/cli/handlers/orchestration/worker-observation-handlers.ts new file mode 100644 index 00000000000..29841f7390f --- /dev/null +++ b/src/cli/handlers/orchestration/worker-observation-handlers.ts @@ -0,0 +1,61 @@ +import type { CommandHandler } from '../../dispatch' +import { printResult } from '../../format' +import { + getOptionalPositiveIntegerFlag, + getOptionalStringFlag, + getRequiredStringFlag +} from '../../flags' +import { RuntimeClientError } from '../../runtime-client' +import type { + OrchestrationWorkerReadResult, + OrchestrationWorkerReadSource +} from '../../../shared/orchestration-worker-output' +import { formatWorkerRead, type LegacyWorkerReadResult } from './worker-output' + +export const ORCHESTRATION_WORKER_OBSERVATION_HANDLERS: Record = { + 'orchestration worker-show': async ({ flags, client, json }) => { + const result = await client.call<{ + dispatch: { id: string; task_id: string; status: string } + worker: { state: string; stage: string; agent_terminal_handle: string | null } + observation?: { agentWait?: { source: string; reason?: string } | null } + }>('orchestration.workerShow', { + dispatch: getRequiredStringFlag(flags, 'dispatch') + }) + printResult(result, json, (value) => { + const base = `${value.dispatch.id} task=${value.dispatch.task_id} [${value.worker.state}] stage=${value.worker.stage}` + // Why: absent means unknown on older runtimes, distinct from an evaluated null wait. + if (value.observation === undefined || !('agentWait' in value.observation)) { + return `${base}\nInteractive wait: unknown (not evaluated)` + } + const wait = value.observation.agentWait + return wait + ? `${base}\nWaiting on a human: ${wait.reason ?? 'interactive prompt'} (via ${wait.source})` + : `${base}\nInteractive wait: none` + }) + }, + + 'orchestration worker-read': async ({ flags, client, json }) => { + const cursorFlag = getOptionalStringFlag(flags, 'cursor') + const cursor = + cursorFlag !== undefined && /^\d+$/.test(cursorFlag) + ? Number.parseInt(cursorFlag, 10) + : cursorFlag + const source = getOptionalStringFlag(flags, 'source') + if (source && !['auto', 'transcript', 'terminal'].includes(source)) { + throw new RuntimeClientError( + 'invalid_argument', + '--source must be auto, transcript, or terminal' + ) + } + const result = await client.call( + 'orchestration.workerRead', + { + dispatch: getRequiredStringFlag(flags, 'dispatch'), + cursor, + limit: getOptionalPositiveIntegerFlag(flags, 'limit'), + source: source as OrchestrationWorkerReadSource | undefined + } + ) + printResult(result, json, formatWorkerRead) + } +} diff --git a/src/cli/handlers/orchestration/worker-output.ts b/src/cli/handlers/orchestration/worker-output.ts new file mode 100644 index 00000000000..572f33117dd --- /dev/null +++ b/src/cli/handlers/orchestration/worker-output.ts @@ -0,0 +1,68 @@ +import type { NativeChatMessage } from '../../../shared/native-chat-types' +import type { RuntimeTerminalRead } from '../../../shared/runtime-types' +import type { OrchestrationWorkerReadResult } from '../../../shared/orchestration-worker-output' + +export type LegacyWorkerReadResult = { + dispatchId: string + terminal: RuntimeTerminalRead +} + +export function formatWorkerRead( + value: OrchestrationWorkerReadResult | LegacyWorkerReadResult +): string { + if (!('source' in value) || value.source === 'terminal') { + return value.terminal.tail.join('\n') + } + return value.transcript.messages.map(formatWorkerTranscriptMessage).join('\n\n') +} + +function formatWorkerTranscriptMessage(message: NativeChatMessage): string { + const blocks = message.blocks.map((block) => { + if (block.type === 'text') { + return block.text + } + if (block.type === 'tool-call') { + return `[tool ${block.name}] ${safeJson(block.input)}` + } + if (block.type === 'tool-result') { + return `[tool result${block.isError ? ' error' : ''}] ${block.output}` + } + return block.url ? `[image] ${block.url}` : `[image omitted]` + }) + return `[${message.role}] ${blocks.join('\n')}`.trimEnd() +} + +export type WorkerReleaseReceipt = { + dispatchId: string + state: string + reason?: string + processAction: string + archive: { source: string | null; status: string | null } | null + recovery?: string + lastError?: string +} + +export function formatWorkerRelease(value: WorkerReleaseReceipt): string { + const head = `Worker ${value.dispatchId} terminal [${value.state}]` + const lines = [ + `${head}${value.reason ? ` reason=${value.reason}` : ''} process=${value.processAction}` + ] + if (value.archive) { + lines.push(`archive ${value.archive.source ?? 'none'} [${value.archive.status ?? 'unknown'}]`) + } + if (value.lastError) { + lines.push(value.lastError) + } + if (value.recovery) { + lines.push(value.recovery) + } + return lines.join('\n') +} + +function safeJson(value: unknown): string { + try { + return JSON.stringify(value) + } catch { + return '[unserializable input]' + } +} diff --git a/src/cli/handlers/orchestration/worker-terminal-handlers.ts b/src/cli/handlers/orchestration/worker-terminal-handlers.ts new file mode 100644 index 00000000000..20e14c1da7f --- /dev/null +++ b/src/cli/handlers/orchestration/worker-terminal-handlers.ts @@ -0,0 +1,126 @@ +import type { CommandHandler } from '../../dispatch' +import { printResult } from '../../format' +import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags' +import { RuntimeClientError } from '../../runtime-client' +import { callOrchestrationMutation } from './mutation-request' +import { formatWorkerRelease, type WorkerReleaseReceipt } from './worker-output' + +const WORKER_TERMINAL_LIST_STATES = [ + 'active', + 'reclaimable', + 'retained', + 'release_pending', + 'release_unknown', + 'released' +] as const + +export const ORCHESTRATION_WORKER_TERMINAL_HANDLERS: Record = { + 'orchestration worker-stop': async ({ flags, client, json }) => { + const result = await callOrchestrationMutation<{ + dispatchId: string + state: string + processAction: string + lastError?: string + warning?: string + }>(client, flags, 'orchestration.workerStop', { + dispatch: getRequiredStringFlag(flags, 'dispatch') + }) + if (result.result.state === 'stop_unknown') { + process.exitCode = 1 + } + printResult( + result, + json, + (value) => + `Worker ${value.dispatchId} [${value.state}] process=${value.processAction}${value.lastError ? `\n${value.lastError}` : ''}${value.warning ? `\nWarning: ${value.warning}` : ''}` + ) + }, + + 'orchestration worker-abandon': async ({ flags, client, json }) => { + const result = await callOrchestrationMutation<{ + dispatchId: string + state: string + warning: string + }>(client, flags, 'orchestration.workerAbandon', { + dispatch: getRequiredStringFlag(flags, 'dispatch') + }) + printResult( + result, + json, + (value) => `Worker ${value.dispatchId} [${value.state}]\nWarning: ${value.warning}` + ) + }, + + 'orchestration worker-release': async ({ flags, client, json }) => { + const result = await callOrchestrationMutation( + client, + flags, + 'orchestration.workerRelease', + { dispatch: getRequiredStringFlag(flags, 'dispatch') } + ) + // Why: only an unprovable close is a failure; retained/pending/already-released are settled answers. + if (result.result.state === 'release_unknown') { + process.exitCode = 1 + } + printResult(result, json, formatWorkerRelease) + }, + + 'orchestration worker-retain': async ({ flags, client, json }) => { + const result = await callOrchestrationMutation( + client, + flags, + 'orchestration.workerRetain', + { dispatch: getRequiredStringFlag(flags, 'dispatch') } + ) + if (result.result.state === 'release_unknown') { + process.exitCode = 1 + } + printResult(result, json, formatWorkerRelease) + }, + + 'orchestration worker-list': async ({ flags, client, json }) => { + const terminalState = getOptionalStringFlag(flags, 'terminal-state') + if ( + terminalState && + !WORKER_TERMINAL_LIST_STATES.includes( + terminalState as (typeof WORKER_TERMINAL_LIST_STATES)[number] + ) + ) { + throw new RuntimeClientError( + 'invalid_argument', + `invalid --terminal-state '${terminalState}', expected one of: ${WORKER_TERMINAL_LIST_STATES.join(', ')}` + ) + } + const result = await client.call<{ + workers: { + dispatchId: string + taskId: string + runId: string + workerState: string + dispatchStatus: string + agentTerminalHandle: string | null + terminalState: string | null + resource: unknown + }[] + counts: Record + }>('orchestration.workerList', { + run: getOptionalStringFlag(flags, 'run'), + terminalState + }) + printResult(result, json, (value) => { + if (value.workers.length === 0) { + return 'No workers found.' + } + const rows = value.workers + .map( + (worker) => + `${worker.dispatchId} task=${worker.taskId} [${worker.workerState}] terminal=${worker.terminalState ?? 'none'}` + ) + .join('\n') + const counts = Object.entries(value.counts) + .map(([state, count]) => `${state}=${count}`) + .join(' ') + return counts ? `${rows}\nTerminals: ${counts}` : rows + }) + } +} diff --git a/src/main/agent-hooks/ended-process-reconciliation.test.ts b/src/main/agent-hooks/ended-process-reconciliation.test.ts new file mode 100644 index 00000000000..0719b584d8a --- /dev/null +++ b/src/main/agent-hooks/ended-process-reconciliation.test.ts @@ -0,0 +1,179 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { makePaneKey } from '../../shared/stable-pane-id' +import { AgentHookServer } from './server' + +const LEAF = '11111111-1111-4111-8111-111111111111' +const PANE = makePaneKey('tab-1', LEAF) + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-ended-process-')) +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +async function startServer(): Promise { + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath: dir }) + return server +} + +function claudeRow(server: AgentHookServer, state: 'working' | 'waiting' | 'done'): void { + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + payload: { state, prompt: 'review the PR', agentType: 'claude' } + }) +} + +/** A row carrying a resumable provider session — the only shape `dropStatusEntry` retains. */ +function resumableClaudeRow(server: AgentHookServer): void { + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + source: 'claude', + hookEventName: 'UserPromptSubmit', + launchToken: 'dead-launch-token', + providerSession: { key: 'session_id', id: 'resume-me' }, + payload: { state: 'working', prompt: 'review the PR', agentType: 'claude' } + }, + 'conn-a' + ) +} + +function paneState(server: AgentHookServer): string { + return server.getStatusSnapshotForPane(PANE)[0]?.state ?? 'missing' +} + +// The teardown that normally clears a dead pane depends on the spawn-time ptyPaneKey mapping, which +// a restored PTY may never rebuild — leaving the row and its latches with nothing left to retire +// them. See PLAN-STA-4612 §4.2. +describe('reconcileEndedProcessForPaneKeys', () => { + it('retires a working row whose process is certifiably dead', async () => { + const server = await startServer() + try { + claudeRow(server, 'working') + expect(paneState(server)).toBe('working') + + expect(server.reconcileEndedProcessForPaneKeys([PANE])).toBe(1) + + expect(paneState(server)).toBe('missing') + } finally { + server.stop() + } + }) + + it('retires a waiting row — a wait whose owner is dead is not a wait', async () => { + const server = await startServer() + try { + claudeRow(server, 'waiting') + + expect(server.reconcileEndedProcessForPaneKeys([PANE])).toBe(1) + + expect(paneState(server)).toBe('missing') + } finally { + server.stop() + } + }) + + it('clears Claude latches even when the stored row already reads done', async () => { + // An interrupted lead suppresses the gate while leaving the latch set, so a row can read `done` + // with a latch that would re-gate `working` on the pane's very next event. + const server = await startServer() + try { + claudeRow(server, 'done') + server._getStateForTests().claudeRunningNonAgentTaskPaneKeys.add(PANE) + server._getStateForTests().claudeActiveSessionCronPaneKeys.add(PANE) + + expect(server.reconcileEndedProcessForPaneKeys([PANE])).toBe(1) + + expect(server._getStateForTests().claudeRunningNonAgentTaskPaneKeys.has(PANE)).toBe(false) + expect(server._getStateForTests().claudeActiveSessionCronPaneKeys.has(PANE)).toBe(false) + } finally { + server.stop() + } + }) + + it('is a no-op for a pane with nothing to retire', async () => { + const server = await startServer() + try { + expect(server.reconcileEndedProcessForPaneKeys([PANE])).toBe(0) + } finally { + server.stop() + } + }) + + it('keeps the resume identity a paired dismissal minted when the shell outlived the agent', async () => { + // The renderer's confirmed-shell route calls agentStatus:drop FIRST, which deliberately mints a + // providerSessionOnly remnant, then this. That remnant carries no state claim — it cannot gate a + // pane `working` — and the pane's PTY is still there to resume into, so retiring the pane's live + // claims must not take it. Without the option it goes with everything else. + const server = await startServer() + try { + resumableClaudeRow(server) + server.dropStatusEntry(PANE) + const afterDrop = server.getStatusSnapshotForPane(PANE)[0] + expect(afterDrop?.providerSessionOnly).toBe(true) + expect(afterDrop?.providerSession?.id).toBe('resume-me') + + expect( + server.reconcileEndedProcessForPaneKeys([PANE], { preserveResumeIdentity: true }) + ).toBe(1) + + const kept = server.getStatusSnapshotForPane(PANE)[0] + expect(kept?.providerSessionOnly).toBe(true) + expect(kept?.providerSession?.id).toBe('resume-me') + expect(kept?.launchToken).toBeUndefined() + // The live claims still went: a latch left behind would re-gate the pane on its next event. + expect(server._getStateForTests().claudeRunningNonAgentTaskPaneKeys.has(PANE)).toBe(false) + + // The retained identity must not recreate dead launch authority after restart. + server.flushStatusPersistSync() + server.stop() + const restarted = await startServer() + expect(restarted.getStatusSnapshotForPane(PANE)[0]?.launchToken).toBeUndefined() + expect(restarted.getHydratedAuthorityCommitments()).toHaveLength(0) + restarted.stop() + } finally { + server.stop() + } + }) + + it('takes the resume identity too on a certified PTY exit, where no pane is left to resume into', async () => { + const server = await startServer() + try { + resumableClaudeRow(server) + server.dropStatusEntry(PANE) + + expect(server.reconcileEndedProcessForPaneKeys([PANE])).toBe(1) + + expect(paneState(server)).toBe('missing') + } finally { + server.stop() + } + }) + + it('drops the session owner so a rebound pane is not read as a session replacement', async () => { + const server = await startServer() + try { + claudeRow(server, 'working') + server._getStateForTests().claudeSessionOwnerByPaneKey.set(PANE, 'session-a') + + server.reconcileEndedProcessForPaneKeys([PANE]) + + expect(server._getStateForTests().claudeSessionOwnerByPaneKey.has(PANE)).toBe(false) + } finally { + server.stop() + } + }) +}) diff --git a/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts b/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts index d554c6e4e00..5f28a40c039 100644 --- a/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts +++ b/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts @@ -38,6 +38,7 @@ describe('createHookStatusSessionTabsInvalidator', () => { it.each([ ['state', { state: 'waiting' as const }], + ['workingMode', { workingMode: 'monitoring' as const }], ['prompt', { prompt: 'ship it' }], ['agentType', { agentType: 'codex' }], ['toolName', { toolName: 'Bash' }], diff --git a/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts b/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts index 61d9fe00f0e..0a36eef0f56 100644 --- a/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts +++ b/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts @@ -33,6 +33,7 @@ export function createHookStatusSessionTabsInvalidator(): { return ( !previous || previous.payload.state !== next.state || + previous.payload.workingMode !== next.workingMode || previous.payload.prompt !== next.prompt || (previous.payload.agentType ?? null) !== (next.agentType ?? null) || (previous.payload.toolName ?? null) !== (next.toolName ?? null) || diff --git a/src/main/agent-hooks/manual-compact-hook-stream.test.ts b/src/main/agent-hooks/manual-compact-hook-stream.test.ts index 22b1b0605c2..1a122349ca2 100644 --- a/src/main/agent-hooks/manual-compact-hook-stream.test.ts +++ b/src/main/agent-hooks/manual-compact-hook-stream.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { RelayAgentHookServer } from '../../relay/agent-hook-server' +import { seedClaudeSubagentRosterFromSnapshots } from '../../shared/agent-hook-listener' import type { AgentHookRelayEnvelope } from '../../shared/agent-hook-relay' import { makePaneKey } from '../../shared/stable-pane-id' import { AgentHookServer } from './server' @@ -12,25 +13,11 @@ vi.mock('../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({})) })) const PANE_KEY = makePaneKey('manual-compact', '11111111-1111-4111-8111-111111111111') -const PROMPT_ID_1 = '22222222-2222-4222-8222-222222222222' -const PROMPT_ID_2 = '33333333-3333-4333-8333-333333333333' +const COMPACT_PROMPT_ID = '22222222-2222-4222-8222-222222222222' +const TURN_PROMPT_ID = '33333333-3333-4333-8333-333333333333' const SESSION = { key: 'session_id' as const, id: 'session-a' } -type ClaudeHook = { - hook_event_name: string - prompt?: string - prompt_id: string - session_id: string - trigger?: 'manual' | 'auto' - agent_id?: string - agent_type?: string -} - -function claudeHook( - hookEventName: string, - promptId: string, - extra: Partial = {} -): ClaudeHook { +function claudeHook(hookEventName: string, promptId: string, extra: Record = {}) { return { hook_event_name: hookEventName, prompt_id: promptId, @@ -39,30 +26,66 @@ function claudeHook( } } -function manualEnvelope(hookEventName: string, state: 'working' | 'done') { - return { - source: 'claude' as const, - paneKey: PANE_KEY, - hasExplicitPrompt: hookEventName === 'UserPromptSubmit' ? true : undefined, - hookEventName, - providerPromptId: hookEventName === 'UserPromptSubmit' ? PROMPT_ID_2 : PROMPT_ID_1, - compactTrigger: hookEventName === 'UserPromptSubmit' ? undefined : ('manual' as const), - providerSession: SESSION, - payload: { state, prompt: 'work before compact', agentType: 'claude' as const } - } -} - function postHook(port: number, token: string, payload: Record) { return fetch(`http://127.0.0.1:${port}/hook/claude`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': token - }, + headers: { 'Content-Type': 'application/json', 'X-Orca-Agent-Hook-Token': token }, body: JSON.stringify({ paneKey: PANE_KEY, payload }) }) } +function turnEnvelope(): AgentHookRelayEnvelope { + return { + source: 'claude', + paneKey: PANE_KEY, + connectionId: null, + hasExplicitPrompt: true, + hookEventName: 'UserPromptSubmit', + providerPromptId: TURN_PROMPT_ID, + providerSession: SESSION, + payload: { state: 'working', prompt: 'work before compact', agentType: 'claude' } + } as AgentHookRelayEnvelope +} + +/** What a relay predating this change forwards: it ran its own shipped normalizer, so a manual + * compact arrives as a PLAIN `done` (no session boundary) and an auto compact as `working`. */ +function legacyRelayCompactEnvelope( + state: 'done' | 'working', + overrides: Partial = {} +): AgentHookRelayEnvelope { + return { + source: 'claude', + paneKey: PANE_KEY, + connectionId: null, + hookEventName: 'PostCompact', + providerPromptId: COMPACT_PROMPT_ID, + compactTrigger: state === 'done' ? 'manual' : 'auto', + providerSession: SESSION, + payload: { state, prompt: 'work before compact', agentType: 'claude' }, + ...overrides + } as AgentHookRelayEnvelope +} + +/** What AgentHookServer.hydrate() rebuilds for a pane that was stuck `working` across a restart: + * the previous session's connectionId, the unconfirmed flag, an older receivedAt, and the child + * the turn had spawned — restored from disk, so proof of nothing. */ +function seedHydratedStuckPane(server: AgentHookServer, receivedAt: number) { + const state = server._getStateForTests() + const subagents = [{ id: 'child-1', state: 'working', startedAt: 0, agentType: 'general' }] + state.lastStatusByPaneKey.set(PANE_KEY, { + paneKey: PANE_KEY, + source: 'claude', + connectionId: null, + hookEventName: 'UserPromptSubmit', + providerPromptId: TURN_PROMPT_ID, + providerSession: SESSION, + restoredUnconfirmed: true, + receivedAt, + payload: { state: 'working', prompt: 'work before the restart', agentType: 'claude', subagents } + } as never) + seedClaudeSubagentRosterFromSnapshots(state, PANE_KEY, subagents as never) +} + describe('manual Claude compact hook stream', () => { const servers: { stop: () => void }[] = [] const temporaryPaths: string[] = [] @@ -76,75 +99,112 @@ describe('manual Claude compact hook stream', () => { } }) - it('settles an exact local HTTP lifecycle and rejects a duplicate completion', async () => { + it('settles the measured local lifecycle and rejects a duplicate completion', async () => { const server = new AgentHookServer() servers.push(server) await server.start({ env: 'production' }) const env = server.buildPtyEnv() + const port = Number(env.ORCA_AGENT_HOOK_PORT) + const token = env.ORCA_AGENT_HOOK_TOKEN const events: string[] = [] const unsubscribe = server.subscribeEnrichedStatus((event) => { events.push(`${event.hookEventName}:${event.payload.state}`) }) + await postHook(port, token, claudeHook('UserPromptSubmit', TURN_PROMPT_ID, { prompt: 'work' })) + // Measured stream for a successful manual /compact. PreCompact is not registered in production + // and is rejected here too; the summarizer's start-less SubagentStop only republishes state. + await postHook(port, token, claudeHook('PreCompact', COMPACT_PROMPT_ID, { trigger: 'manual' })) await postHook( - Number(env.ORCA_AGENT_HOOK_PORT), - env.ORCA_AGENT_HOOK_TOKEN, - claudeHook('UserPromptSubmit', PROMPT_ID_2, { prompt: 'work before compact' }) - ) - await postHook( - Number(env.ORCA_AGENT_HOOK_PORT), - env.ORCA_AGENT_HOOK_TOKEN, - claudeHook('PreCompact', PROMPT_ID_1, { trigger: 'manual' }) - ) - await postHook( - Number(env.ORCA_AGENT_HOOK_PORT), - env.ORCA_AGENT_HOOK_TOKEN, - claudeHook('SubagentStart', PROMPT_ID_1, { - agent_id: 'compact-agent', - agent_type: 'general-purpose' + port, + token, + claudeHook('SubagentStop', COMPACT_PROMPT_ID, { + agent_id: 'a75b38b59774e1f31', + agent_type: '', + background_tasks: [], + session_crons: [] }) ) await postHook( - Number(env.ORCA_AGENT_HOOK_PORT), - env.ORCA_AGENT_HOOK_TOKEN, - claudeHook('SubagentStop', PROMPT_ID_1, { - agent_id: 'compact-agent', - agent_type: 'general-purpose' - }) - ) - await postHook( - Number(env.ORCA_AGENT_HOOK_PORT), - env.ORCA_AGENT_HOOK_TOKEN, - claudeHook('PostCompact', PROMPT_ID_1, { trigger: 'manual' }) - ) - await postHook( - Number(env.ORCA_AGENT_HOOK_PORT), - env.ORCA_AGENT_HOOK_TOKEN, - claudeHook('PostCompact', PROMPT_ID_1, { trigger: 'manual' }) + port, + token, + claudeHook('SessionStart', COMPACT_PROMPT_ID, { source: 'compact' }) ) + await postHook(port, token, claudeHook('PostCompact', COMPACT_PROMPT_ID, { trigger: 'manual' })) + await postHook(port, token, claudeHook('PostCompact', COMPACT_PROMPT_ID, { trigger: 'manual' })) - expect(events).toEqual([ - 'UserPromptSubmit:working', - 'PreCompact:working', - 'SubagentStart:working', - 'SubagentStop:working', - 'PostCompact:done' - ]) + expect(events).toEqual(['UserPromptSubmit:working', 'SubagentStop:working', 'PostCompact:done']) expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - state: 'done', - prompt: 'work before compact', - agentType: 'claude' - }) + expect.objectContaining({ state: 'done', prompt: 'work', agentType: 'claude' }) ]) unsubscribe() }) - it('preserves the manual identity over relay and rejects stale transport identities', async () => { + it('clears the restart-stuck pane STA-2915 reports, without resurrecting anything', async () => { + const server = new AgentHookServer() + servers.push(server) + await server.start({ env: 'production' }) + const env = server.buildPtyEnv() + const port = Number(env.ORCA_AGENT_HOOK_PORT) + const token = env.ORCA_AGENT_HOOK_TOKEN! + seedHydratedStuckPane(server, Date.now() - 60_000) + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) + + await postHook(port, token, claudeHook('PreCompact', COMPACT_PROMPT_ID, { trigger: 'manual' })) + await postHook( + port, + token, + claudeHook('SubagentStop', COMPACT_PROMPT_ID, { + agent_id: 'a75b38b59774e1f31', + agent_type: '' + }) + ) + await postHook( + port, + token, + claudeHook('SessionStart', COMPACT_PROMPT_ID, { source: 'compact' }) + ) + await postHook(port, token, claudeHook('PostCompact', COMPACT_PROMPT_ID, { trigger: 'manual' })) + + const row = server.getStatusSnapshot()[0] + expect(row).toMatchObject({ state: 'done', prompt: 'work before the restart' }) + expect(row?.subagents ?? []).toEqual([]) + }) + + it('does not restate a pane a live child still owns, so the staleness clock keeps running', async () => { + const server = new AgentHookServer() + servers.push(server) + await server.start({ env: 'production' }) + const env = server.buildPtyEnv() + const port = Number(env.ORCA_AGENT_HOOK_PORT) + const token = env.ORCA_AGENT_HOOK_TOKEN! + seedHydratedStuckPane(server, Date.now() - 60_000) + // A child THIS runtime observed: real agent work in flight, which a compact may not retire. + await postHook( + port, + token, + claudeHook('SubagentStart', TURN_PROMPT_ID, { agent_id: 'child-live' }) + ) + const before = server.getStatusSnapshot()[0] + expect(before).toMatchObject({ state: 'working' }) + + const emitted: string[] = [] + const unsubscribe = server.subscribeEnrichedStatus((event) => { + emitted.push(`${event.hookEventName}:${event.payload.state}`) + }) + await postHook(port, token, claudeHook('PostCompact', COMPACT_PROMPT_ID, { trigger: 'manual' })) + + // Why: restating the row here is the regression, not the fix — it would refresh receivedAt and + // hand the stale sweep a brand-new clock for work the compact never observed. + expect(emitted).toEqual([]) + expect(server.getStatusSnapshot()[0]).toEqual(before) + unsubscribe() + }) + + it('forwards the completion over the relay and preserves its cached compact identity', async () => { const main = new AgentHookServer() const forwarded: AgentHookRelayEnvelope[] = [] const emitted: string[] = [] - const connectionId = 'conn-a' const endpointDir = mkdtempSync(join(tmpdir(), 'orca-compact-relay-')) temporaryPaths.push(endpointDir) const relay = new RelayAgentHookServer({ @@ -152,7 +212,7 @@ describe('manual Claude compact hook stream', () => { token: 'manual-compact-token', forward: (envelope) => { forwarded.push(envelope) - main.ingestRemote(envelope, connectionId) + main.ingestRemote(envelope, 'conn-a') } }) servers.push(main, relay) @@ -160,208 +220,253 @@ describe('manual Claude compact hook stream', () => { emitted.push(`${event.hookEventName}:${event.payload.state}`) }) await relay.start({ publishEndpoint: false }) - const coordinates = relay.getCoordinates() + const { port, token } = relay.getCoordinates() - await postHook( - coordinates.port, - coordinates.token, - claudeHook('UserPromptSubmit', PROMPT_ID_2, { prompt: 'work before compact' }) - ) - // A restarted relay has no prior listener cache; main still owns the status boundary. - relay.clearPaneState(PANE_KEY) - await postHook( - coordinates.port, - coordinates.token, - claudeHook('PreCompact', PROMPT_ID_1, { trigger: 'manual' }) - ) - const validPost = { - ...forwarded.at(-1)!, - hookEventName: 'PostCompact', - payload: { - state: 'done' as const, - prompt: 'work before compact', - agentType: 'claude' as const - } - } - - main.ingestRemote({ ...validPost, providerPromptId: PROMPT_ID_2 }, 'conn-a') - main.ingestRemote({ ...validPost, providerPromptId: undefined }, 'conn-a') - main.ingestRemote({ ...validPost, providerSession: undefined }, 'conn-a') - main.ingestRemote({ ...validPost, source: 'codex' }, 'conn-a') - main.ingestRemote(validPost, 'conn-b') - - expect(emitted).toEqual(['UserPromptSubmit:working', 'PreCompact:working']) - expect(main.getStatusSnapshot()[0]).toMatchObject({ - state: 'working', - prompt: 'work before compact' - }) - - await postHook( - coordinates.port, - coordinates.token, - claudeHook('SubagentStart', PROMPT_ID_1, { - agent_id: 'compact-agent', - agent_type: 'general-purpose' - }) - ) - await postHook( - coordinates.port, - coordinates.token, - claudeHook('SubagentStop', PROMPT_ID_1, { - agent_id: 'compact-agent', - agent_type: 'general-purpose' - }) - ) - await postHook( - coordinates.port, - coordinates.token, - claudeHook('PostCompact', PROMPT_ID_1, { trigger: 'manual' }) - ) + await postHook(port, token, claudeHook('UserPromptSubmit', TURN_PROMPT_ID, { prompt: 'work' })) + await postHook(port, token, claudeHook('PostCompact', COMPACT_PROMPT_ID, { trigger: 'manual' })) expect(forwarded.at(-1)).toMatchObject({ source: 'claude', - providerPromptId: PROMPT_ID_1, + hookEventName: 'PostCompact', compactTrigger: 'manual', - providerSession: SESSION, - hookEventName: 'PostCompact' + providerPromptId: COMPACT_PROMPT_ID }) expect(emitted.at(-1)).toBe('PostCompact:done') - expect(emitted.slice(-3)).toEqual([ - 'SubagentStart:working', - 'SubagentStop:working', - 'PostCompact:done' - ]) - expect(main.getStatusSnapshot()[0]).toMatchObject({ - state: 'done', - prompt: 'work before compact' + expect(main.getStatusSnapshot()[0]).toMatchObject({ state: 'done', sessionBoundary: true }) + + // Why: a client that was offline during the compact receives the row as a reconnect replay of + // this cache. Retaining the compact identity lets the client re-run ownership instead of + // treating an unowned completion as an ordinary status row. + const replayed = relay.replayCachedPayloadsForPanes() + expect(replayed).toBeGreaterThan(0) + expect(forwarded.at(-1)).toMatchObject({ isReplay: true, payload: { state: 'done' } }) + expect(forwarded.at(-1)).toMatchObject({ + hookEventName: 'PostCompact', + compactTrigger: 'manual' }) unsubscribe() }) - it('invalidates a compact completion when later provider work owns the pane', () => { - const server = new AgentHookServer() - servers.push(server) - server.ingestRemote(manualEnvelope('UserPromptSubmit', 'working'), 'conn-a') - server.ingestRemote(manualEnvelope('PreCompact', 'working'), 'conn-a') - server.ingestRemote( - { - source: 'codex', - paneKey: PANE_KEY, - hookEventName: 'UserPromptSubmit', - hasExplicitPrompt: true, - payload: { state: 'working', prompt: 'new work', agentType: 'codex' } - }, - 'conn-a' - ) - server.ingestRemote({ ...manualEnvelope('PreCompact', 'working'), isReplay: true }, 'conn-a') - server.ingestRemote(manualEnvelope('PostCompact', 'done'), 'conn-a') - - expect(server.getStatusSnapshot()[0]).toMatchObject({ - state: 'working', - prompt: 'new work', - agentType: 'claude' - }) - expect(server._getStateForTests().lastStatusByPaneKey.get(PANE_KEY)).toMatchObject({ - source: 'codex', - hookEventName: 'UserPromptSubmit' - }) - }) - - it('keeps automatic compact hooks working without settling the turn', async () => { + it('forwards the completion from a relay whose cache is cold, and the client still guards it', async () => { const main = new AgentHookServer() const forwarded: AgentHookRelayEnvelope[] = [] - const endpointDir = mkdtempSync(join(tmpdir(), 'orca-auto-compact-relay-')) + const endpointDir = mkdtempSync(join(tmpdir(), 'orca-compact-cold-')) temporaryPaths.push(endpointDir) + // A relay that restarted while the agent session kept running: hooks resolve the endpoint file + // per invocation, so they reconnect — but the relay's per-process cache is empty, and the + // compact completion may be the first event it ever sees for this pane. const relay = new RelayAgentHookServer({ endpointDir, + token: 'cold-cache-token', forward: (envelope) => { forwarded.push(envelope) - main.ingestRemote(envelope, 'conn-auto') + main.ingestRemote(envelope, 'conn-a') } }) servers.push(main, relay) await relay.start({ publishEndpoint: false }) const { port, token } = relay.getCoordinates() - await postHook( - port, - token, - claudeHook('UserPromptSubmit', PROMPT_ID_1, { prompt: 'continue the task' }) - ) - await postHook(port, token, claudeHook('PreCompact', PROMPT_ID_1, { trigger: 'auto' })) - await postHook(port, token, claudeHook('PostCompact', PROMPT_ID_1, { trigger: 'auto' })) + await postHook(port, token, claudeHook('PostCompact', COMPACT_PROMPT_ID, { trigger: 'manual' })) - expect(forwarded.map((event) => event.hookEventName)).toEqual([ - 'UserPromptSubmit', - 'PreCompact', - 'PostCompact' - ]) - expect(main.getStatusSnapshot()[0]).toMatchObject({ state: 'working', agentType: 'claude' }) - expect(main._getStateForTests().lastStatusByPaneKey.get(PANE_KEY)).toMatchObject({ - hookEventName: 'PostCompact', - compactTrigger: undefined + // Why: the relay is a forwarder, not the authority on pane identity. Dropping the event here + // is how the one signal that can clear a remote pane goes missing after a restart. + expect(forwarded.map((envelope) => envelope.hookEventName)).toEqual(['PostCompact']) + expect(forwarded[0]).toMatchObject({ + compactTrigger: 'manual', + providerPromptId: COMPACT_PROMPT_ID }) + // ...and the client, which DOES own pane identity, still refuses to mint a row it never had. + expect(main.getStatusSnapshot()).toEqual([]) }) - it('rejects unproven compact sources at the main relay boundary', () => { + it('does not resurrect a retired pane when the cold relay replays its completion', async () => { + const main = new AgentHookServer() + const endpointDir = mkdtempSync(join(tmpdir(), 'orca-compact-cold-replay-')) + temporaryPaths.push(endpointDir) + const relay = new RelayAgentHookServer({ + endpointDir, + token: 'cold-replay-token', + forward: (envelope) => main.ingestRemote(envelope, 'conn-a') + }) + servers.push(main, relay) + await relay.start({ publishEndpoint: false }) + const { port, token } = relay.getCoordinates() + + await postHook(port, token, claudeHook('PostCompact', COMPACT_PROMPT_ID, { trigger: 'manual' })) + expect(main.getStatusSnapshot()).toEqual([]) + + expect(relay.replayCachedPayloadsForPanes()).toBe(1) + expect(main.getStatusSnapshot()).toEqual([]) + }) + + it('stamps the silent boundary on a manual completion from a relay that predates it', () => { const server = new AgentHookServer() servers.push(server) + server.ingestRemote(turnEnvelope(), 'conn-a') + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) + + server.ingestRemote(legacyRelayCompactEnvelope('done'), 'conn-a') + + // Why: the old relay built this payload before the flag existed, so it arrives as a plain + // `done` that every completion-reactive consumer would read as a finished turn. + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', sessionBoundary: true }) + }) + + it('drops an auto compact from a relay that predates it, instead of minting working', () => { + const server = new AgentHookServer() + servers.push(server) + server.ingestRemote(turnEnvelope(), 'conn-a') server.ingestRemote( { - ...manualEnvelope('PreCompact', 'working'), - source: 'kimi', - payload: { state: 'working', prompt: '', agentType: 'kimi' } - }, - 'conn-kimi' - ) - server.ingestRemote( - { ...manualEnvelope('PreCompact', 'working'), compactTrigger: undefined }, - 'conn-auto' + ...turnEnvelope(), + payload: { state: 'done', prompt: 'work', agentType: 'claude' }, + hookEventName: 'Stop' + } as AgentHookRelayEnvelope, + 'conn-a' ) + const before = server.getStatusSnapshot()[0] + expect(before).toMatchObject({ state: 'done' }) - expect(server.getStatusSnapshot()).toEqual([]) - expect(server._getStateForTests().lastStatusByPaneKey.size).toBe(0) + server.ingestRemote(legacyRelayCompactEnvelope('working'), 'conn-a') + + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done' }) }) - it('hydrates a manual PreCompact identity and accepts only its exact local completion', async () => { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-compact-restore-')) - temporaryPaths.push(userDataPath) - const first = new AgentHookServer() - servers.push(first) - await first.start({ env: 'production', userDataPath }) - const firstEnv = first.buildPtyEnv() - await postHook( - Number(firstEnv.ORCA_AGENT_HOOK_PORT), - firstEnv.ORCA_AGENT_HOOK_TOKEN, - claudeHook('UserPromptSubmit', PROMPT_ID_2, { prompt: 'work before compact' }) - ) - await postHook( - Number(firstEnv.ORCA_AGENT_HOOK_PORT), - firstEnv.ORCA_AGENT_HOOK_TOKEN, - claudeHook('PreCompact', PROMPT_ID_1, { trigger: 'manual' }) - ) - first.stop() + it('suppresses a duplicate completion arriving twice over the relay', () => { + const server = new AgentHookServer() + servers.push(server) + const emitted: string[] = [] + const unsubscribe = server.subscribeEnrichedStatus((event) => { + emitted.push(`${event.hookEventName}:${event.payload.state}`) + }) + server.ingestRemote(turnEnvelope(), 'conn-a') + server.ingestRemote(legacyRelayCompactEnvelope('done'), 'conn-a') + const applied = server._getStateForTests().lastStatusByPaneKey.get(PANE_KEY) + expect(applied?.payload.state).toBe('done') - const restored = new AgentHookServer() - servers.push(restored) - await restored.start({ env: 'production', userDataPath }) - const restoredEnv = restored.buildPtyEnv() - await postHook( - Number(restoredEnv.ORCA_AGENT_HOOK_PORT), - restoredEnv.ORCA_AGENT_HOOK_TOKEN, - claudeHook('PostCompact', PROMPT_ID_2, { trigger: 'manual' }) - ) - expect(restored.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) + server.ingestRemote(legacyRelayCompactEnvelope('done'), 'conn-a') - await postHook( - Number(restoredEnv.ORCA_AGENT_HOOK_PORT), - restoredEnv.ORCA_AGENT_HOOK_TOKEN, - claudeHook('PostCompact', PROMPT_ID_1, { trigger: 'manual' }) + // Why: the deleted ownership cache used to reject a repeat; a same-owner guard alone would + // accept it and keep refreshing the row, so suppression is keyed on the consumed compact id. + expect(emitted).toEqual(['UserPromptSubmit:working', 'PostCompact:done']) + expect(server._getStateForTests().lastStatusByPaneKey.get(PANE_KEY)).toBe(applied) + unsubscribe() + }) + + it('classifies a triggerless replay by payload state and still checks ownership', () => { + const done = new AgentHookServer() + servers.push(done) + done.ingestRemote(turnEnvelope(), 'conn-a') + // An older relay strips compactTrigger from its cached PostCompact before replaying it. + done.ingestRemote( + legacyRelayCompactEnvelope('done', { compactTrigger: undefined, isReplay: true }), + 'conn-a' ) - expect(restored.getStatusSnapshot()[0]).toMatchObject({ + expect(done.getStatusSnapshot()[0]).toMatchObject({ state: 'done', sessionBoundary: true }) + + // Why: assert this arm from a pane that already FINISHED. Asserting `working` on a pane that + // was already `working` cannot fail — it holds whether the replay was dropped or applied, which + // is exactly how a trigger-stripped auto compact could start re-arming the stuck row again. + const working = new AgentHookServer() + servers.push(working) + working.ingestRemote(turnEnvelope(), 'conn-a') + working.ingestRemote( + { + ...turnEnvelope(), + hookEventName: 'Stop', + payload: { state: 'done', prompt: 'work before compact', agentType: 'claude' } + } as AgentHookRelayEnvelope, + 'conn-a' + ) + expect(working.getStatusSnapshot()[0]).toMatchObject({ state: 'done' }) + working.ingestRemote( + legacyRelayCompactEnvelope('working', { compactTrigger: undefined, isReplay: true }), + 'conn-a' + ) + expect(working.getStatusSnapshot()[0]).toMatchObject({ state: 'done' }) + + const foreign = new AgentHookServer() + servers.push(foreign) + foreign.ingestRemote(turnEnvelope(), 'conn-a') + // Payload state substitutes for the missing trigger only — ownership is still enforced. + foreign.ingestRemote( + legacyRelayCompactEnvelope('done', { + compactTrigger: undefined, + isReplay: true, + providerSession: { key: 'session_id', id: 'a-different-session' } + }), + 'conn-a' + ) + expect(foreign.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) + }) + + it('keeps the summarized turn as the label, with or without a trigger on the envelope', () => { + // The compact's own event carries no prompt of its own, so the row's label can only come from + // the turn it summarized. The trigger-stripped replay is the one shape that arrives with no + // trigger at all, so the carry-forward must not be keyed on the trigger being present. + const blank = { state: 'done' as const, prompt: '', agentType: 'claude' } + + const tagged = new AgentHookServer() + servers.push(tagged) + tagged.ingestRemote(turnEnvelope(), 'conn-a') + tagged.ingestRemote(legacyRelayCompactEnvelope('done', { payload: blank }), 'conn-a') + expect(tagged.getStatusSnapshot()[0]).toMatchObject({ state: 'done', - prompt: 'work before compact', - agentType: 'claude' + prompt: 'work before compact' + }) + + const stripped = new AgentHookServer() + servers.push(stripped) + stripped.ingestRemote(turnEnvelope(), 'conn-a') + stripped.ingestRemote( + legacyRelayCompactEnvelope('done', { + compactTrigger: undefined, + isReplay: true, + payload: blank + }), + 'conn-a' + ) + expect(stripped.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + prompt: 'work before compact' }) }) + + it('rejects a completion with no provider prompt id', () => { + const server = new AgentHookServer() + servers.push(server) + server.ingestRemote(turnEnvelope(), 'conn-a') + + server.ingestRemote( + legacyRelayCompactEnvelope('done', { providerPromptId: undefined }), + 'conn-a' + ) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) + }) + + it('never lets a PreCompact envelope drive pane state', () => { + const server = new AgentHookServer() + servers.push(server) + server.ingestRemote(turnEnvelope(), 'conn-a') + server.ingestRemote( + { + ...turnEnvelope(), + payload: { state: 'done', prompt: 'work', agentType: 'claude' }, + hookEventName: 'Stop' + } as AgentHookRelayEnvelope, + 'conn-a' + ) + + server.ingestRemote( + legacyRelayCompactEnvelope('working', { + hookEventName: 'PreCompact', + compactTrigger: 'manual' + }), + 'conn-a' + ) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done' }) + }) }) diff --git a/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts b/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts index 9811773eb07..b3257e6972c 100644 --- a/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts +++ b/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts @@ -211,7 +211,10 @@ describe('restored subagent liveness sweep', () => { } }) - it('does not probe a hydrated Claude pane without restored rows', async () => { + it('reaps a hydrated non-terminal row with no roster at all (STA-4612)', async () => { + // A restored roster is only one shape of stranded claim. A lead row left `working` with no + // children has nothing left to retire it — no child event, no inventory, no hook — so it holds + // the pane working for good and locks its agent out of hibernation. const server = await restartWithInFlightSubagent({ subagents: [] }) const probeLiveLocalPty = vi.fn(() => false) try { @@ -220,9 +223,42 @@ describe('restored subagent liveness sweep', () => { probeLiveLocalPty, persistedPtyIdByPaneKey: { [PANE]: PTY } }) + ).toBe(1) + + expect(probeLiveLocalPty).toHaveBeenCalled() + expect(paneStatus(server).state).toBe('missing') + } finally { + server.stop() + } + }) + + it('keeps a hydrated non-terminal row with no roster when its PTY is still live', async () => { + const server = await restartWithInFlightSubagent({ subagents: [] }) + try { + expect( + await sweepWith(server, { + probeLiveLocalPty: () => true, + persistedPtyIdByPaneKey: { [PANE]: PTY } + }) ).toBe(0) - expect(probeLiveLocalPty).not.toHaveBeenCalled() + expect(paneStatus(server).state).toBe('working') + } finally { + server.stop() + } + }) + + it('keeps a hydrated non-terminal row with no roster when the probe cannot prove either state', async () => { + const server = await restartWithInFlightSubagent({ subagents: [] }) + try { + expect( + await sweepWith(server, { + probeLiveLocalPty: () => null, + persistedPtyIdByPaneKey: { [PANE]: PTY } + }) + ).toBe(0) + + expect(paneStatus(server).state).toBe('working') } finally { server.stop() } diff --git a/src/main/agent-hooks/server-ingest-terminal-status.test.ts b/src/main/agent-hooks/server-ingest-terminal-status.test.ts index 8661701e1dc..0ac729fa72e 100644 --- a/src/main/agent-hooks/server-ingest-terminal-status.test.ts +++ b/src/main/agent-hooks/server-ingest-terminal-status.test.ts @@ -27,6 +27,52 @@ afterEach(() => { }) describe('AgentHookServer ingestTerminalStatus', () => { + it('keeps hook monitoring mode across an equivalent OSC ping until a hook clears it', () => { + const server = new AgentHookServer() + + server.ingestRemote( + { + paneKey: PANE, + source: 'claude', + hookEventName: 'Stop', + payload: { + state: 'working', + workingMode: 'monitoring', + prompt: 'watch the build', + agentType: 'claude' + } + }, + 'conn-1' + ) + server.ingestTerminalStatus({ + paneKey: PANE, + connectionId: 'conn-1', + payload: { state: 'working', prompt: 'watch the build', agentType: 'claude' } + }) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + workingMode: 'monitoring' + }) + + server.ingestRemote( + { + paneKey: PANE, + source: 'claude', + hookEventName: 'PreToolUse', + payload: { + state: 'working', + prompt: 'watch the build', + agentType: 'claude', + toolName: 'Read' + } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()[0]?.workingMode).toBeUndefined() + }) + it('preserves a hook turn stamp when an OSC repaint omits hook-only completion text', () => { const server = new AgentHookServer() const listener = vi.fn() diff --git a/src/main/agent-hooks/server-interrupt-inference-guards.test.ts b/src/main/agent-hooks/server-interrupt-inference-guards.test.ts index 8d0d6a3975c..29803adf020 100644 --- a/src/main/agent-hooks/server-interrupt-inference-guards.test.ts +++ b/src/main/agent-hooks/server-interrupt-inference-guards.test.ts @@ -228,6 +228,7 @@ describe('AgentHookServer listener replay', () => { const baseline = server.getStatusSnapshot()[0] expect(baseline).not.toHaveProperty('claudeRunningNonAgentTask') + expect(baseline).toMatchObject({ state: 'working', workingMode: 'monitoring' }) expect( server.inferInterrupt({ paneKey: PANE, diff --git a/src/main/agent-hooks/server-last-status-write.test.ts b/src/main/agent-hooks/server-last-status-write.test.ts index f693c2d4c36..b0bfb7db929 100644 --- a/src/main/agent-hooks/server-last-status-write.test.ts +++ b/src/main/agent-hooks/server-last-status-write.test.ts @@ -77,7 +77,11 @@ describe('Last-status persistence', () => { worktreeId: 'wt-1', receivedAt: expect.any(Number), stateStartedAt: expect.any(Number), - payload: expect.objectContaining({ state: 'working', prompt: 'persist me' }) + payload: expect.objectContaining({ + state: 'working', + workingMode: 'monitoring', + prompt: 'persist me' + }) }) expect(file.entries[PANE].launchToken).toBeUndefined() expect(file.entries[PANE].launchTokenHash).toBe( diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 65b7b1479fd..f69ffea2e4c 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -12,6 +12,7 @@ import { ORCA_HOOK_PROTOCOL_VERSION } from '../../shared/agent-hook-types' import { clearAllListenerCaches, clearPaneCacheState, + paneHasStateClaims, clearClaudeAnsweredQuestionWait, createHookListenerState, getEndpointFileName, @@ -23,14 +24,12 @@ import { markCodexLeadTurnInterrupted, MAX_PANE_KEY_LEN, movePaneCacheState, - canAcceptClaudeCompactTransition, normalizeClaudePromptId, normalizeHookPayload, parseFormEncodedBody, readRequestBody, reapRestoredClaudeSubagentsForDeadPane, reconcileRemoteCodexState, - resolveCachedClaudeCompactOwnership, resolveHookSource, preparePendingGrokResultDiscovery, seedClaudeLeadTurnFromPersistedStatus, @@ -41,6 +40,12 @@ import { type AgentHookEventPayload, type HookListenerState } from '../../shared/agent-hook-listener' +import { + canAcceptClaudeCompactCompletion, + isClaudeCompactCompletionConsumed, + markClaudeCompactCompletionConsumed, + resolveLegacyCompactTrigger +} from '../../shared/claude-compact-completion' import { createHookTransportInterferenceTracker, describeHookTransportInterference, @@ -72,6 +77,7 @@ import { type ParsedAgentStatusPayload, normalizeAgentStatusPayload } from '../../shared/agent-status-types' +import { terminalStatusPayloadMatchesHook } from '../../shared/agent-terminal-status-equivalence' import { AgentStatusObservationSequencer, createAgentStatusAuthorityId, @@ -448,30 +454,6 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta } } -// Why: OSC never carries model/children; omit both so an equivalent OSC ping preserves the hook-cached identity graph. -function equivalentParsedAgentStatusPayload( - a: ParsedAgentStatusPayload, - b: ParsedAgentStatusPayload, - preserveActiveTurnStamp = false -): boolean { - return ( - a.state === b.state && - a.prompt === b.prompt && - a.agentType === b.agentType && - a.toolName === b.toolName && - a.toolInput === b.toolInput && - a.interactivePrompt === b.interactivePrompt && - (a.lastAssistantMessage === b.lastAssistantMessage || - (preserveActiveTurnStamp && b.lastAssistantMessage === undefined)) && - a.interrupted === b.interrupted && - // Why: a session-boundary done must never be deduped against a cached real done — - // the flag has to reach receivers deterministically (STA-3386). - a.sessionBoundary === b.sessionBoundary && - (a.turnCompletedAt === b.turnCompletedAt || - (preserveActiveTurnStamp && b.turnCompletedAt === undefined)) - ) -} - function trackEmptyPaneKeyHook(body: unknown): void { if (typeof body !== 'object' || body === null) { return @@ -1039,6 +1021,7 @@ export class AgentHookServer { providerSession: existing.providerSession, payload: { state: restored.state, + ...(restored.workingMode ? { workingMode: restored.workingMode } : {}), prompt: payload.prompt, agentType: payload.agentType, ...(restored.state === 'done' && restored.interrupted ? { interrupted: true } : {}), @@ -1541,10 +1524,9 @@ export class AgentHookServer { if (!identity.inheritedFromActivePane) { this.maybeTrackAgentPromptSent(effectivePayload, previous) } - const cachedPayload = resolveCachedClaudeCompactOwnership(previous, boundaryAwarePayload) const enriched = { - ...this.attachStatusTiming(cachedPayload, now), - observation: this.stampObservation(cachedPayload, origin, now) + ...this.attachStatusTiming(boundaryAwarePayload, now), + observation: this.stampObservation(boundaryAwarePayload, origin, now) } if ( typeof enriched.payload.turnCompletedAt === 'number' && @@ -2201,7 +2183,7 @@ export class AgentHookServer { previous?.connectionId === connectionId && previous.tabId === tabId && previous.worktreeId === worktreeId && - equivalentParsedAgentStatusPayload(previous.payload, event.payload, preserveActiveTurnStamp) + terminalStatusPayloadMatchesHook(previous.payload, event.payload, preserveActiveTurnStamp) ) { return } @@ -2378,39 +2360,59 @@ export class AgentHookServer { this.state.lastStatusByPaneKey.get(paneKey)?.payload ) const previousStatus = this.state.lastStatusByPaneKey.get(paneKey) + let acceptedCompactCompletion = false if (hookEventName === 'PreCompact' || hookEventName === 'PostCompact') { - if ( - source !== 'claude' || - compactTrigger === undefined || - normalizedPayload.agentType !== source - ) { + // Why: PreCompact is never registered and proves nothing (an aborted compact emits it alone); + // reject it here too so a host on any version cannot drive pane state from it. + if (hookEventName === 'PreCompact' || source !== 'claude') { + return + } + // Why: a relay predating this change strips `compactTrigger` from its cached PostCompact + // before replaying it, so the replay has no manual/auto discriminator. That relay's mapping is + // fixed and known — manual produced `done`, auto produced `working` — so the payload state + // stands in for the missing trigger. Trigger substitution only; ownership is still checked. + const effectiveTrigger = resolveLegacyCompactTrigger(compactTrigger, normalizedPayload.state) + // Why: an auto compact happens inside a turn that resumes and emits its own Stop. An older + // relay maps it to `working`, and this ingest applies the relay's payload verbatim — so + // without this drop, every auto compact on such a host mints exactly the stuck `working` this + // change removes. + if (effectiveTrigger !== 'manual' || normalizedPayload.agentType !== source) { return } if ( - hookEventName === 'PreCompact' && - envelope.isReplay === true && - (previousStatus?.hookEventName !== 'PreCompact' || - previousStatus.compactTrigger !== compactTrigger || - previousStatus.providerPromptId !== providerPromptId) - ) { - return - } - if ( - !canAcceptClaudeCompactTransition(previousStatus, { + isClaudeCompactCompletionConsumed( + this.state.claudeConsumedCompactPromptIdByPaneKey, + paneKey, + providerPromptId + ) || + !canAcceptClaudeCompactCompletion(previousStatus, { source, connectionId: trimmedConnectionId, - hookEventName, providerPromptId, - compactTrigger, providerSession }) ) { return } + markClaudeCompactCompletionConsumed( + this.state.claudeConsumedCompactPromptIdByPaneKey, + paneKey, + providerPromptId + ) + // Why: an older relay built this payload before the boundary flag existed, so it arrives as a + // plain `done` — which every completion-reactive consumer reads as a finished turn. Stamp the + // boundary here so a compact stays silent regardless of which relay normalized it. + if (normalizedPayload.sessionBoundary !== true) { + normalizedPayload = { ...normalizedPayload, sessionBoundary: true } + } + acceptedCompactCompletion = true } + // Why: keyed on "did we accept a completion", not on the trigger surviving the wire — the + // trigger-stripped replay is exactly the shape that arrives without one, and it is still the + // compact's own promptless event, so it still needs the summarized turn's label. if ( source === 'claude' && - compactTrigger !== undefined && + (compactTrigger !== undefined || acceptedCompactCompletion) && normalizedPayload.prompt.length === 0 && previousStatus?.payload.prompt ) { @@ -2652,28 +2654,87 @@ export class AgentHookServer { this.notifyStatusChangeListeners() } + /** The resume-identity remnant of a dropped row: a `providerSessionOnly` entry carries no state + * claim — it cannot gate a pane `working` — so it survives teardowns that end the pane's live + * claims. Returns null when the row has no resumable session to keep. */ + private toRetainedProviderSessionRow( + entry: EnrichedAgentHookEventPayload | null | undefined + ): EnrichedAgentHookEventPayload | null { + if ( + !entry?.providerSession || + !entry.payload.agentType || + entry.payload.agentType === 'unknown' + ) { + return null + } + const { launchToken: _launchToken, ...resumeIdentity } = entry + return { ...resumeIdentity, providerSessionOnly: true, retainedForLiveness: true } + } + /** Drop only the status row (user dismissal); do NOT wipe prompt/tool caches since the pane's agent may still be alive. Use clearPaneState for PTY-teardown. */ dropStatusEntry(paneKey: string): void { const deleted = this.deleteStatusEntry(paneKey, { preserveAuthority: true }) if (!deleted) { return } - if ( - deleted.providerSession && - deleted.payload.agentType && - deleted.payload.agentType !== 'unknown' - ) { - const retained: EnrichedAgentHookEventPayload = { - ...deleted, - providerSessionOnly: true, - retainedForLiveness: true - } + const retained = this.toRetainedProviderSessionRow(deleted) + if (retained) { this.state.lastStatusByPaneKey.set(deleted.paneKey, retained) } this.scheduleStatusPersist() this.notifyStatusChangeListeners() } + /** Retire panes whose owning process is certifiably dead. + * + * The ordinary teardown already does this: every attributable PTY exit reaches + * `clearProviderPtyState`, which resolves the pane key and calls `clearPaneState`. But that + * resolution depends on the spawn-time `ptyPaneKey` mapping, which a restored/reattached PTY may + * never rebuild — so those panes keep a `working` row and its latches for good, with no hook left + * to retire them. This is the same operation reached from the runtime's own pane-key knowledge, + * so a dead pane is cleaned up identically however its keys were resolved. */ + reconcileEndedProcessForPaneKeys( + paneKeys: Iterable, + options?: { + /** The pane's PTY outlived its agent (a confirmed shell foreground), so the session can still + * be resumed in place — keep the `providerSessionOnly` remnant the paired `agentStatus:drop` + * minted for exactly this case. A certified PTY exit passes nothing: there is no pane left to + * resume into, and dropping it matches what `clearProviderPtyState` already does. */ + preserveResumeIdentity?: boolean + } + ): number { + let cleared = 0 + for (const paneKey of paneKeys) { + const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) + if (!this.hasLiveClaimsForPaneKey(resolvedPaneKey)) { + continue + } + const retained = options?.preserveResumeIdentity + ? this.toRetainedProviderSessionRow( + this.state.lastStatusByPaneKey.get(resolvedPaneKey) as + | EnrichedAgentHookEventPayload + | undefined + ) + : null + this.clearPaneState(resolvedPaneKey) + if (retained) { + this.state.lastStatusByPaneKey.set(resolvedPaneKey, retained) + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + } + cleared += 1 + } + return cleared + } + + /** Anything a dead pane could still be asserting: a row, or a latch that would re-gate one through + * `resolveClaudePaneState` on the pane's next event even after the row reads `done`. The list + * itself lives beside `clearPaneCacheState`, so adding a latch cannot leave this behind in a + * different file. */ + private hasLiveClaimsForPaneKey(paneKey: string): boolean { + return paneHasStateClaims(this.state, paneKey) + } + /** Clear statuses proven to belong to one lost SSH transport. */ clearStatusEntriesForConnection(connectionId: string): void { const normalizedConnectionId = connectionId.trim() @@ -2704,6 +2765,7 @@ export class AgentHookServer { this.state.claudeLeadStateByPaneKey.delete(paneKey) this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) this.state.claudeActiveSessionCronPaneKeys.delete(paneKey) + this.state.claudeSessionOwnerByPaneKey.delete(paneKey) } } } @@ -2894,9 +2956,15 @@ export class AgentHookServer { enriched.payload.agentType === 'claude' && enriched.connectionId === null && isLocalExecutionHost(enriched.worktreeId) && - claudeRosterHasRestoredSnapshotSubagent( + // Why: a restored roster is only one shape of stranded claim. A lead row left non-terminal, + // or a background-task/cron latch nothing will refresh, strands the pane just as + // permanently — and unlike the roster case there is no child event left to reap it. + (claudeRosterHasRestoredSnapshotSubagent( this.state.claudeSubagentRosterByPaneKey.get(paneKey) - ) && + ) || + enriched.payload.state !== 'done' || + this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || + this.state.claudeActiveSessionCronPaneKeys.has(paneKey)) && !this.runtimeObservedStatusPaneKeys.has(paneKey) ) { candidates.push({ paneKey, entry: enriched }) @@ -2924,6 +2992,20 @@ export class AgentHookServer { continue } if (!reapRestoredClaudeSubagentsForDeadPane(this.state, paneKey)) { + // Why: the roster reap only speaks for restored child rows. A pane whose PTY is provably + // gone and whose claim is a lead row or a latch has nothing for it to reap, so retire the + // pane the same way an observed exit would — otherwise the widened candidate set is inert. + // + // Why delete rather than downgrade to `done` like the reap branch below: that branch has a + // real turn to describe — a parent whose children it just reaped — while these panes' only + // claim IS the stale non-terminal row. Rewriting a `waiting`/`blocked` row to `done` would + // invent a completion that never happened, and leaving it non-terminal keeps the bug. This + // sweep stands in for the exit Orca never observed, so it does what that exit does: + // `clearProviderPtyState` -> `clearPaneState`. + if (this.hasLiveClaimsForPaneKey(paneKey)) { + this.clearPaneState(paneKey) + changedPanes += 1 + } continue } changedPanes += 1 @@ -2949,7 +3031,12 @@ export class AgentHookServer { ...(state !== 'done' && restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), receivedAt: reconciledAt, stateStartedAt: stateChanged ? reconciledAt : enriched.stateStartedAt, - payload: { ...enriched.payload, state, subagents } + payload: { + ...enriched.payload, + state, + workingMode: state === 'working' ? enriched.payload.workingMode : undefined, + subagents + } } this.state.lastStatusByPaneKey.set(paneKey, reconciled) } diff --git a/src/main/ai-vault/cached-session-list.ts b/src/main/ai-vault/cached-session-list.ts index 960bbc5576c..5ecb45f3469 100644 --- a/src/main/ai-vault/cached-session-list.ts +++ b/src/main/ai-vault/cached-session-list.ts @@ -1,5 +1,6 @@ import { join } from 'node:path' import { + clearAiVaultBackgroundRestartCircuit, resetAiVaultScannerBackgroundForTests, scanAiVaultSessionsInBackground } from './session-scanner-background' @@ -56,6 +57,9 @@ export async function listAiVaultSessions( const depth = requestedAiVaultSessionDepth(args) const scanKey = JSON.stringify({ key, depth }) const now = Date.now() + if (args?.force === true) { + clearAiVaultBackgroundRestartCircuit() + } // Why: opening this panel repeatedly should not re-parse hundreds of JSONL // transcripts; explicit refreshes bypass the cache and preempt stale scans. if ( diff --git a/src/main/ai-vault/session-scanner-background.ts b/src/main/ai-vault/session-scanner-background.ts index fb80afa2def..6903e689a64 100644 --- a/src/main/ai-vault/session-scanner-background.ts +++ b/src/main/ai-vault/session-scanner-background.ts @@ -9,6 +9,7 @@ import { type ReadAiVaultFirstUserPromptResult } from './session-first-user-prompt-read' import { + clearAiVaultServiceRestartCircuit, invalidateAiVaultServiceCache, listAiVaultSubagentSessionsInService, readAiVaultFirstUserPromptInService, @@ -36,6 +37,13 @@ export function shouldUseAiVaultServiceProcess(): boolean { return process.env.NODE_ENV !== 'test' } +// Let forced refreshes retry after a local service circuit opens. +export function clearAiVaultBackgroundRestartCircuit(): void { + if (shouldUseAiVaultServiceProcess()) { + clearAiVaultServiceRestartCircuit() + } +} + export function scanAiVaultSessionsInBackground( options: AiVaultWorkerScanOptions, signal?: AbortSignal diff --git a/src/main/ai-vault/session-scanner-service-client-state.ts b/src/main/ai-vault/session-scanner-service-client-state.ts index ea56bd534f5..9b64431e219 100644 --- a/src/main/ai-vault/session-scanner-service-client-state.ts +++ b/src/main/ai-vault/session-scanner-service-client-state.ts @@ -1,8 +1,9 @@ import type { ChildProcess } from 'node:child_process' -import type { - AiVaultServiceInit, - AiVaultServiceLane, - AiVaultServiceRequest +import { + AI_VAULT_SERVICE_PROTOCOL_VERSION, + type AiVaultServiceInit, + type AiVaultServiceLane, + type AiVaultServiceRequest } from './session-scanner-service-protocol' export const AI_VAULT_SERVICE_READY_TIMEOUT_MS = 5_000 @@ -110,6 +111,28 @@ export function armAiVaultServiceCancellationTimeout( * A cold start that faults before the request reached the child self-heals on * the scheduled respawn. Requeue once; the caller rejects when this returns false. */ +/** Wires a freshly forked child to the client's callbacks and hands it the init frame. */ +export function attachAiVaultServiceChild( + child: ChildProcess, + init: AiVaultServiceClientOptions['init'], + handlers: { + onMessage: (message: unknown) => void + onFault: (error: Error) => void + onStderr?: (text: string) => void + } +): void { + child.on('message', handlers.onMessage) + child.on('error', handlers.onFault) + child.on('disconnect', () => handlers.onFault(new Error('AI Vault service disconnected.'))) + child.on('exit', (code) => handlers.onFault(new Error(`AI Vault service exited (${code}).`))) + child.stderr?.on('data', (chunk: Buffer) => handlers.onStderr?.(String(chunk))) + child.send({ + type: 'init', + protocol: AI_VAULT_SERVICE_PROTOCOL_VERSION, + ...init + } satisfies AiVaultServiceInit) +} + export function requeueAiVaultServiceStart( call: AiVaultServicePendingCall, queue: AiVaultServicePendingCall[] diff --git a/src/main/ai-vault/session-scanner-service-client.test.ts b/src/main/ai-vault/session-scanner-service-client.test.ts index 6dd415cafa5..97cb0d46afd 100644 --- a/src/main/ai-vault/session-scanner-service-client.test.ts +++ b/src/main/ai-vault/session-scanner-service-client.test.ts @@ -181,6 +181,87 @@ describe('AiVaultScannerServiceClient', () => { client.dispose() }) + it('starts a queued retry immediately when a forced refresh clears backoff', async () => { + vi.useFakeTimers() + const children: AiVaultServiceTestChild[] = [] + const client = new AiVaultScannerServiceClient({ + processFactory: () => { + const child = new AiVaultServiceTestChild(18_345 + children.length) + children.push(child) + return child.asChildProcess() + }, + init: { sessionParseCache: null } + }) + const titles = client.request({ type: 'request', operation: 'titles', requests: [] }) + + vi.advanceTimersByTime(AI_VAULT_SERVICE_READY_TIMEOUT_MS) + await Promise.resolve() + expect(children).toHaveLength(1) + + client.clearRestartCircuit() + expect(children).toHaveLength(2) + + readyAiVaultServiceChild(children[1]!) + await vi.waitFor(() => + expect(children[1]!.sent).toContainEqual(expect.objectContaining({ operation: 'titles' })) + ) + children[1]!.emit('message', { + type: 'result', + id: aiVaultServiceRequestId(children[1]!, 'titles'), + operation: 'titles', + value: { titles: [] } + }) + await expect(titles).resolves.toEqual({ titles: [] }) + client.dispose() + }) + + it('lets a forced refresh reopen the circuit instead of waiting out the window', async () => { + vi.useFakeTimers() + const children: AiVaultServiceTestChild[] = [] + const client = new AiVaultScannerServiceClient({ + processFactory: () => { + const child = new AiVaultServiceTestChild(22_345 + children.length) + children.push(child) + return child.asChildProcess() + }, + init: { sessionParseCache: null } + }) + // Each request retries its cold start once, so two requests spend the three + // faults the circuit breaker needs. + const first = client.request({ type: 'request', operation: 'titles', requests: [] }) + vi.advanceTimersByTime(AI_VAULT_SERVICE_READY_TIMEOUT_MS) + await Promise.resolve() + vi.advanceTimersByTime(250) + await Promise.resolve() + vi.advanceTimersByTime(AI_VAULT_SERVICE_READY_TIMEOUT_MS) + await expect(first).rejects.toThrow('did not become ready') + vi.advanceTimersByTime(1_000) + await Promise.resolve() + + const blocked = client.request({ type: 'request', operation: 'titles', requests: [] }) + vi.advanceTimersByTime(AI_VAULT_SERVICE_READY_TIMEOUT_MS) + await Promise.resolve() + vi.advanceTimersByTime(5_000) + await expect(blocked).rejects.toThrow('circuit is open') + expect(children).toHaveLength(3) + + client.clearRestartCircuit() + const retried = client.request({ type: 'request', operation: 'titles', requests: [] }) + await vi.waitFor(() => expect(children).toHaveLength(4)) + readyAiVaultServiceChild(children[3]!) + await vi.waitFor(() => + expect(children[3]!.sent).toContainEqual(expect.objectContaining({ operation: 'titles' })) + ) + children[3]!.emit('message', { + type: 'result', + id: aiVaultServiceRequestId(children[3]!, 'titles'), + operation: 'titles', + value: { titles: [] } + }) + await expect(retried).resolves.toEqual({ titles: [] }) + client.dispose() + }) + it('acknowledges cache invalidation through the running child', async () => { const { child, client } = setup() const invalidation = client.invalidate(['/tmp/deleted.jsonl']) diff --git a/src/main/ai-vault/session-scanner-service-client.ts b/src/main/ai-vault/session-scanner-service-client.ts index 4f46dc643c5..e068f2e2e0a 100644 --- a/src/main/ai-vault/session-scanner-service-client.ts +++ b/src/main/ai-vault/session-scanner-service-client.ts @@ -9,6 +9,7 @@ import { AiVaultServiceIdleRetirement, AiVaultServiceInvalidations, armAiVaultServiceCancellationTimeout, + attachAiVaultServiceChild, clearAiVaultServiceCall, createAiVaultServiceReadyWaiter, rejectAiVaultServiceCall, @@ -20,11 +21,9 @@ import { } from './session-scanner-service-client-state' import { AiVaultServiceRestartPolicy } from './session-scanner-service-restart-policy' import { - AI_VAULT_SERVICE_PROTOCOL_VERSION, aiVaultServiceLane, isAiVaultServiceChildMessage, type AiVaultServiceChildMessage, - type AiVaultServiceInit, type AiVaultServiceRequest, type AiVaultServiceRequestBody, type AiVaultServiceResultValue @@ -77,6 +76,11 @@ export class AiVaultScannerServiceClient { }) } + clearRestartCircuit(): void { + this.restartPolicy.clearCircuit() + this.pump() + } + async invalidate(paths: string[]): Promise { if (paths.length === 0 || this.disposed) { return @@ -199,16 +203,11 @@ export class AiVaultScannerServiceClient { this.onFault(new Error('AI Vault service did not become ready.')) ) this.readyWaiter = waiter - child.on('message', (message) => this.onMessage(message)) - child.on('error', (error) => this.onFault(error)) - child.on('disconnect', () => this.onFault(new Error('AI Vault service disconnected.'))) - child.on('exit', (code) => this.onFault(new Error(`AI Vault service exited (${code}).`))) - child.stderr?.on('data', (chunk: Buffer) => this.options.onStderr?.(String(chunk))) - child.send({ - type: 'init', - protocol: AI_VAULT_SERVICE_PROTOCOL_VERSION, - ...this.options.init - } satisfies AiVaultServiceInit) + attachAiVaultServiceChild(child, this.options.init, { + onMessage: (message) => this.onMessage(message), + onFault: (error) => this.onFault(error), + onStderr: this.options.onStderr + }) return waiter.promise } diff --git a/src/main/ai-vault/session-scanner-service-restart-policy.test.ts b/src/main/ai-vault/session-scanner-service-restart-policy.test.ts index 98dfadf91e3..a3e72948f05 100644 --- a/src/main/ai-vault/session-scanner-service-restart-policy.test.ts +++ b/src/main/ai-vault/session-scanner-service-restart-policy.test.ts @@ -30,6 +30,22 @@ describe('AiVaultServiceRestartPolicy', () => { expect(policy.restartScheduled).toBe(false) }) + it('clears the pending backoff and fault history for a forced retry', () => { + vi.useFakeTimers() + const policy = new AiVaultServiceRestartPolicy() + const restart = vi.fn() + + policy.recordFault(restart) + policy.clearCircuit() + vi.advanceTimersByTime(10_000) + + expect(restart).not.toHaveBeenCalled() + policy.recordFault(restart) + policy.recordFault(restart) + expect(policy.startError()).toBeNull() + policy.dispose() + }) + it('opens the circuit after three faults inside the window', () => { let now = 0 const policy = new AiVaultServiceRestartPolicy(() => now) diff --git a/src/main/ai-vault/session-scanner-service-restart-policy.ts b/src/main/ai-vault/session-scanner-service-restart-policy.ts index 605e6c9731b..ae9f437ab3e 100644 --- a/src/main/ai-vault/session-scanner-service-restart-policy.ts +++ b/src/main/ai-vault/session-scanner-service-restart-policy.ts @@ -12,6 +12,16 @@ export class AiVaultServiceRestartPolicy { return this.timer !== null } + /** A forced refresh starts a fresh retry window immediately. */ + clearCircuit(): void { + this.circuitUntil = 0 + this.faults = [] + if (this.timer) { + clearTimeout(this.timer) + this.timer = null + } + } + startError(): Error | null { return this.now() < this.circuitUntil ? new Error('AI Vault service restart circuit is open.') diff --git a/src/main/ai-vault/session-scanner-service-spawn.ts b/src/main/ai-vault/session-scanner-service-spawn.ts index 9fd20cfe57e..3ba12322734 100644 --- a/src/main/ai-vault/session-scanner-service-spawn.ts +++ b/src/main/ai-vault/session-scanner-service-spawn.ts @@ -85,6 +85,11 @@ export function invalidateAiVaultServiceCache(paths: string[]): Promise { return sharedClient?.invalidate(paths) ?? Promise.resolve() } +/** A forced refresh is a deliberate user action, so it reopens the restart circuit. */ +export function clearAiVaultServiceRestartCircuit(): void { + sharedClient?.clearRestartCircuit() +} + export function resetAiVaultScannerServiceForTests(): void { sharedClient?.dispose() sharedClient = null diff --git a/src/main/app-relaunch.test.ts b/src/main/app-relaunch.test.ts index 26c98c98bb3..4ba8c6ce5a0 100644 --- a/src/main/app-relaunch.test.ts +++ b/src/main/app-relaunch.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { appRelaunchMock, recordDurableCrashBreadcrumbMock } = vi.hoisted(() => ({ appRelaunchMock: vi.fn(), @@ -11,12 +11,24 @@ vi.mock('./crash-reporting/durable-crash-breadcrumb', () => ({ })) import { relaunchApp } from './app-relaunch' +import { _resetHydrateShellPathCache, _setLaunchPathForTests } from './startup/hydrate-shell-path' beforeEach(() => { appRelaunchMock.mockReset() recordDurableCrashBreadcrumbMock.mockReset() }) +const originalPath = process.env.PATH + +afterEach(() => { + _resetHydrateShellPathCache() + if (originalPath === undefined) { + delete process.env.PATH + } else { + process.env.PATH = originalPath + } +}) + describe('relaunchApp', () => { it('durably records the reason before scheduling the replacement process', () => { relaunchApp('gpu-fallback', { processReason: 'crashed', exitCode: 5 }) @@ -32,4 +44,18 @@ describe('relaunchApp', () => { appRelaunchMock.mock.invocationCallOrder[0] ) }) + + it('does not carry Orca PATH seeds into the replacement process', () => { + process.env.PATH = '/seeded/newest-nvm/bin:/usr/bin' + _setLaunchPathForTests('/usr/bin') + let inheritedPath: string | undefined + appRelaunchMock.mockImplementation(() => { + inheritedPath = process.env.PATH + }) + + relaunchApp('renderer-request') + + expect(inheritedPath).toBe('/usr/bin') + expect(process.env.PATH).toBe('/seeded/newest-nvm/bin:/usr/bin') + }) }) diff --git a/src/main/app-relaunch.ts b/src/main/app-relaunch.ts index ab7a725c847..ce96af1e96f 100644 --- a/src/main/app-relaunch.ts +++ b/src/main/app-relaunch.ts @@ -1,6 +1,7 @@ import { app } from 'electron' import type { CrashReportBreadcrumbData } from '../shared/crash-reporting' import { recordDurableCrashBreadcrumb } from './crash-reporting/durable-crash-breadcrumb' +import { runWithLaunchPath } from './startup/hydrate-shell-path' export type AppRelaunchReason = | 'admin-restart' @@ -13,5 +14,5 @@ export function relaunchApp(reason: AppRelaunchReason, data?: CrashReportBreadcr // Why: the current process can exit immediately after app.relaunch(), so // persist the cause before Electron schedules the replacement process. recordDurableCrashBreadcrumb('app_relaunch_requested', { ...data, reason }) - app.relaunch() + runWithLaunchPath(() => app.relaunch()) } diff --git a/src/main/browser/browser-guest-shortcut-dispatch.ts b/src/main/browser/browser-guest-shortcut-dispatch.ts index 8785bed2c11..3992a5ade35 100644 --- a/src/main/browser/browser-guest-shortcut-dispatch.ts +++ b/src/main/browser/browser-guest-shortcut-dispatch.ts @@ -46,7 +46,10 @@ export function forwardGuestShortcutInput( return true } if (input.isAutoRepeat) { - if (action?.type === 'dictationKeyDown' && shouldForwardDictationShortcut?.()) { + if ( + (action?.type === 'dictationKeyDown' && shouldForwardDictationShortcut?.()) || + action?.type === 'deleteCurrentWorkspace' + ) { event.preventDefault() return true } @@ -176,6 +179,8 @@ export function forwardGuestShortcutInput( renderer.send('ui:toggleQuickCommandsMenu') } else if (action?.type === 'openNewWorkspace') { renderer.send('ui:openNewWorkspace') + } else if (action?.type === 'deleteCurrentWorkspace') { + renderer.send('ui:deleteCurrentWorkspace') } else if (action?.type === 'openWorkspaceBoard') { renderer.send('ui:openWorkspaceBoard') } else if (action?.type === 'openTasks') { diff --git a/src/main/browser/browser-guest-shortcut-forwarding.test.ts b/src/main/browser/browser-guest-shortcut-forwarding.test.ts index 39a24fa45fc..a3385a08582 100644 --- a/src/main/browser/browser-guest-shortcut-forwarding.test.ts +++ b/src/main/browser/browser-guest-shortcut-forwarding.test.ts @@ -623,6 +623,47 @@ describe('setupGuestShortcutForwarding', () => { expect(rendererSendMock).toHaveBeenCalledWith('ui:toggleQuickCommandsMenu') }) + it('forwards workspace delete shortcuts from focused guest pages', () => { + setupGuestShortcutForwarding({ + browserTabId, + guest: makeGuest(), + resolveRenderer: () => makeRenderer() + }) + + const isMac = process.platform === 'darwin' + const preventDefault = triggerBeforeInput({ + code: 'Backspace', + key: 'Backspace', + meta: isMac, + control: !isMac, + shift: true + }) + + expect(preventDefault).toHaveBeenCalledOnce() + expect(rendererSendMock).toHaveBeenCalledWith('ui:deleteCurrentWorkspace') + }) + + it('consumes repeated workspace delete shortcuts from focused guest pages', () => { + setupGuestShortcutForwarding({ + browserTabId, + guest: makeGuest(), + resolveRenderer: () => makeRenderer() + }) + + const isMac = process.platform === 'darwin' + const preventDefault = triggerBeforeInput({ + code: 'Backspace', + key: 'Backspace', + meta: isMac, + control: !isMac, + shift: true, + isAutoRepeat: true + }) + + expect(preventDefault).toHaveBeenCalledOnce() + expect(rendererSendMock).not.toHaveBeenCalled() + }) + it('consumes guest zoom shortcuts even when the renderer is unavailable', () => { setupGuestShortcutForwarding({ browserTabId, diff --git a/src/main/claude-accounts/claude-account-service-login-process.test.ts b/src/main/claude-accounts/claude-account-service-login-process.test.ts index cb387a659d5..43e051a95fd 100644 --- a/src/main/claude-accounts/claude-account-service-login-process.test.ts +++ b/src/main/claude-accounts/claude-account-service-login-process.test.ts @@ -6,6 +6,7 @@ import { join } from 'node:path' import { PassThrough } from 'node:stream' import type { ClaudeManagedAccount } from '../../shared/managed-account-types' import { readActiveClaudeKeychainCredentials } from './keychain' +import { getCmdExePath } from '../../shared/windows-batch-spawn' import { createService, resetClaudeKeychainMocks, @@ -516,7 +517,24 @@ describe('ClaudeAccountService credential capture', () => { const destroyStdin = vi.spyOn(child.stdin, 'destroy') const taskkill = new EventEmitter() const spawnMock = vi.fn((command: string) => (command === 'taskkill.exe' ? taskkill : child)) + const cleanupInteractiveLogin = vi.fn() + let publishTerminationPid: (pid: number) => void = () => {} + const buildInteractiveLoginSpawn = vi.fn(() => ({ + command: getCmdExePath(), + args: ['/d', '/c', 'start', '', '/wait', 'claude', 'auth', 'login', '--claudeai'], + stdio: 'ignore' as const, + windowsHide: true, + cleanup: cleanupInteractiveLogin, + getTerminationPid: () => null, + waitForTerminationPid: () => + new Promise((resolve) => { + publishTerminationPid = resolve + }) + })) vi.doMock('node:child_process', () => ({ spawn: spawnMock })) + vi.doMock('../../shared/windows-interactive-login-spawn', () => ({ + buildWindowsHostInteractiveLoginSpawn: buildInteractiveLoginSpawn + })) try { const { ClaudeAccountService } = await import('./service') @@ -548,28 +566,43 @@ describe('ClaudeAccountService credential capture', () => { const addPromise = service.addAccount() await vi.waitFor(() => { + expect(buildInteractiveLoginSpawn).toHaveBeenCalledWith('claude', [ + 'auth', + 'login', + '--claudeai' + ]) expect(spawnMock).toHaveBeenCalledWith( - process.env.ComSpec ?? 'cmd.exe', - ['/d', '/v:off', '/s', '/c', '""claude" "auth" "login" "--claudeai""'], - expect.objectContaining({ shell: false, windowsVerbatimArguments: true }) + getCmdExePath(), + ['/d', '/c', 'start', '', '/wait', 'claude', 'auth', 'login', '--claudeai'], + expect.objectContaining({ stdio: 'ignore', windowsHide: true }) ) }) expect(service.cancelPendingLogin()).toBe(true) const rejection = expect(addPromise).rejects.toThrow('Claude sign-in was cancelled.') expect(child.kill).not.toHaveBeenCalled() - expect(spawnMock).toHaveBeenCalledWith( + expect(spawnMock).not.toHaveBeenCalledWith( 'taskkill.exe', - ['/pid', '1234', '/t', '/f'], - expect.objectContaining({ stdio: 'ignore', windowsHide: true }) + expect.anything(), + expect.anything() ) + publishTerminationPid(9876) + await vi.waitFor(() => { + expect(spawnMock).toHaveBeenCalledWith( + 'taskkill.exe', + ['/pid', '9876', '/t', '/f'], + expect.objectContaining({ stdio: 'ignore', windowsHide: true }) + ) + }) expect(destroyStdin).not.toHaveBeenCalled() taskkill.emit('close', 0) await rejection expect(destroyStdin).toHaveBeenCalledTimes(1) + expect(cleanupInteractiveLogin).toHaveBeenCalledTimes(1) expect(service.cancelPendingLogin()).toBe(false) } finally { vi.doUnmock('node:child_process') + vi.doUnmock('../../shared/windows-interactive-login-spawn') } }) }) diff --git a/src/main/claude-accounts/claude-windows-interactive-login.test.ts b/src/main/claude-accounts/claude-windows-interactive-login.test.ts new file mode 100644 index 00000000000..69ca24c21a8 --- /dev/null +++ b/src/main/claude-accounts/claude-windows-interactive-login.test.ts @@ -0,0 +1,148 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' +import { getCmdExePath } from '../../shared/windows-batch-spawn' +import { createService, restorePlatform, setPlatform } from './claude-account-service-test-harness' + +vi.mock('electron', () => ({ + app: { + getPath: () => '/tmp/orca-claude-windows-login-test' + } +})) + +vi.mock('../codex-cli/command', () => ({ + resolveClaudeCommand: vi.fn(() => 'C:\\Tools\\claude.cmd') +})) + +describe('Claude Windows host interactive login', () => { + afterEach(() => { + restorePlatform() + }) + + it('resolves a Windows auth login whose child has no piped streams', async () => { + setPlatform('win32') + vi.resetModules() + const child = new EventEmitter() as EventEmitter & { + stdout: null + stderr: null + kill: ReturnType + pid: number + } + child.stdout = null + child.stderr = null + child.kill = vi.fn() + child.pid = 4242 + const spawnMock = vi.fn(() => { + queueMicrotask(() => child.emit('exit', 0)) + return child + }) + const buildInteractiveLoginSpawn = vi.fn(() => ({ + command: getCmdExePath(), + args: [ + '/d', + '/c', + 'start', + '', + '/wait', + 'C:\\Tools\\claude.cmd', + 'auth', + 'login', + '--claudeai' + ], + stdio: 'ignore' as const, + windowsHide: true + })) + vi.doMock('node:child_process', () => ({ spawn: spawnMock })) + vi.doMock('../../shared/windows-interactive-login-spawn', () => ({ + buildWindowsHostInteractiveLoginSpawn: buildInteractiveLoginSpawn + })) + + try { + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + createService() as never, + createService() as never, + createService() as never + ) + await ( + service as unknown as { + runClaudeCommand( + args: string[], + configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, + timeoutMs: number + ): Promise + } + ).runClaudeCommand( + ['auth', 'login', '--claudeai'], + { windowsPath: 'C:\\tmp\\claude-auth', linuxPath: null, wslDistro: null }, + 1000 + ) + + expect(buildInteractiveLoginSpawn).toHaveBeenCalledWith('C:\\Tools\\claude.cmd', [ + 'auth', + 'login', + '--claudeai' + ]) + expect(spawnMock).toHaveBeenCalledWith( + getCmdExePath(), + expect.arrayContaining(['start', '', '/wait']), + expect.objectContaining({ + stdio: 'ignore', + windowsHide: true + }) + ) + expect(child.kill).not.toHaveBeenCalled() + } finally { + vi.doUnmock('node:child_process') + vi.doUnmock('../../shared/windows-interactive-login-spawn') + } + }) + + it('times out a denied Windows login that stays alive without piped output', async () => { + setPlatform('win32') + vi.resetModules() + vi.useFakeTimers() + const child = new EventEmitter() as EventEmitter & { + stdout: null + stderr: null + kill: ReturnType + pid: number + } + child.stdout = null + child.stderr = null + child.kill = vi.fn() + child.pid = 0 + const spawnMock = vi.fn(() => child) + vi.doMock('node:child_process', () => ({ spawn: spawnMock })) + + try { + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + createService() as never, + createService() as never, + createService() as never + ) + const login = ( + service as unknown as { + runClaudeCommand( + args: string[], + configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, + timeoutMs: number + ): Promise + } + ).runClaudeCommand( + ['auth', 'login', '--claudeai'], + { windowsPath: 'C:\\tmp\\claude-auth', linuxPath: null, wslDistro: null }, + 1000 + ) + const rejection = expect(login).rejects.toThrow('Claude sign-in took too long to finish.') + + await vi.advanceTimersByTimeAsync(3_000) + + await rejection + expect(child.kill).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + vi.doUnmock('node:child_process') + } + }) +}) diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts index 2e3206eab69..88e3712fde8 100644 --- a/src/main/claude-accounts/service.ts +++ b/src/main/claude-accounts/service.ts @@ -35,6 +35,7 @@ import { parseWslUncPath } from '../../shared/wsl-paths' import { toWindowsWslPath } from '../wsl' import { runWslProcess } from '../wsl/wsl-runner' import { buildWindowsCommandInvocation } from './windows-command-invocation' +import { buildWindowsHostInteractiveLoginSpawn } from '../../shared/windows-interactive-login-spawn' import { getClaudeSelectionTargetForAccount, getSelectedClaudeAccountIdForTarget, @@ -1043,8 +1044,27 @@ export class ClaudeAccountService { options?: { allowFailure?: boolean; signal?: AbortSignal; keepStdinOpen?: boolean } ): Promise { return new Promise((resolvePromise, rejectPromise) => { - const spawnConfig = - configDir.linuxPath && configDir.wslDistro + const isWindowsHostInteractiveLogin = + process.platform === 'win32' && + configDir.linuxPath === null && + configDir.wslDistro === null && + args[0] === 'auth' && + args[1] === 'login' + const interactiveLogin = isWindowsHostInteractiveLogin + ? buildWindowsHostInteractiveLoginSpawn(resolveClaudeCommand(), args) + : null + const spawnConfig = interactiveLogin + ? { + command: interactiveLogin.command, + args: interactiveLogin.args, + env: { + ...process.env, + CLAUDE_CONFIG_DIR: configDir.windowsPath + }, + shell: false, + windowsVerbatimArguments: false + } + : configDir.linuxPath && configDir.wslDistro ? { command: 'wsl.exe', args: [ @@ -1082,10 +1102,11 @@ export class ClaudeAccountService { // Why: Claude's browser auth can bind its callback lifetime to stdin. // Keeping stdin open prevents hidden managed-login runs from tearing down // the local callback server before the browser returns. - stdio: [options?.keepStdinOpen ? 'pipe' : 'ignore', 'pipe', 'pipe'], + stdio: interactiveLogin + ? interactiveLogin.stdio + : [options?.keepStdinOpen ? 'pipe' : 'ignore', 'pipe', 'pipe'], shell: spawnConfig.shell, - // On Windows this is wsl.exe or a cmd invocation; without the flag it - // opens a console and steals foreground on every managed login (#10488). + // Why: hide only the outer wrapper; start creates the visible login console. windowsHide: true, windowsVerbatimArguments: spawnConfig.windowsVerbatimArguments, env: spawnConfig.env, @@ -1095,7 +1116,7 @@ export class ClaudeAccountService { }) const stdout = child.stdout const stderr = child.stderr - if (!stdout || !stderr) { + if (!interactiveLogin && (!stdout || !stderr)) { if (options?.keepStdinOpen) { child.stdin?.destroy() } @@ -1130,17 +1151,18 @@ export class ClaudeAccountService { clearTimeout(timeout) timeout = null } - stdout.off('data', appendOutput) - stderr.off('data', appendOutput) + stdout?.off('data', appendOutput) + stderr?.off('data', appendOutput) child.off('error', onError) child.off(completionEvent, onDone) options?.signal?.removeEventListener('abort', onAbort) + interactiveLogin?.cleanup?.() if (options?.keepStdinOpen) { child.stdin?.destroy() } if (completesOnExit) { - stdout.destroy() - stderr.destroy() + stdout?.destroy() + stderr?.destroy() } } const settle = (callback: () => void): void => { @@ -1159,11 +1181,15 @@ export class ClaudeAccountService { return } terminationPending = true - if (process.platform === 'win32' && child.pid) { - const taskkill = spawn('taskkill.exe', ['/pid', String(child.pid), '/t', '/f'], { - stdio: 'ignore', - windowsHide: true - }) + const killWindowsTree = (windowsTerminationPid: number): void => { + const taskkill = spawn( + 'taskkill.exe', + ['/pid', String(windowsTerminationPid), '/t', '/f'], + { + stdio: 'ignore', + windowsHide: true + } + ) let taskkillFinished = false const finishTaskkill = (succeeded: boolean): void => { if (taskkillFinished) { @@ -1182,9 +1208,27 @@ export class ClaudeAccountService { }, WINDOWS_TASKKILL_TIMEOUT_MS) taskkill.once('error', () => finishTaskkill(false)) taskkill.once('close', (code) => finishTaskkill(code === 0)) + } + if (process.platform === 'win32') { + const resolveTerminationPid = interactiveLogin?.waitForTerminationPid + ? interactiveLogin.waitForTerminationPid() + : Promise.resolve(interactiveLogin?.getTerminationPid?.() ?? child.pid ?? null) + void resolveTerminationPid + .then((windowsTerminationPid) => { + if (windowsTerminationPid) { + killWindowsTree(windowsTerminationPid) + return + } + child.kill() + afterKill() + }) + .catch(() => { + child.kill() + afterKill() + }) return } - if (process.platform !== 'win32' && child.pid) { + if (child.pid) { try { process.kill(-child.pid) afterKill() @@ -1229,8 +1273,8 @@ export class ClaudeAccountService { }) } - stdout.on('data', appendOutput) - stderr.on('data', appendOutput) + stdout?.on('data', appendOutput) + stderr?.on('data', appendOutput) child.on('error', onError) // Native Windows browsers can inherit these pipes and indefinitely delay close. child.on(completionEvent, onDone) diff --git a/src/main/claude/compact-status-registration.test.ts b/src/main/claude/compact-status-registration.test.ts new file mode 100644 index 00000000000..f5a54aa1e15 --- /dev/null +++ b/src/main/claude/compact-status-registration.test.ts @@ -0,0 +1,413 @@ +import { describe, expect, it } from 'vitest' + +import { + clearAllListenerCaches, + clearPaneCacheState, + createHookListenerState, + movePaneCacheState, + normalizeHookPayload, + seedClaudeSubagentRosterFromSnapshots, + type AgentHookEventPayload, + type HookListenerState +} from '../../shared/agent-hook-listener' +import { makePaneKey } from '../../shared/stable-pane-id' +import { applyManagedHooks, CLAUDE_EVENTS } from './hook-settings' + +const PANE_KEY = makePaneKey('compact-registration', '11111111-1111-4111-8111-111111111111') +const TURN_PROMPT_ID = '22222222-2222-4222-8222-222222222222' +const COMPACT_PROMPT_ID = '33333333-3333-4333-8333-333333333333' + +const REGISTERED_EVENT_NAMES = new Set(CLAUDE_EVENTS.map((event) => event.eventName)) + +/** Deliver a hook event the way production does: only if the event is actually REGISTERED with + * Claude. Feeding the normalizer directly is what let a whole compact-correlation feature ship + * green while being unreachable — every behavior assertion here goes through this gate. */ +function deliverIfRegistered( + state: HookListenerState, + payload: Record +): AgentHookEventPayload | null { + if (!REGISTERED_EVENT_NAMES.has(payload.hook_event_name as string)) { + return null + } + const event = normalizeHookPayload(state, 'claude', { paneKey: PANE_KEY, payload }, 'production') + if (event) { + state.lastStatusByPaneKey.set(PANE_KEY, event) + } + return event +} + +function hook(name: string, promptId: string, extra: Record = {}) { + return { hook_event_name: name, prompt_id: promptId, session_id: 'session-a', ...extra } +} + +/** The exact sequence Claude Code emits for a successful manual /compact, measured from a PTY-driven + * interactive session (2.1.227). PreCompact and the summarizer's start-less SubagentStop are part + * of the real stream, so they are delivered here even though only PostCompact is registered. */ +function successfulManualCompact(state: HookListenerState) { + deliverIfRegistered(state, hook('PreCompact', COMPACT_PROMPT_ID, { trigger: 'manual' })) + deliverIfRegistered( + state, + hook('SubagentStop', COMPACT_PROMPT_ID, { + agent_id: 'a75b38b59774e1f31', + agent_type: '', + background_tasks: [], + session_crons: [] + }) + ) + deliverIfRegistered(state, hook('SessionStart', COMPACT_PROMPT_ID, { source: 'compact' })) + return deliverIfRegistered(state, hook('PostCompact', COMPACT_PROMPT_ID, { trigger: 'manual' })) +} + +function startTurn(state: HookListenerState) { + deliverIfRegistered(state, hook('UserPromptSubmit', TURN_PROMPT_ID, { prompt: 'do the thing' })) +} + +/** The row AgentHookServer.hydrate() rebuilds for a pane that was stuck `working` when Orca last + * exited: the previous session's connectionId, the unconfirmed flag, and — when the turn had + * spawned one — a child that exists only as a disk snapshot. */ +function hydrateStuckRow( + state: HookListenerState, + options: { withRestoredChild?: boolean; providerSession?: boolean } = {} +): void { + const subagents = options.withRestoredChild + ? [{ id: 'child-1', state: 'working' as const, startedAt: 0, agentType: 'general' }] + : undefined + const hydrated = { + paneKey: PANE_KEY, + source: 'claude', + connectionId: 'a-previous-connection', + hookEventName: 'UserPromptSubmit', + providerPromptId: TURN_PROMPT_ID, + ...(options.providerSession === false + ? {} + : { providerSession: { key: 'session_id', id: 'session-a' } }), + restoredUnconfirmed: true, + payload: { + state: 'working', + prompt: 'work before the restart', + agentType: 'claude', + ...(subagents ? { subagents } : {}) + } + } as unknown as AgentHookEventPayload + state.lastStatusByPaneKey.set(PANE_KEY, hydrated) + if (subagents) { + seedClaudeSubagentRosterFromSnapshots(state, PANE_KEY, subagents) + } +} + +describe('Claude compact hook registration', () => { + it('registers PostCompact and deliberately does not register PreCompact', () => { + expect(REGISTERED_EVENT_NAMES.has('PostCompact')).toBe(true) + // Why: PreCompact fires BEFORE the compact is validated and an aborted compact emits it alone, + // so subscribing to it at all is what strands the pane. This assertion is the guard. + expect(REGISTERED_EVENT_NAMES.has('PreCompact')).toBe(false) + }) + + it('writes PostCompact, and no PreCompact, into the settings Claude actually reads', () => { + const written = applyManagedHooks( + { hooks: {} }, + { type: 'command', command: 'orca-claude-hook' }, + 'claude-hook.sh' + ) + const postCompact = written.hooks?.PostCompact ?? [] + expect( + postCompact.some((definition) => + (definition.hooks ?? []).some((entry) => entry.command === 'orca-claude-hook') + ) + ).toBe(true) + expect(written.hooks?.PreCompact).toBeUndefined() + }) +}) + +describe('STA-2915: a manual compact clears the pane', () => { + it('ends a stuck working row as a silent done', () => { + const state = createHookListenerState() + startTurn(state) + expect(state.lastStatusByPaneKey.get(PANE_KEY)?.payload.state).toBe('working') + + const completion = successfulManualCompact(state) + + expect(completion?.payload.state).toBe('done') + // Why: a finished compact is a session-shaped boundary, not a completed turn — consumers that + // react to completion (automation runs, unread counts, notifications) must stay out of it. + expect(completion?.payload.sessionBoundary).toBe(true) + }) + + it('keeps the summarizer SubagentStop from being the thing that resolves the pane', () => { + const state = createHookListenerState() + startTurn(state) + // The start-less SubagentStop re-emits the cached lead state; it can only republish `working`, + // never clear it. Only PostCompact resolves the pane. + const republished = deliverIfRegistered( + state, + hook('SubagentStop', COMPACT_PROMPT_ID, { + agent_id: 'a75b38b59774e1f31', + agent_type: '', + background_tasks: [], + session_crons: [] + }) + ) + expect(republished?.payload.state).toBe('working') + }) +}) + +describe('STA-4613: an aborted compact must not strand the pane', () => { + it('leaves an idle pane idle when PreCompact arrives alone', () => { + const state = createHookListenerState() + startTurn(state) + deliverIfRegistered(state, hook('Stop', TURN_PROMPT_ID)) + expect(state.lastStatusByPaneKey.get(PANE_KEY)?.payload.state).toBe('done') + + // Measured: "Not enough messages to compact" emits PreCompact and nothing else. + deliverIfRegistered(state, hook('PreCompact', COMPACT_PROMPT_ID, { trigger: 'manual' })) + + expect(state.lastStatusByPaneKey.get(PANE_KEY)?.payload.state).toBe('done') + }) + + it('leaves a working pane working when PreCompact arrives alone', () => { + const state = createHookListenerState() + startTurn(state) + + deliverIfRegistered(state, hook('PreCompact', COMPACT_PROMPT_ID, { trigger: 'manual' })) + + expect(state.lastStatusByPaneKey.get(PANE_KEY)?.payload.state).toBe('working') + }) + + it('does not map PreCompact even when it is delivered past the registration gate', () => { + const state = createHookListenerState() + startTurn(state) + deliverIfRegistered(state, hook('Stop', TURN_PROMPT_ID)) + + const event = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: hook('PreCompact', COMPACT_PROMPT_ID, { trigger: 'manual' }) + }, + 'production' + ) + + expect(event).toBeNull() + }) +}) + +describe('an auto compact claims nothing', () => { + it('leaves the in-flight turn alone and lets its own Stop close it', () => { + const state = createHookListenerState() + startTurn(state) + + const autoCompact = deliverIfRegistered( + state, + hook('PostCompact', COMPACT_PROMPT_ID, { trigger: 'auto' }) + ) + + expect(autoCompact).toBeNull() + expect(state.lastStatusByPaneKey.get(PANE_KEY)?.payload.state).toBe('working') + + deliverIfRegistered(state, hook('Stop', TURN_PROMPT_ID)) + expect(state.lastStatusByPaneKey.get(PANE_KEY)?.payload.state).toBe('done') + }) + + it("writes no compact-scoped state, so it cannot spend the pane's consumed-compact slot", () => { + const state = createHookListenerState() + startTurn(state) + + deliverIfRegistered(state, hook('PostCompact', COMPACT_PROMPT_ID, { trigger: 'auto' })) + + // Why: only a manual completion claims anything. Running the ownership guard for an auto + // compact would mark its prompt id consumed for an event that maps to no state at all. + expect(state.claudeConsumedCompactPromptIdByPaneKey.has(PANE_KEY)).toBe(false) + }) +}) + +describe('STA-2915: the pane the ticket actually reports', () => { + it('clears a row stuck behind a child that exists only as a disk snapshot', () => { + const state = createHookListenerState() + hydrateStuckRow(state, { withRestoredChild: true }) + + deliverIfRegistered(state, hook('PreCompact', COMPACT_PROMPT_ID, { trigger: 'manual' })) + // Why: assert the summarizer stays silent rather than assuming it. With a restored roster it + // publishes nothing, which is what leaves the hydrated row in place for the completion — if + // that ever changes, this test must fail loudly instead of quietly testing something else. + const summarizer = deliverIfRegistered( + state, + hook('SubagentStop', COMPACT_PROMPT_ID, { agent_id: 'a75b38b59774e1f31', agent_type: '' }) + ) + expect(summarizer).toBeNull() + deliverIfRegistered(state, hook('SessionStart', COMPACT_PROMPT_ID, { source: 'compact' })) + + const completion = deliverIfRegistered( + state, + hook('PostCompact', COMPACT_PROMPT_ID, { trigger: 'manual' }) + ) + + // A restored child is a disk snapshot, not evidence of live work, and a manual /compact only + // completes at an idle prompt — so it may not keep the pane spinning. + expect(completion?.payload.state).toBe('done') + expect(completion?.payload.sessionBoundary).toBe(true) + expect(completion?.payload.subagents ?? []).toEqual([]) + }) + + it('leaves a pane held by a RUNTIME child at working, and republishes nothing', () => { + const state = createHookListenerState() + startTurn(state) + deliverIfRegistered(state, hook('SubagentStart', TURN_PROMPT_ID, { agent_id: 'child-live' })) + const before = state.lastStatusByPaneKey.get(PANE_KEY) + + const completion = deliverIfRegistered( + state, + hook('PostCompact', COMPACT_PROMPT_ID, { trigger: 'manual' }) + ) + + // Why: a child observed in this runtime IS live agent work. The compact must not retire it — + // and it must not restate the row either, because a restated row loses `restoredUnconfirmed` + // and restarts the staleness clock for work the compact never observed. + expect(completion).toBeNull() + expect(state.lastStatusByPaneKey.get(PANE_KEY)).toBe(before) + expect(before?.payload.state).toBe('working') + }) + + it('leaves a pane held by an active session cron at working, and republishes nothing', () => { + const state = createHookListenerState() + startTurn(state) + deliverIfRegistered(state, hook('Stop', TURN_PROMPT_ID, { session_crons: [{ id: 'cron-1' }] })) + const before = state.lastStatusByPaneKey.get(PANE_KEY) + expect(before?.payload.state).toBe('working') + + expect( + deliverIfRegistered(state, hook('PostCompact', COMPACT_PROMPT_ID, { trigger: 'manual' })) + ).toBeNull() + expect(state.lastStatusByPaneKey.get(PANE_KEY)).toBe(before) + }) +}) + +describe('compact completion guards', () => { + it('does not resurrect a pane that was retired before the completion arrived', () => { + const state = createHookListenerState() + // No cached status: the pane was closed/cleared. A compact clears a row, it never creates one. + expect(successfulManualCompact(state)).toBeNull() + expect(state.lastStatusByPaneKey.get(PANE_KEY)).toBeUndefined() + }) + + it('clears a stuck row restored from disk after a restart, despite a stale connection id', () => { + const state = createHookListenerState() + hydrateStuckRow(state) + // Why: the completion must land while the HYDRATED row is still the cached owner, or the + // restored-row branch is never the one under test. Deliver it alone — an intervening + // summarizer would replace the row with a live one and the assertion would pass through the + // strict branch instead, which is exactly how this test used to be vacuous. + const completion = deliverIfRegistered( + state, + hook('PostCompact', COMPACT_PROMPT_ID, { trigger: 'manual' }) + ) + + expect(completion?.payload.state).toBe('done') + expect(completion?.payload.sessionBoundary).toBe(true) + }) + + it('clears a restored row that predates provider-session persistence', () => { + const state = createHookListenerState() + hydrateStuckRow(state, { providerSession: false }) + expect(state.lastStatusByPaneKey.get(PANE_KEY)?.providerSession).toBeUndefined() + + const completion = deliverIfRegistered( + state, + hook('PostCompact', COMPACT_PROMPT_ID, { trigger: 'manual' }) + ) + + expect(completion?.payload.state).toBe('done') + }) + + it('does not clear a live turn owned by a different Claude session', () => { + const state = createHookListenerState() + startTurn(state) + + const foreign = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PostCompact', + prompt_id: COMPACT_PROMPT_ID, + session_id: 'a-different-session', + trigger: 'manual' + } + }, + 'production' + ) + + expect(foreign).toBeNull() + expect(state.lastStatusByPaneKey.get(PANE_KEY)?.payload.state).toBe('working') + }) + + it('rejects a compact completion with no provider prompt id', () => { + const state = createHookListenerState() + startTurn(state) + + const event = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { hook_event_name: 'PostCompact', session_id: 'session-a', trigger: 'manual' } + }, + 'production' + ) + + expect(event).toBeNull() + }) + + it('applies a compact completion once, so a duplicate cannot refresh the row', () => { + const state = createHookListenerState() + startTurn(state) + expect(successfulManualCompact(state)?.payload.state).toBe('done') + + const duplicate = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: hook('PostCompact', COMPACT_PROMPT_ID, { trigger: 'manual' }) + }, + 'production' + ) + + expect(duplicate).toBeNull() + }) +}) + +describe('the consumed-compact record is torn down with its pane', () => { + const OTHER_PANE = makePaneKey('compact-registration', '44444444-4444-4444-8444-444444444444') + + function paneWithConsumedCompact(): HookListenerState { + const state = createHookListenerState() + startTurn(state) + expect(successfulManualCompact(state)?.payload.state).toBe('done') + expect(state.claudeConsumedCompactPromptIdByPaneKey.get(PANE_KEY)).toBe(COMPACT_PROMPT_ID) + return state + } + + // Why: this codebase keeps growing a new per-pane store that lands in ONE teardown path and is + // missed by the siblings, which leaks the map and leaves stale reads behind a pane that is gone. + // Every route that tears pane state down has to take this map with it. + it('clearPaneCacheState drops it', () => { + const state = paneWithConsumedCompact() + clearPaneCacheState(state, PANE_KEY) + expect(state.claudeConsumedCompactPromptIdByPaneKey.has(PANE_KEY)).toBe(false) + }) + + it('movePaneCacheState carries it to the new pane key', () => { + const state = paneWithConsumedCompact() + movePaneCacheState(state, PANE_KEY, OTHER_PANE) + expect(state.claudeConsumedCompactPromptIdByPaneKey.has(PANE_KEY)).toBe(false) + expect(state.claudeConsumedCompactPromptIdByPaneKey.get(OTHER_PANE)).toBe(COMPACT_PROMPT_ID) + }) + + it('clearAllListenerCaches empties it', () => { + const state = paneWithConsumedCompact() + clearAllListenerCaches(state) + expect(state.claudeConsumedCompactPromptIdByPaneKey.size).toBe(0) + }) +}) diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index 3447c62ffa5..c6cf3a9b53c 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -88,6 +88,14 @@ export const CLAUDE_EVENTS = [ { eventName: 'PermissionRequest', definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } + }, + // Why: a manual /compact ends at an idle prompt without emitting Stop, so PostCompact is the only + // signal that can clear the pane (STA-2915). PreCompact is deliberately NOT registered: it fires + // before the compact is validated, and an aborted compact emits it alone — mapping it to 'working' + // would strand the pane exactly as this registration is meant to prevent (STA-4613). + { + eventName: 'PostCompact', + definition: { hooks: [{ type: 'command', command: '' }] } } ] as const diff --git a/src/main/codex-accounts/codex-windows-interactive-login.test.ts b/src/main/codex-accounts/codex-windows-interactive-login.test.ts new file mode 100644 index 00000000000..f9ceb64b4d3 --- /dev/null +++ b/src/main/codex-accounts/codex-windows-interactive-login.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' +import type * as NodeOs from 'node:os' +import { getCmdExePath } from '../../shared/windows-batch-spawn' +import { + createRateLimits, + createRuntimeHome, + createSettings, + createStore, + registerCodexAccountsTestHomes, + testState +} from './service-test-harness' + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.userDataDir + } +})) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { + ...actual, + homedir: () => testState.fakeHomeDir + } +}) + +describe('Codex Windows host interactive login', () => { + registerCodexAccountsTestHomes() + + it('resolves a Windows login whose child has no piped streams', async () => { + vi.resetModules() + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) + const child = new EventEmitter() as EventEmitter & { + stdout: null + stderr: null + kill: ReturnType + pid: number + } + child.stdout = null + child.stderr = null + child.kill = vi.fn() + child.pid = 4242 + const spawnMock = vi.fn(() => { + queueMicrotask(() => child.emit('close', 0)) + return child + }) + const buildInteractiveLoginSpawn = vi.fn(() => ({ + command: getCmdExePath(), + args: ['/d', '/c', 'start', '', '/wait', 'C:\\Tools\\codex.cmd', 'login'], + stdio: 'ignore' as const, + windowsHide: true + })) + vi.doMock('node:child_process', () => ({ + execFileSync: vi.fn(), + spawn: spawnMock + })) + vi.doMock('../codex-cli/command', () => ({ + resolveCodexCommand: () => 'C:\\Tools\\codex.cmd' + })) + vi.doMock('../../shared/windows-interactive-login-spawn', () => ({ + buildWindowsHostInteractiveLoginSpawn: buildInteractiveLoginSpawn + })) + + try { + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + createStore(createSettings()) as never, + createRateLimits() as never, + createRuntimeHome() as never + ) + await ( + service as unknown as { + runCodexLogin(managedHomePath: string): Promise + } + ).runCodexLogin(testState.fakeHomeDir) + + expect(buildInteractiveLoginSpawn).toHaveBeenCalledWith('C:\\Tools\\codex.cmd', ['login']) + expect(spawnMock).toHaveBeenCalledWith( + getCmdExePath(), + expect.arrayContaining(['start', '', '/wait']), + expect.objectContaining({ + stdio: 'ignore', + windowsHide: true + }) + ) + expect(child.kill).not.toHaveBeenCalled() + } finally { + Object.defineProperty(process, 'platform', originalPlatform) + vi.doUnmock('node:child_process') + vi.doUnmock('../codex-cli/command') + vi.doUnmock('../../shared/windows-interactive-login-spawn') + } + }) +}) diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index cd790207823..5fd0a5ef649 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -6,6 +6,10 @@ import { dirname, join, resolve, sep } from 'node:path' import { homedir } from 'node:os' import { app } from 'electron' import { getSpawnArgsForWindows } from '../win32-utils' +import { + buildWindowsHostInteractiveLoginSpawn, + type WindowsHostInteractiveLoginSpawn +} from '../../shared/windows-interactive-login-spawn' import type { CodexManagedAccount, CodexManagedAccountSummary, @@ -211,10 +215,14 @@ function removeManagedHomeTreeSync(targetPath: string): void { }) } -function killLoginProcessTree(child: ChildProcess): void { +function killLoginProcessTree( + child: ChildProcess, + interactiveLogin?: WindowsHostInteractiveLoginSpawn | null +): void { + const terminationPid = interactiveLogin?.getTerminationPid?.() ?? child.pid if ( process.platform === 'win32' && - typeof child.pid === 'number' && + typeof terminationPid === 'number' && child.exitCode === null && child.signalCode === null ) { @@ -222,7 +230,7 @@ function killLoginProcessTree(child: ChildProcess): void { // Why: child.kill() only reaches the direct child (cmd.exe for npm .cmd // shims); taskkill /t also ends codex descendants whose open handles on // the managed home make post-login file operations fail with ENOTEMPTY. - execFileSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { + execFileSync('taskkill', ['/pid', String(terminationPid), '/t', '/f'], { windowsHide: true, timeout: WINDOWS_LOGIN_TREE_KILL_TIMEOUT_MS, stdio: 'ignore' @@ -1711,12 +1719,20 @@ export class CodexAccountService { command: 'wsl.exe', args: buildWslCodexLoginArgs(wslInfo.distro, wslInfo.linuxPath), env: process.env, - codexCommand: 'codex' + codexCommand: 'codex', + interactiveLogin: null } : (() => { const codexCommand = resolveCodexCommand() - // Why: Windows codex may be a .cmd/.bat; spawn+shell:true would trigger DEP0190, so invoke cmd.exe /c explicitly. - const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(codexCommand, ['login']) + // Why: Windows host login needs a real console; otherwise inherit/hide + // leaves the child unable to read a paste-code / device-auth prompt. + const interactiveLogin = + process.platform === 'win32' + ? buildWindowsHostInteractiveLoginSpawn(codexCommand, ['login']) + : null + const { spawnCmd, spawnArgs } = interactiveLogin + ? { spawnCmd: interactiveLogin.command, spawnArgs: interactiveLogin.args } + : getSpawnArgsForWindows(codexCommand, ['login']) return { command: spawnCmd, args: spawnArgs, @@ -1724,12 +1740,15 @@ export class CodexAccountService { ...process.env, CODEX_HOME: managedHomePath }, - codexCommand + codexCommand, + interactiveLogin } })() const child = spawn(spawnConfig.command, spawnConfig.args, { - stdio: ['ignore', 'pipe', 'pipe'], - // Why: prevents a console window flash for .cmd/.bat entrypoints routed through cmd.exe on Windows. + stdio: spawnConfig.interactiveLogin + ? spawnConfig.interactiveLogin.stdio + : ['ignore', 'pipe', 'pipe'], + // Why: hide the outer wrapper only. A dedicated login console stays visible. windowsHide: true, env: spawnConfig.env }) @@ -1761,10 +1780,11 @@ export class CodexAccountService { clearTimeout(postAuthExitTimeout) postAuthExitTimeout = null } - child.stdout.off('data', appendOutput) - child.stderr.off('data', appendOutput) + child.stdout?.off('data', appendOutput) + child.stderr?.off('data', appendOutput) child.off('error', onError) child.off('close', onClose) + spawnConfig.interactiveLogin?.cleanup?.() } const settle = (callback: () => void): void => { @@ -1778,7 +1798,7 @@ export class CodexAccountService { const timeoutError = new Error('Codex sign-in took too long to finish. Please try again.') timeout = setTimeout(() => { - killLoginProcessTree(child) + killLoginProcessTree(child, spawnConfig.interactiveLogin) settle(() => { rejectPromise(timeoutError) }) @@ -1799,7 +1819,7 @@ export class CodexAccountService { } postAuthExitTimeout = setTimeout(() => { loginTreeKilledAfterAuth = true - killLoginProcessTree(child) + killLoginProcessTree(child, spawnConfig.interactiveLogin) }, WINDOWS_LOGIN_POST_AUTH_EXIT_GRACE_MS) }, WINDOWS_LOGIN_AUTH_POLL_INTERVAL_MS) } @@ -1845,8 +1865,8 @@ export class CodexAccountService { }) } - child.stdout.on('data', appendOutput) - child.stderr.on('data', appendOutput) + child.stdout?.on('data', appendOutput) + child.stderr?.on('data', appendOutput) child.on('error', onError) child.on('close', onClose) }) diff --git a/src/main/codex/codex-hooks-read-denial-followup.test.ts b/src/main/codex/codex-hooks-read-denial-followup.test.ts new file mode 100644 index 00000000000..1efb40f571d --- /dev/null +++ b/src/main/codex/codex-hooks-read-denial-followup.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFs from 'node:fs' +import type * as NodeOs from 'node:os' +import type * as CodexManagedTrustReconciliation from './codex-managed-trust-reconciliation' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +// Follow-up to STA-4823: two more reads on the Codex hook paths acted on +// "absent" when the real answer was "could not read". Both were found while +// reviewing #15417 and are not covered by it. +// +// The denial shape here is the MEASURED one: chmod 000 / icacls deny leave +// existsSync true and stat succeeding, and fail only the content read +// (EACCES -13 on macOS, EPERM -4048 on Windows). No mode fakes existsSync, +// because no platform does. + +const denials = vi.hoisted(() => { + const state = { + paths: new Set(), + deny(path: string): void { + state.paths.add(path) + }, + reset(): void { + state.paths.clear() + }, + check(target: unknown, syscall: string): void { + if (typeof target !== 'string' || !state.paths.has(target)) { + return + } + const error: NodeJS.ErrnoException = new Error( + `EACCES: permission denied, ${syscall} '${target}'` + ) + error.code = 'EACCES' + error.errno = -13 + error.syscall = syscall + error.path = target + throw error + } + } + return state +}) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + const guardRead = (fn: unknown, syscall: string): unknown => { + const original = fn as (...args: unknown[]) => unknown + const wrapped = (...args: unknown[]): unknown => { + denials.check(args[0], syscall) + return original(...args) + } + return Object.assign(wrapped, original) + } + const patched: Record = { + ...actual, + readFileSync: guardRead(actual.readFileSync, 'read'), + // Why: existsSync and stat stay HEALTHY. That is the whole point — a + // permission denial does not hide the file, it only refuses its contents. + openSync: Object.assign((...args: unknown[]): unknown => { + const flags = args[1] + if (flags === undefined || (typeof flags === 'string' && flags.startsWith('r'))) { + denials.check(args[0], 'open') + } + return (actual.openSync as (...a: unknown[]) => unknown)(...args) + }, actual.openSync) + } + return { ...patched, default: patched } +}) + +const { getPathMock, homedirMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>(), + homedirMock: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ app: { getPath: getPathMock } })) +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { ...actual, homedir: homedirMock } +}) + +const realFs = await vi.importActual('node:fs') + +let fakeHomeDir: string +let userDataDir: string +let previousUserDataPath: string | undefined + +beforeEach(() => { + denials.reset() + fakeHomeDir = realFs.mkdtempSync(join(tmpdir(), 'orca-hooksread-home-')) + userDataDir = realFs.mkdtempSync(join(tmpdir(), 'orca-hooksread-data-')) + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = userDataDir + homedirMock.mockReturnValue(fakeHomeDir) + getPathMock.mockImplementation((name: string) => { + if (name === 'userData') { + return userDataDir + } + throw new Error(`unexpected app.getPath(${name})`) + }) + realFs.mkdirSync(join(fakeHomeDir, '.codex'), { recursive: true }) +}) + +afterEach(() => { + denials.reset() + realFs.rmSync(fakeHomeDir, { recursive: true, force: true }) + realFs.rmSync(userDataDir, { recursive: true, force: true }) + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + vi.clearAllMocks() +}) + +describe('an unreadable legacy hooks.json must not clear managed trust or its ledger', () => { + it('returns before the removal branch instead of discarding approvals', async () => { + const legacyHooksPath = join(fakeHomeDir, '.codex', 'hooks.json') + realFs.writeFileSync(legacyHooksPath, JSON.stringify({ hooks: { Stop: [] } }), 'utf-8') + + const removeSpy = vi.fn() + vi.resetModules() + vi.doMock('./codex-managed-trust-reconciliation', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, removeCodexManagedHookTrustEntries: removeSpy } + }) + try { + // The removal only fires when a real-home grant is on record — without + // this the branch is inert and the test proves nothing either way. + const ledger = await import('./codex-trust-grant-ledger') + ledger.writeCodexTrustGrantLedgerHome( + join(fakeHomeDir, '.codex'), + { entries: { 'seed:1': { trustedHash: 'sha256:seed' } } } as never, + ledger.getCodexTrustGrantLedgerPath() + ) + + const hookService = await import('./hook-service') + denials.deny(legacyHooksPath) + + hookService._internals.cleanupLegacySystemManagedHooks() + + // `readHooksJsonWithRaw` catches the denial and reports {raw:null, + // config:null} — "could not read". Before the fix that fell into the same + // branch as "no hooks configured", which removes the managed trust + // entries AND their grant-ledger record. A read that failed is not + // permission to discard approvals the user already gave. + expect(removeSpy).not.toHaveBeenCalled() + } finally { + vi.doUnmock('./codex-managed-trust-reconciliation') + vi.resetModules() + } + }) +}) diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index a0e30c5941c..270906012b5 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -601,6 +601,13 @@ function cleanupLegacySystemManagedHooks(): void { // Why: the pre-write guard below compares against these bytes; a separate // later read would let a concurrent save land between parse and snapshot. const { raw: previousRaw, config } = readHooksJsonWithRaw(legacyConfigPath) + // Why: `config === null` with no raw is the "could not read" answer, not the + // "no hooks here" one — the branch below removes managed trust entries AND + // their grant-ledger record, so acting on it would discard approvals over a + // read that merely failed. A genuine absence still returns `config: {}`. + if (config === null && previousRaw === null) { + return + } if (!config?.hooks || previousRaw === null) { if (hasRecordedRealHomeGrant) { removeSystemManagedHookTrustEntries(systemHomePath, legacyConfigPath) @@ -1637,6 +1644,7 @@ export class CodexHookService { export const codexHookService = new CodexHookService() export const _internals = { + cleanupLegacySystemManagedHooks, getManagedScript, installManagedHooksIntoWslRuntime, refreshWslRuntimeUserHooks, diff --git a/src/main/crash-reporting/crash-breadcrumb-renderer-attribution.test.ts b/src/main/crash-reporting/crash-breadcrumb-renderer-attribution.test.ts new file mode 100644 index 00000000000..3431a35089b --- /dev/null +++ b/src/main/crash-reporting/crash-breadcrumb-renderer-attribution.test.ts @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { + clearCrashBreadcrumbsForTest, + getCrashBreadcrumbSnapshot, + recordCoalescedCrashBreadcrumb, + recordCrashBreadcrumb +} from './crash-breadcrumb-store' + +describe('renderer crash breadcrumb attribution', () => { + beforeEach(() => { + clearCrashBreadcrumbsForTest() + }) + + it('filters a renderer report to its own breadcrumbs while retaining global evidence', () => { + recordCrashBreadcrumb('app_started', { packaged: true }) + recordCrashBreadcrumb('renderer_a_event', undefined, 'renderer:11') + recordCrashBreadcrumb('renderer_b_event', undefined, 'renderer:22') + + expect(getCrashBreadcrumbSnapshot('renderer:11').map((entry) => entry.name)).toEqual([ + 'app_started', + 'renderer_a_event' + ]) + expect(getCrashBreadcrumbSnapshot('renderer:22').map((entry) => entry.name)).toEqual([ + 'app_started', + 'renderer_b_event' + ]) + expect(getCrashBreadcrumbSnapshot().map((entry) => entry.name)).toEqual([ + 'app_started', + 'renderer_a_event', + 'renderer_b_event' + ]) + }) + + it('does not merge identical coalesced storms from sibling renderers', () => { + recordCoalescedCrashBreadcrumb({ + name: 'renderer_error', + data: { message: 'boom' }, + coalesceKey: 'renderer:11\u0000renderer_error:boom', + minIntervalMs: 30_000, + origin: 'renderer:11' + }) + recordCoalescedCrashBreadcrumb({ + name: 'renderer_error', + data: { message: 'boom' }, + coalesceKey: 'renderer:22\u0000renderer_error:boom', + minIntervalMs: 30_000, + origin: 'renderer:22' + }) + + expect(getCrashBreadcrumbSnapshot('renderer:11')).toHaveLength(1) + expect(getCrashBreadcrumbSnapshot('renderer:22')).toHaveLength(1) + expect(getCrashBreadcrumbSnapshot('renderer:11')[0]?.origin).toBe('renderer:11') + expect(getCrashBreadcrumbSnapshot('renderer:22')[0]?.origin).toBe('renderer:22') + }) + + it('retains identical high-water profiles independently per renderer', () => { + const profile = { rendererSurface: 'main', thresholdPct: 80, usedHeapMB: 512 } + recordCrashBreadcrumb('renderer_memory_highwater', profile, 'renderer:11') + recordCrashBreadcrumb('renderer_memory_highwater', profile, 'renderer:22') + + expect(getCrashBreadcrumbSnapshot('renderer:11')[0]?.origin).toBe('renderer:11') + expect(getCrashBreadcrumbSnapshot('renderer:22')[0]?.origin).toBe('renderer:22') + }) + + it('preserves the reporter trail when sibling renderer traffic fills the ring', () => { + for (let index = 0; index < 4; index += 1) { + recordCrashBreadcrumb('renderer_memory_highwater', { + rendererSurface: `surface-${index}`, + thresholdPct: 80 + }) + } + recordCrashBreadcrumb('renderer_a_event', undefined, 'renderer:11') + for (let index = 0; index < 26; index += 1) { + recordCrashBreadcrumb(`renderer_b_event_${index}`, undefined, 'renderer:22') + } + + expect(getCrashBreadcrumbSnapshot('renderer:11').map((entry) => entry.name)).toContain( + 'renderer_a_event' + ) + }) + + it('folds pending repeats using the reporter-specific evidence window', () => { + recordCoalescedCrashBreadcrumb({ + name: 'renderer_error', + data: { message: 'boom' }, + coalesceKey: 'renderer:11\u0000renderer_error:boom', + minIntervalMs: 30_000, + origin: 'renderer:11' + }) + recordCoalescedCrashBreadcrumb({ + name: 'renderer_error', + data: { message: 'boom' }, + coalesceKey: 'renderer:22\u0000renderer_error:boom', + minIntervalMs: 30_000, + origin: 'renderer:22' + }) + recordCoalescedCrashBreadcrumb({ + name: 'renderer_error', + data: { message: 'boom' }, + coalesceKey: 'renderer:22\u0000renderer_error:boom', + minIntervalMs: 30_000, + origin: 'renderer:22' + }) + recordCoalescedCrashBreadcrumb({ + name: 'renderer_error', + data: { message: 'boom' }, + coalesceKey: 'renderer:11\u0000renderer_error:boom', + minIntervalMs: 30_000, + origin: 'renderer:11' + }) + for (let index = 0; index < 4; index += 1) { + recordCrashBreadcrumb( + 'renderer_memory_highwater', + { rendererSurface: `surface-${index}`, thresholdPct: 80 }, + 'renderer:22' + ) + } + for (let index = 0; index < 25; index += 1) { + recordCrashBreadcrumb(`renderer_b_event_${index}`, undefined, 'renderer:22') + } + + const snapshot = getCrashBreadcrumbSnapshot('renderer:11') + + expect(snapshot).toHaveLength(1) + expect(snapshot[0]?.data).toEqual({ message: 'boom', suppressedSinceLast: 1 }) + + const siblingSnapshot = getCrashBreadcrumbSnapshot('renderer:22') + expect(siblingSnapshot.find((entry) => entry.name === 'renderer_error')?.data).toEqual({ + message: 'boom', + suppressedSinceLast: 1 + }) + }) +}) diff --git a/src/main/crash-reporting/crash-breadcrumb-store.ts b/src/main/crash-reporting/crash-breadcrumb-store.ts index e5f96e8f05e..0a9a71f452e 100644 --- a/src/main/crash-reporting/crash-breadcrumb-store.ts +++ b/src/main/crash-reporting/crash-breadcrumb-store.ts @@ -30,6 +30,7 @@ type CoalescedBreadcrumbState = { emitted?: CrashReportBreadcrumb /** Newest suppressed payload, sanitized only if a snapshot actually asks for it. */ pending?: CrashReportBreadcrumbData + origin?: string } let breadcrumbs: CrashReportBreadcrumb[] = [] @@ -42,19 +43,21 @@ function retainedBreadcrumbKey(breadcrumb: CrashReportBreadcrumb): string | null } const surface = breadcrumb.data?.rendererSurface const threshold = breadcrumb.data?.thresholdPct - return `${breadcrumb.name}:${String(surface)}:${String(threshold)}` + return `${breadcrumb.name}:${String(surface)}:${String(threshold)}:${breadcrumb.origin ?? 'global'}` } /** Returns the stored breadcrumb so coalescing can refresh the entry it owns. */ export function recordCrashBreadcrumb( name: string, - data?: CrashReportBreadcrumbData + data?: CrashReportBreadcrumbData, + origin?: string ): CrashReportBreadcrumb | undefined { const sanitized = sanitizeCrashReportBreadcrumbs([ { createdAt: new Date().toISOString(), name, - data + data, + ...(origin ? { origin } : {}) } ]) const breadcrumb = sanitized?.[0] @@ -85,12 +88,14 @@ export function recordCoalescedCrashBreadcrumb({ name, data, coalesceKey, - minIntervalMs + minIntervalMs, + origin }: { name: string data?: CrashReportBreadcrumbData coalesceKey: string minIntervalMs: number + origin?: string }): { suppressedSinceLast: number } | undefined { const now = monotonicNow() const previous = coalescedBreadcrumbs.get(coalesceKey) @@ -136,7 +141,8 @@ export function recordCoalescedCrashBreadcrumb({ windowStartedAtMs: now, suppressed: 0, carried: suppressedSinceLast, - resolved: 0 + resolved: 0, + ...(origin ? { origin } : {}) } coalescedBreadcrumbs.set(coalesceKey, state) while (coalescedBreadcrumbs.size > MAX_COALESCE_KEYS) { @@ -149,7 +155,8 @@ export function recordCoalescedCrashBreadcrumb({ } state.emitted = recordCrashBreadcrumb( name, - suppressedSinceLast > 0 ? { ...data, suppressedSinceLast } : data + suppressedSinceLast > 0 ? { ...data, suppressedSinceLast } : data, + origin ) return { suppressedSinceLast } } @@ -157,22 +164,29 @@ export function recordCoalescedCrashBreadcrumb({ /** Whether any future snapshot can still include this crumb. The ring only * appends and the retained map only grows toward its cap, so an entry pushed * past the snapshot budget is invisible forever, not just for now. */ -function isCoalescedCrumbStillInEvidence(crumb: CrashReportBreadcrumb): boolean { - const visibleFrom = breadcrumbs.length - (MAX_BREADCRUMBS - retainedBreadcrumbs.size) - const index = breadcrumbs.indexOf(crumb) - if (index !== -1 && index >= visibleFrom) { +function isCoalescedCrumbStillInEvidence( + crumb: CrashReportBreadcrumb, + reporterOrigin?: string +): boolean { + const retained = [...retainedBreadcrumbs.values()].filter((breadcrumb) => + isVisibleToReporter(breadcrumb, reporterOrigin) + ) + if (retained.some((retainedBreadcrumb) => retainedBreadcrumb === crumb)) { return true } - for (const retained of retainedBreadcrumbs.values()) { - if (retained === crumb) { - return true - } - } - return false + const visibleRecent = breadcrumbs.filter((breadcrumb) => + isVisibleToReporter(breadcrumb, reporterOrigin) + ) + return visibleRecent + .slice(-(MAX_BREADCRUMBS - retained.length)) + .some((recentBreadcrumb) => recentBreadcrumb === crumb) } /** Fold a key's newest suppressed payload into the ring entry it owns. */ -function resolvePendingCoalescedBreadcrumb(state: CoalescedBreadcrumbState): void { +function resolvePendingCoalescedBreadcrumb( + state: CoalescedBreadcrumbState, + reporterOrigin?: string +): void { // `data` is optional, so the count—not pending payload presence—marks unresolved work. if (!state.emitted || state.suppressed <= state.resolved) { return @@ -182,7 +196,7 @@ function resolvePendingCoalescedBreadcrumb(state: CoalescedBreadcrumbState): voi // then claim nothing — the burst vanishes from the record entirely. Drop the // handle (keeping `resolved` for folds that landed while it was live) so a // later emit or bounded cleanup can materialize the unclaimed repeats. - if (!isCoalescedCrumbStillInEvidence(state.emitted)) { + if (!isCoalescedCrumbStillInEvidence(state.emitted, reporterOrigin)) { state.emitted = undefined return } @@ -201,30 +215,46 @@ function resolvePendingCoalescedBreadcrumb(state: CoalescedBreadcrumbState): voi /** Resolve into the owned crumb, or emit the unclaimed repeats before bounded * cleanup drops an orphan's last accounting state. */ function preservePendingCoalescedBreadcrumb(state: CoalescedBreadcrumbState): void { - resolvePendingCoalescedBreadcrumb(state) + resolvePendingCoalescedBreadcrumb(state, state.origin) const unresolved = state.suppressed - state.resolved if (state.emitted || unresolved <= 0) { return } - recordCrashBreadcrumb(state.name, { - ...state.pending, - suppressedSinceLast: unresolved - }) + recordCrashBreadcrumb( + state.name, + { ...state.pending, suppressedSinceLast: unresolved }, + state.origin + ) state.resolved = state.suppressed state.pending = undefined } -function resolveAllPendingCoalescedBreadcrumbs(): void { +function resolveAllPendingCoalescedBreadcrumbs(reporterOrigin?: string): void { for (const state of coalescedBreadcrumbs.values()) { - resolvePendingCoalescedBreadcrumb(state) + if (reporterOrigin && state.origin && state.origin !== reporterOrigin) { + continue + } + resolvePendingCoalescedBreadcrumb(state, reporterOrigin) } } -export function getCrashBreadcrumbSnapshot(): CrashReportBreadcrumb[] { - resolveAllPendingCoalescedBreadcrumbs() +function isVisibleToReporter( + breadcrumb: CrashReportBreadcrumb, + reporterOrigin: string | undefined +): boolean { + return !reporterOrigin || !breadcrumb.origin || breadcrumb.origin === reporterOrigin +} + +export function getCrashBreadcrumbSnapshot(reporterOrigin?: string): CrashReportBreadcrumb[] { + resolveAllPendingCoalescedBreadcrumbs(reporterOrigin) // Why: long sessions must retain threshold profiles without growing the 30-entry budget. - const retained = [...retainedBreadcrumbs.values()] - const recent = breadcrumbs.slice(-(MAX_BREADCRUMBS - retained.length)) + const retained = [...retainedBreadcrumbs.values()].filter((breadcrumb) => + isVisibleToReporter(breadcrumb, reporterOrigin) + ) + const visibleRecent = breadcrumbs.filter((breadcrumb) => + isVisibleToReporter(breadcrumb, reporterOrigin) + ) + const recent = visibleRecent.slice(-(MAX_BREADCRUMBS - retained.length)) return [...retained, ...recent] .sort((left, right) => left.createdAt.localeCompare(right.createdAt)) .map((breadcrumb) => ({ diff --git a/src/main/crash-reporting/crash-report-store.test.ts b/src/main/crash-reporting/crash-report-store.test.ts index d594bd2090a..8a99a79df4a 100644 --- a/src/main/crash-reporting/crash-report-store.test.ts +++ b/src/main/crash-reporting/crash-report-store.test.ts @@ -41,7 +41,8 @@ function input(reason = 'crashed'): CrashReportCreateInput { { createdAt: '2026-05-16T01:00:00.000Z', name: 'workspace_opened', - data: { path: '/Users/alice/project', ssh: false } + data: { path: '/Users/alice/project', ssh: false }, + origin: 'renderer:42' } ] } diff --git a/src/main/crash-reporting/crash-report-store.ts b/src/main/crash-reporting/crash-report-store.ts index 64028b8e2fb..143210418ce 100644 --- a/src/main/crash-reporting/crash-report-store.ts +++ b/src/main/crash-reporting/crash-report-store.ts @@ -96,7 +96,9 @@ export class CrashReportStore { createdAt: new Date().toISOString(), status: 'pending', details: sanitizeCrashReportDetails(input.details), - breadcrumbs: sanitizeCrashReportBreadcrumbs(input.breadcrumbs) + breadcrumbs: sanitizeCrashReportBreadcrumbs(input.breadcrumbs)?.map( + ({ origin: _origin, ...breadcrumb }) => breadcrumb + ) } return { reports: [report, ...reports].slice(0, MAX_REPORTS), diff --git a/src/main/crash-reporting/durable-crash-breadcrumb.ts b/src/main/crash-reporting/durable-crash-breadcrumb.ts index e4271fb61d8..ff1c77bc9c6 100644 --- a/src/main/crash-reporting/durable-crash-breadcrumb.ts +++ b/src/main/crash-reporting/durable-crash-breadcrumb.ts @@ -41,11 +41,12 @@ function traceDurableBreadcrumb( export function recordDurableCrashBreadcrumb( name: string, data?: CrashReportBreadcrumbData, - failureCause?: string + failureCause?: string, + origin?: string ): void { const sanitizedName = sanitizeCrashReportString(name) const lifecycleData = buildLifecycleData(data) - recordCrashBreadcrumb(sanitizedName, lifecycleData) + recordCrashBreadcrumb(sanitizedName, lifecycleData, origin) traceDurableBreadcrumb(sanitizedName, lifecycleData, failureCause) } @@ -56,12 +57,14 @@ export function recordCoalescedDurableCrashBreadcrumb({ name, data, coalesceKey, - minIntervalMs + minIntervalMs, + origin }: { name: string data?: CrashReportBreadcrumbData coalesceKey: string minIntervalMs: number + origin?: string }): void { const sanitizedName = sanitizeCrashReportString(name) const lifecycleData = buildLifecycleData(data) @@ -69,7 +72,8 @@ export function recordCoalescedDurableCrashBreadcrumb({ name: sanitizedName, data: lifecycleData, coalesceKey, - minIntervalMs + minIntervalMs, + ...(origin ? { origin } : {}) }) if (!coalesced) { return diff --git a/src/main/crash-reporting/process-gone-dedupe.test.ts b/src/main/crash-reporting/process-gone-dedupe.test.ts index c4a2dc714fc..b3db4b4a0fd 100644 --- a/src/main/crash-reporting/process-gone-dedupe.test.ts +++ b/src/main/crash-reporting/process-gone-dedupe.test.ts @@ -77,6 +77,23 @@ describe('ProcessGoneDedupe', () => { ).toBe(true) }) + it('does not suppress simultaneous crashes from different renderers', () => { + const dedupe = new ProcessGoneDedupe({ windowMs: 2_000 }) + + expect( + dedupe.shouldRecord(getProcessGoneDedupeKey('renderer', 'renderer', 'crashed', 5, 11), 1_000) + ).toBe(true) + expect( + dedupe.shouldRecord(getProcessGoneDedupeKey('renderer', 'renderer', 'crashed', 5, 22), 1_001) + ).toBe(true) + expect( + dedupe.shouldRecord( + getProcessGoneDedupeKey('renderer', 'renderer', 'oom', -536870904, 11), + 1_002 + ) + ).toBe(false) + }) + it('keeps child process crash tuples distinct inside renderer burst windows', () => { const dedupe = new ProcessGoneDedupe({ windowMs: 2_000 }) diff --git a/src/main/crash-reporting/process-gone-dedupe.ts b/src/main/crash-reporting/process-gone-dedupe.ts index b35cccc6576..861e6738e4e 100644 --- a/src/main/crash-reporting/process-gone-dedupe.ts +++ b/src/main/crash-reporting/process-gone-dedupe.ts @@ -80,12 +80,14 @@ export function getProcessGoneDedupeKey( source: 'renderer' | 'child', processType: string, reason: string, - exitCode: number | null + exitCode: number | null, + webContentsId?: number ): string { // Why: one renderer death can surface as crashed/oom/launch-failed in a - // burst. Coalesce that prompt noise while keeping child identities precise. + // burst. Coalesce that prompt noise per renderer while keeping child + // identities precise. if (source === 'renderer') { - return `${source}:${processType}` + return `${source}:${processType}:${webContentsId ?? 'global'}` } return `${source}:${processType}:${reason}:${exitCode ?? 'null'}` } diff --git a/src/main/crash-reporting/process-gone-recorder.test.ts b/src/main/crash-reporting/process-gone-recorder.test.ts index 24749b1bacf..9f564712e24 100644 --- a/src/main/crash-reporting/process-gone-recorder.test.ts +++ b/src/main/crash-reporting/process-gone-recorder.test.ts @@ -114,6 +114,22 @@ describe('recordProcessGoneCrash', () => { expect(sink.flushMock).toHaveBeenCalledOnce() }) + it('keeps suppressed renderer evidence scoped to its renderer', () => { + const dedupe = new ProcessGoneDedupe() + const suppressed = (webContentsId: number) => + event({ reason: 'killed', exitCode: 1, expectedTeardown: 'renderer-reload', webContentsId }) + + recordProcessGoneCrash({ record: vi.fn() } as never, suppressed(11), dedupe) + recordProcessGoneCrash({ record: vi.fn() } as never, suppressed(22), dedupe) + + expect(getCrashBreadcrumbSnapshot('renderer:11')).toEqual([ + expect.objectContaining({ origin: 'renderer:11' }) + ]) + expect(getCrashBreadcrumbSnapshot('renderer:22')).toEqual([ + expect.objectContaining({ origin: 'renderer:22' }) + ]) + }) + it('coalesces a recoverable-service crash loop instead of flushing every event', () => { const record = vi.fn() const dedupe = new ProcessGoneDedupe() @@ -314,6 +330,26 @@ describe('recordProcessGoneCrash', () => { expect(sink.flushMock).toHaveBeenCalledOnce() }) + it('keeps simultaneous renderer reports distinct by webContents identity', async () => { + const record = vi.fn().mockResolvedValue({ id: 'report-1' }) + const dedupe = new ProcessGoneDedupe() + + recordProcessGoneCrash( + { record, attachDetails } as never, + event({ webContentsId: 11 }), + dedupe, + noMinidump + ) + recordProcessGoneCrash( + { record, attachDetails } as never, + event({ webContentsId: 22 }), + dedupe, + noMinidump + ) + + await vi.waitFor(() => expect(record).toHaveBeenCalledTimes(2)) + }) + it('still persists the report when the forced trace flush fails', async () => { const record = vi.fn().mockResolvedValue({ id: 'report-1' }) sink.flushMock.mockImplementation(() => { diff --git a/src/main/crash-reporting/process-gone-recorder.ts b/src/main/crash-reporting/process-gone-recorder.ts index 947e8165bb3..ec9d53bdfff 100644 --- a/src/main/crash-reporting/process-gone-recorder.ts +++ b/src/main/crash-reporting/process-gone-recorder.ts @@ -7,6 +7,7 @@ import { type CrashReportBreadcrumbData } from '../../shared/crash-reporting' import { decodePosixWaitStatus, describePosixWaitStatus } from '../../shared/posix-wait-status' +import { rendererCrashBreadcrumbOrigin } from '../../shared/crash-breadcrumb-origin' import type { CrashReportStore } from './crash-report-store' import { getCrashBreadcrumbSnapshot } from './crash-breadcrumb-store' import { @@ -41,6 +42,7 @@ export type ProcessGoneCrashEvent = { exitCode: number | null expectedTeardown: ExpectedTeardownScope details: Record + webContentsId?: number } type CrashReportRecorderStore = Pick @@ -74,6 +76,12 @@ function processGoneBreadcrumbData(event: ProcessGoneCrashEvent) { return buildSuppressedProcessGoneBreadcrumbData(event) } +function processGoneRendererOrigin(event: ProcessGoneCrashEvent): string | undefined { + return event.webContentsId === undefined + ? undefined + : rendererCrashBreadcrumbOrigin(event.webContentsId) +} + // Why: key off the emitted breadcrumb, not the crash-report dedupe key, so two // different recoverable services can never suppress each other's evidence. function suppressedProcessGoneCoalesceKey(data: CrashReportBreadcrumbData): string { @@ -179,11 +187,15 @@ export function recordProcessGoneCrash( // 1459/min) and each suppressed event costs a span plus a forced disk flush, // which both floods the 30-entry ring and evicts the real pre-crash trail. const suppressedData = processGoneBreadcrumbData(event) + const origin = processGoneRendererOrigin(event) recordCoalescedDurableCrashBreadcrumb({ name: 'process_gone_suppressed', data: suppressedData, - coalesceKey: suppressedProcessGoneCoalesceKey(suppressedData), - minIntervalMs: SUPPRESSED_PROCESS_GONE_COALESCE_MS + coalesceKey: origin + ? `${origin}\u0000${suppressedProcessGoneCoalesceKey(suppressedData)}` + : suppressedProcessGoneCoalesceKey(suppressedData), + minIntervalMs: SUPPRESSED_PROCESS_GONE_COALESCE_MS, + ...(origin ? { origin } : {}) }) return } @@ -191,12 +203,19 @@ export function recordProcessGoneCrash( recordDurableCrashBreadcrumb( 'crash_report_store_unavailable', processGoneBreadcrumbData(event), - 'Crash report store unavailable' + 'Crash report store unavailable', + processGoneRendererOrigin(event) ) return } - const key = getProcessGoneDedupeKey(event.source, event.processType, event.reason, event.exitCode) + const key = getProcessGoneDedupeKey( + event.source, + event.processType, + event.reason, + event.exitCode, + event.webContentsId + ) const claim = dedupe.tryClaim(key) if (!claim) { return @@ -209,7 +228,8 @@ export function recordProcessGoneCrash( }, event.processType ) - const breadcrumbs = getCrashBreadcrumbSnapshot() + const breadcrumbs = getCrashBreadcrumbSnapshot(processGoneRendererOrigin(event)) + const reportBreadcrumbs = breadcrumbs?.map(({ origin: _origin, ...breadcrumb }) => breadcrumb) const span = startSpan('electron.process_gone', { attributes: { 'crash.source': event.source, @@ -227,7 +247,7 @@ export function recordProcessGoneCrash( 'app.main_process.launch_id': mainProcessLifecycle.mainProcessLaunchId, 'app.main_process.started_at': mainProcessLifecycle.mainProcessStartedAt, details: crashDetails, - breadcrumbs + breadcrumbs: reportBreadcrumbs } }) // Why: a renderer crash can be followed by another process exit before the @@ -252,7 +272,7 @@ export function recordProcessGoneCrash( electronVersion: process.versions.electron ?? 'unknown', chromeVersion: process.versions.chrome ?? 'unknown', details: crashDetails, - breadcrumbs + breadcrumbs: reportBreadcrumbs }) .then((report) => { // Why: kept off the returned chain so a minidump failure can never reach @@ -268,7 +288,8 @@ export function recordProcessGoneCrash( recordDurableCrashBreadcrumb( 'minidump_signature_attach_failed', processGoneBreadcrumbData(event), - error instanceof Error ? error.message : String(error) + error instanceof Error ? error.message : String(error), + processGoneRendererOrigin(event) ) }) }) @@ -279,7 +300,8 @@ export function recordProcessGoneCrash( recordDurableCrashBreadcrumb( 'crash_report_persist_failed', data, - `${String(data.errorName)}: ${String(data.errorMessage)}` + `${String(data.errorName)}: ${String(data.errorMessage)}`, + processGoneRendererOrigin(event) ) }) } diff --git a/src/main/git/remote-rebase.ts b/src/main/git/remote-rebase.ts new file mode 100644 index 00000000000..d978f644695 --- /dev/null +++ b/src/main/git/remote-rebase.ts @@ -0,0 +1,107 @@ +import { randomUUID } from 'node:crypto' +import { normalizeGitErrorMessage } from '../../shared/git-remote-error' +import { isNoWriteFetchHeadUnsupportedError } from '../../shared/git-fetch-head-capability' +import { runWithGitWorktreeOperationLock } from '../../shared/git-worktree-operation-lock' +import { + REBASE_SOURCE_FETCH_TIMEOUT_MS, + resolveGitRemoteRebaseSource +} from '../../shared/git-rebase-source' +import type { GitRuntimeOptions } from './git-runtime-options' +import { gitOptionsForWorktree } from './git-runtime-options' +import { withLocalGitCapabilityCacheForExecution } from './git-capability-state' +import { gitExecFileAsync } from './runner' +import { runWithGitReadCacheInvalidation } from './status' + +export async function gitPullRebaseFromBase( + worktreePath: string, + baseRef: string, + options: GitRuntimeOptions = {} +): Promise { + await runWithGitWorktreeOperationLock(worktreePath, options.signal, () => + gitPullRebaseFromBaseUnlocked(worktreePath, baseRef, options) + ) +} + +async function gitPullRebaseFromBaseUnlocked( + worktreePath: string, + baseRef: string, + options: GitRuntimeOptions +): Promise { + await runWithGitReadCacheInvalidation(async () => { + const operationOptions = { + ...gitOptionsForWorktree(worktreePath, options), + terminationBarrier: true, + captureWslLoginShellOutput: true + } + let rebaseRef: string | null = null + try { + const source = await resolveGitRemoteRebaseSource( + (args) => gitExecFileAsync(args, operationOptions), + baseRef + ) + let forkPoint: string | null = null + let hasHead = true + try { + const { stdout } = await gitExecFileAsync( + ['merge-base', '--fork-point', `refs/remotes/${source.displayName}`, 'HEAD'], + operationOptions + ) + forkPoint = stdout.trim() || null + } catch { + // A first fetch or an unhelpful reflog falls back to Git's merge-base behavior. + try { + await gitExecFileAsync(['rev-parse', '--verify', 'HEAD'], operationOptions) + } catch { + hasHead = false + } + } + // Why: concurrent fetches can replace FETCH_HEAD and remote-tracking refs between fetch and rebase. + rebaseRef = `refs/orca/rebase/${randomUUID()}` + const fetchArgs = [ + source.remoteName, + `+refs/heads/${source.branchName}:${rebaseRef}`, + `+refs/heads/${source.branchName}:refs/remotes/${source.displayName}` + ] + await withLocalGitCapabilityCacheForExecution( + { cwd: worktreePath, ...options }, + (capabilities) => + capabilities.runWithFallback( + 'fetch-no-write-fetch-head', + () => + gitExecFileAsync(['fetch', '--no-write-fetch-head', ...fetchArgs], { + ...operationOptions, + timeout: REBASE_SOURCE_FETCH_TIMEOUT_MS + }), + () => + gitExecFileAsync(['fetch', ...fetchArgs], { + ...operationOptions, + timeout: REBASE_SOURCE_FETCH_TIMEOUT_MS + }), + isNoWriteFetchHeadUnsupportedError + ) + ) + await gitExecFileAsync( + hasHead + ? forkPoint + ? ['rebase', '--onto', rebaseRef, forkPoint] + : ['rebase', rebaseRef] + : ['merge', '--ff-only', rebaseRef], + operationOptions + ) + } catch (error) { + throw new Error(normalizeGitErrorMessage(error, 'pull')) + } finally { + if (rebaseRef) { + try { + const { signal: _abortedSignal, ...cleanupOptions } = gitOptionsForWorktree( + worktreePath, + options + ) + await gitExecFileAsync(['update-ref', '-d', rebaseRef], cleanupOptions) + } catch { + // Cleanup must not hide the fetch or rebase result. + } + } + } + }) +} diff --git a/src/main/git/remote.test.ts b/src/main/git/remote.test.ts index 61b95d0e50d..811fed50fec 100644 --- a/src/main/git/remote.test.ts +++ b/src/main/git/remote.test.ts @@ -8,11 +8,20 @@ vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) +import { REBASE_SOURCE_FETCH_TIMEOUT_MS } from '../../shared/git-rebase-source' +import { clearGitCapabilityStateForTests } from './git-capability-state' import { gitFastForward, gitFetch, gitPull, gitPullRebaseFromBase, gitPush } from './remote' +const REBASE_OPERATION_OPTIONS = { + cwd: '/repo', + terminationBarrier: true, + captureWslLoginShellOutput: true +} + describe('git remote operations', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() + clearGitCapabilityStateForTests() }) it('pushes to origin when no upstream is configured', async () => { @@ -511,32 +520,154 @@ describe('git remote operations', () => { ]) }) - it('rebases from the selected remote base ref', async () => { + it('fetches to a private ref then rebases from the selected remote base ref', async () => { gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'origin\nupstream\n', stderr: '' }) .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'fork-point\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) .mockResolvedValueOnce({ stdout: '', stderr: '' }) await gitPullRebaseFromBase('/repo', 'upstream/main') expect(gitExecFileAsyncMock.mock.calls).toEqual([ - [['remote'], { cwd: '/repo' }], - [['check-ref-format', '--branch', 'main'], { cwd: '/repo' }], - [['pull', '--rebase', 'upstream', 'main'], { cwd: '/repo' }] + [['remote'], REBASE_OPERATION_OPTIONS], + [['check-ref-format', '--branch', 'main'], REBASE_OPERATION_OPTIONS], + [ + ['merge-base', '--fork-point', 'refs/remotes/upstream/main', 'HEAD'], + REBASE_OPERATION_OPTIONS + ], + [ + [ + 'fetch', + '--no-write-fetch-head', + 'upstream', + expect.stringMatching(/^\+refs\/heads\/main:refs\/orca\/rebase\//), + '+refs/heads/main:refs/remotes/upstream/main' + ], + { ...REBASE_OPERATION_OPTIONS, timeout: REBASE_SOURCE_FETCH_TIMEOUT_MS } + ], + [ + ['rebase', '--onto', expect.stringMatching(/^refs\/orca\/rebase\//), 'fork-point'], + REBASE_OPERATION_OPTIONS + ], + [['update-ref', '-d', expect.stringMatching(/^refs\/orca\/rebase\//)], { cwd: '/repo' }] ]) + + const fetchRefspec = gitExecFileAsyncMock.mock.calls[3][0][3] + const rebasedRef = gitExecFileAsyncMock.mock.calls[4][0][2] + const deletedRef = gitExecFileAsyncMock.mock.calls[5][0][2] + expect(fetchRefspec).toBe(`+refs/heads/main:${rebasedRef}`) + expect(gitExecFileAsyncMock.mock.calls[3][0][4]).toBe( + '+refs/heads/main:refs/remotes/upstream/main' + ) + expect(deletedRef).toBe(rebasedRef) }) it('uses the longest configured remote name when rebasing from a base ref', async () => { gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'fork\nfork/team\n', stderr: '' }) .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'fork-point\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) .mockResolvedValueOnce({ stdout: '', stderr: '' }) await gitPullRebaseFromBase('/repo', 'fork/team/feature/base') - expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith( - ['pull', '--rebase', 'fork/team', 'feature/base'], - { cwd: '/repo' } + expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( + 5, + ['rebase', '--onto', expect.stringMatching(/^refs\/orca\/rebase\//), 'fork-point'], + REBASE_OPERATION_OPTIONS + ) + }) + + it('rebases when the selected remote has not been fetched before', async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'upstream\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockRejectedValueOnce(new Error('missing remote-tracking ref')) + .mockResolvedValueOnce({ stdout: 'head\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + + await expect(gitPullRebaseFromBase('/repo', 'upstream/main')).resolves.toBeUndefined() + + expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( + 5, + [ + 'fetch', + '--no-write-fetch-head', + 'upstream', + expect.stringMatching(/^\+refs\/heads\/main:refs\/orca\/rebase\//), + '+refs/heads/main:refs/remotes/upstream/main' + ], + { ...REBASE_OPERATION_OPTIONS, timeout: REBASE_SOURCE_FETCH_TIMEOUT_MS } + ) + }) + + it('fast-forwards an unborn branch from the fetched private ref', async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'upstream\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockRejectedValueOnce(new Error('unborn HEAD')) + .mockRejectedValueOnce(new Error('unborn HEAD')) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + + await gitPullRebaseFromBase('/repo', 'upstream/main') + + const fetchedRef = gitExecFileAsyncMock.mock.calls[4][0][3] + expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( + 6, + ['merge', '--ff-only', fetchedRef.slice(fetchedRef.indexOf(':') + 1)], + REBASE_OPERATION_OPTIONS + ) + }) + + it('removes the private ref when rebase fails', async () => { + const controller = new AbortController() + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'upstream\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'fork-point\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockImplementationOnce(async () => { + controller.abort() + throw new Error('fatal: rebase conflict') + }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + + await expect( + gitPullRebaseFromBase('/repo', 'upstream/main', { signal: controller.signal }) + ).rejects.toThrow('fatal: rebase conflict') + + const rebasedRef = gitExecFileAsyncMock.mock.calls[4][0][2] + expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(6, ['update-ref', '-d', rebasedRef], { + cwd: '/repo' + }) + }) + + it('serializes the private fetch when Git cannot avoid writing FETCH_HEAD', async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'upstream\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'fork-point\n', stderr: '' }) + .mockRejectedValueOnce(new Error("error: unknown option `no-write-fetch-head'")) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + + await gitPullRebaseFromBase('/repo', 'upstream/main') + + const preferredRefspecs = gitExecFileAsyncMock.mock.calls[3][0].slice(3) + expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( + 5, + ['fetch', 'upstream', ...preferredRefspecs], + { ...REBASE_OPERATION_OPTIONS, timeout: REBASE_SOURCE_FETCH_TIMEOUT_MS } ) }) diff --git a/src/main/git/remote.ts b/src/main/git/remote.ts index 687102b1184..c3e389d8478 100644 --- a/src/main/git/remote.ts +++ b/src/main/git/remote.ts @@ -4,13 +4,15 @@ import { } from '../../shared/git-remote-error' import { resolveEffectiveGitUpstream } from '../../shared/git-effective-upstream' import { gitRefTargetsBranchOnRemote } from '../../shared/git-remote-branch-name' -import { resolveGitRemoteRebaseSource } from '../../shared/git-rebase-source' import type { GitPushTarget } from '../../shared/worktree/types' import type { GitRuntimeOptions } from './git-runtime-options' import { gitOptionsForWorktree } from './git-runtime-options' import { validateGitPushTarget } from './push-target-validation' import { gitExecFileAsync } from './runner' import { runWithGitReadCacheInvalidation } from './status' +import { runWithGitWorktreeOperationLock } from '../../shared/git-worktree-operation-lock' + +export { gitPullRebaseFromBase } from './remote-rebase' async function getConfiguredPushTarget( worktreePath: string, @@ -257,8 +259,8 @@ export async function gitPull( // Why: plain `git pull` uses the user's configured pull strategy (merge by // default) so diverged branches reconcile instead of erroring out. Conflicts // surface through the existing conflict-resolution flow. - await runWithGitReadCacheInvalidation(() => - gitPullWithArgs(worktreePath, [], pushTarget, options) + await runWithGitWorktreeOperationLock(worktreePath, options.signal, () => + runWithGitReadCacheInvalidation(() => gitPullWithArgs(worktreePath, [], pushTarget, options)) ) } @@ -267,32 +269,13 @@ export async function gitFastForward( pushTarget?: GitPushTarget, options: GitRuntimeOptions = {} ): Promise { - await runWithGitReadCacheInvalidation(() => - gitPullWithArgs(worktreePath, ['--ff-only'], pushTarget, options) + await runWithGitWorktreeOperationLock(worktreePath, options.signal, () => + runWithGitReadCacheInvalidation(() => + gitPullWithArgs(worktreePath, ['--ff-only'], pushTarget, options) + ) ) } -export async function gitPullRebaseFromBase( - worktreePath: string, - baseRef: string, - options: GitRuntimeOptions = {} -): Promise { - await runWithGitReadCacheInvalidation(async () => { - try { - const source = await resolveGitRemoteRebaseSource( - (args) => gitExecFileAsync(args, gitOptionsForWorktree(worktreePath, options)), - baseRef - ) - await gitExecFileAsync( - ['pull', '--rebase', source.remoteName, source.branchName], - gitOptionsForWorktree(worktreePath, options) - ) - } catch (error) { - throw new Error(normalizeGitErrorMessage(error, 'pull')) - } - }) -} - export async function gitFetch( worktreePath: string, pushTarget?: GitPushTarget, diff --git a/src/main/git/runner-command-exec.test.ts b/src/main/git/runner-command-exec.test.ts index e29c9f650cc..f145b311521 100644 --- a/src/main/git/runner-command-exec.test.ts +++ b/src/main/git/runner-command-exec.test.ts @@ -218,6 +218,50 @@ describe('runner execFile timeout handling', () => { expect(child.kill).toHaveBeenCalled() }) + it.skipIf(process.platform === 'win32')( + 'keeps a barrier Git abort pending until the process group closes', + async () => { + const child = createMockChildProcess(1234) + spawnMock.mockImplementation((command: string) => { + if (command !== 'ps') { + return child + } + const probe = createMockChildProcess(4321) + queueMicrotask(() => probe.emit('close', 0, null)) + return probe + }) + const processKill = vi.spyOn(process, 'kill').mockImplementation(() => true) + const controller = new AbortController() + try { + const pending = gitExecFileAsync(['status'], { + cwd: '/repo', + signal: controller.signal, + terminationBarrier: true + }) + let settled = false + void pending.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + + controller.abort() + child.emit('close', 0, null) + await vi.advanceTimersByTimeAsync(1_999) + expect(settled).toBe(false) + await vi.advanceTimersByTimeAsync(1) + expect(processKill).toHaveBeenCalledWith(-1234, 'SIGKILL') + + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }) + } finally { + processKill.mockRestore() + } + } + ) + it('rejects gh executions that never call back using the default timeout', async () => { const child = createMockChildProcess(1234) execFileMock.mockReturnValue(child) diff --git a/src/main/git/runner-wsl-direct-read.test.ts b/src/main/git/runner-wsl-direct-read.test.ts index 3aca1dd738e..aa5bd5dbc0d 100644 --- a/src/main/git/runner-wsl-direct-read.test.ts +++ b/src/main/git/runner-wsl-direct-read.test.ts @@ -253,6 +253,76 @@ describe('WSL direct Git reads', () => { }) }) + it('fences parsed output from a barrier Git login-shell fallback', async () => { + await withPlatform('win32', async () => { + seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT) + spawnMock.mockImplementation((_command, args) => { + const child = createMockChild() + queueMicrotask(() => { + const capturedCommand = args?.find((arg) => + String(arg).includes('__ORCA_WSL_CAPTURE_BEGIN_') + ) + const fenced = fencedProbeStdout(capturedCommand, 'fork-point\n') + const echoedMarker = fenced.match(/__ORCA_WSL_CAPTURE_BEGIN_[^_]+__/)?.[0] ?? '' + child.stdout.emit('data', Buffer.from(`${echoedMarker}shell trace\n${fenced}`)) + child.emit('close', 0, null) + }) + return child + }) + + await expect( + gitExecFileAsync(['merge-base', '--fork-point', 'upstream/main', 'HEAD'], { + cwd: String.raw`C:\repo`, + env: { GIT_CONFIG_GLOBAL: '/home/user/custom.gitconfig' }, + wslDistro: DISTRO, + captureWslLoginShellOutput: true, + terminationBarrier: true + }) + ).resolves.toEqual({ stdout: 'fork-point\n', stderr: '' }) + + expect(spawnMock.mock.calls[0]?.[1]?.join(' ')).toContain('setsid --wait') + expect(spawnMock.mock.calls[0]?.[1]?.join(' ')).toContain('__ORCA_WSL_CAPTURE_BEGIN_') + }) + }) + + it('verifies the WSL guest process group before settling an abort', async () => { + await withPlatform('win32', async () => { + seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT) + const command = createMockChild() + spawnMock.mockImplementation((_program, args) => { + if (spawnMock.mock.calls.length === 1) { + queueMicrotask(() => { + const marker = String(args?.join(' ')).match( + /(__ORCA_WSL_PROCESS_GROUP_[0-9a-f-]+__=)/ + )?.[1] + command.stderr.emit('data', Buffer.from(`${marker}4321\n`)) + }) + return command + } + const terminator = createMockChild() + queueMicrotask(() => terminator.emit('close', 0, null)) + return terminator + }) + const controller = new AbortController() + const pending = gitExecFileAsync(['status'], { + cwd: String.raw`C:\repo`, + env: { GIT_CONFIG_GLOBAL: '/home/user/custom.gitconfig' }, + wslDistro: DISTRO, + signal: controller.signal, + terminationBarrier: true + }) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1)) + await Promise.resolve() + + controller.abort() + + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }) + expect(command.kill).not.toHaveBeenCalled() + expect(spawnMock.mock.calls[1]?.[1]?.join(' ')).toContain('kill -TERM') + expect(spawnMock.mock.calls[1]?.[1]).toContain('4321') + }) + }) + it('ignores unchanged ambient host Git variables when selecting the direct path', async () => { await withPlatform('win32', async () => { const originalAskpass = process.env.GIT_ASKPASS diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index 1ae53551f29..55d2330dd43 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -44,6 +44,11 @@ import { } from '../../shared/wsl-login-shell-command' import { UNTRANSLATED_GIT_OUTPUT_ENV } from '../../shared/git-output-locale' import { endSubprocessStdin } from '../../shared/subprocess-stdin-write' +import { runProcess } from '../../shared/child-process/run-process' +import { + resolveGitFetchHeadCommand, + runWithGitFetchHeadLock +} from '../../shared/git-fetch-head-lock' import { disableWslGitReadEnvironment, getWslGitReadEnvironment, @@ -56,6 +61,10 @@ import { prepareWslLinkedWorktreeGitRouting, usesHostGitForWslLinkedWorktree } from './wsl-linked-worktree-git-routing' +import { + createWslProcessGroupTermination, + type WslProcessGroupTermination +} from './wsl-process-group-termination' // 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 } @@ -79,6 +88,7 @@ type ResolvedCommand = { wslMode: 'direct-git' | 'login-shell' | 'non-login-shell' | null /** Present only when the caller opted into a fenced login-shell read. */ captured?: WslCapturedLoginShellCommand + termination?: WslProcessGroupTermination } /** @@ -317,6 +327,7 @@ function resolveCommand( captureLoginShellOutput?: boolean wslGitReadEnvironment?: WslGitReadEnvironment env?: NodeJS.ProcessEnv + terminationBarrier?: boolean } = {} ): ResolvedCommand { if (process.platform !== 'win32') { @@ -346,25 +357,28 @@ function resolveCommand( if (command === 'git' && options.wslGitReadEnvironment) { const optionalLocks = options.env?.GIT_OPTIONAL_LOCKS - return { - binary: 'wsl.exe', - args: [ - '-d', - wsl.distro, - '--exec', - '/usr/bin/env', - `PATH=${options.wslGitReadEnvironment.path}`, - `HOME=${options.wslGitReadEnvironment.home}`, - ...GIT_OUTPUT_LOCALE_ENV_ARGS, - ...(optionalLocks !== undefined ? [`GIT_OPTIONAL_LOCKS=${optionalLocks}`] : []), - options.wslGitReadEnvironment.gitPath, - ...(linuxCwd ? ['-C', linuxCwd] : []), - ...translatedArgs - ], - cwd: undefined, - wsl, - wslMode: 'direct-git' - } + return withWslProcessGroupTermination( + { + binary: 'wsl.exe', + args: [ + '-d', + wsl.distro, + '--exec', + '/usr/bin/env', + `PATH=${options.wslGitReadEnvironment.path}`, + `HOME=${options.wslGitReadEnvironment.home}`, + ...GIT_OUTPUT_LOCALE_ENV_ARGS, + ...(optionalLocks !== undefined ? [`GIT_OPTIONAL_LOCKS=${optionalLocks}`] : []), + options.wslGitReadEnvironment.gitPath, + ...(linuxCwd ? ['-C', linuxCwd] : []), + ...translatedArgs + ], + cwd: undefined, + wsl, + wslMode: 'direct-git' + }, + options.terminationBarrier + ) } if (options.useWslLoginShell) { @@ -374,31 +388,62 @@ function resolveCommand( // marker would be glued onto their first record. if (options.captureLoginShellOutput) { const captured = buildWslCapturedLoginShellCommand(shellCmd) - return { + return withWslProcessGroupTermination( + { + binary: 'wsl.exe', + args: buildWslExecArgs(wsl.distro, ['sh', '-lc', captured.command]), + cwd: undefined, + wsl, + wslMode: 'login-shell', + captured + }, + options.terminationBarrier + ) + } + return withWslProcessGroupTermination( + { binary: 'wsl.exe', - args: buildWslExecArgs(wsl.distro, ['sh', '-lc', captured.command]), + args: buildWslExecArgs(wsl.distro, ['sh', '-lc', buildWslLoginShellCommand(shellCmd)]), cwd: undefined, wsl, - wslMode: 'login-shell', - captured - } - } - return { - binary: 'wsl.exe', - args: buildWslExecArgs(wsl.distro, ['sh', '-lc', buildWslLoginShellCommand(shellCmd)]), - cwd: undefined, - wsl, - wslMode: 'login-shell' - } + wslMode: 'login-shell' + }, + options.terminationBarrier + ) } + return withWslProcessGroupTermination( + { + binary: 'wsl.exe', + args: buildWslExecArgs(wsl.distro, ['bash', '-c', shellCmd]), + // Why: the `cd` inside bash -c handles the directory; a UNC cwd on the Node process is redundant and can break Node internals. + cwd: undefined, + wsl, + wslMode: 'non-login-shell' + }, + options.terminationBarrier + ) +} + +function withWslProcessGroupTermination( + command: ResolvedCommand, + enabled: boolean | undefined +): ResolvedCommand { + if (!enabled || !command.wsl) { + return command + } + const execIndex = command.args.indexOf('--exec') + if (execIndex === -1) { + return command + } + const termination = createWslProcessGroupTermination(command.wsl.distro) return { - binary: 'wsl.exe', - args: buildWslExecArgs(wsl.distro, ['bash', '-c', shellCmd]), - // Why: the `cd` inside bash -c handles the directory; a UNC cwd on the Node process is redundant and can break Node internals. - cwd: undefined, - wsl, - wslMode: 'non-login-shell' + ...command, + args: [ + ...command.args.slice(0, execIndex + 1), + ...termination.wrapGuestArgs(command.args.slice(execIndex + 1)) + ], + termination } } @@ -418,6 +463,8 @@ type GitExecOptions = { wslDistro?: string preferWslDirectGit?: boolean useConfiguredSshCommandForNetwork?: boolean + terminationBarrier?: boolean + captureWslLoginShellOutput?: boolean } type CommandExecOptions = { @@ -453,7 +500,8 @@ function resolveGitCommand( if (environment) { return resolveCommand('git', args, options.cwd, options.wslDistro, { wslGitReadEnvironment: environment, - env: options.env + env: options.env, + terminationBarrier: options.terminationBarrier }) } if (distro) { @@ -486,7 +534,8 @@ function resolveGitCommandWithoutProbe( ): ResolvedCommand { return resolveCommand('git', args, options.cwd, options.wslDistro, { useWslLoginShell: Boolean(options.wslDistro), - captureLoginShellOutput + captureLoginShellOutput, + terminationBarrier: options.terminationBarrier }) } @@ -605,6 +654,51 @@ function killSpawnedCommandTree(child: ChildProcess): Promise { type ExecFileCaptureOptions = Omit & { timeout?: number stdin?: string + terminationBarrier?: boolean +} + +const GIT_TERMINATION_BARRIER_FALLBACK_TIMEOUT_MS = 2_147_000_000 + +async function execFileCaptureToTermination( + command: string, + args: string[], + options: ExecFileCaptureOptions, + termination?: WslProcessGroupTermination +): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> { + const result = await runProcess({ + program: command, + args, + cwd: typeof options.cwd === 'string' ? options.cwd : undefined, + env: options.env, + timeoutMs: options.timeout ?? GIT_TERMINATION_BARRIER_FALLBACK_TIMEOUT_MS, + maxOutputBytes: options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER, + signal: options.signal, + terminationBarrier: termination ?? true, + ...(options.stdin === undefined ? {} : { input: options.stdin }) + }) + const stdout = options.encoding === 'buffer' ? Buffer.from(result.stdout) : result.stdout + const cleanStderr = termination?.stripControlOutput(result.stderr) ?? result.stderr + const stderr = options.encoding === 'buffer' ? Buffer.from(cleanStderr) : cleanStderr + if (result.code === 0 && !result.timedOut && !options.signal?.aborted) { + return { stdout, stderr } + } + const error = new Error( + result.timedOut + ? `${command} timed out.` + : options.signal?.aborted + ? 'The operation was aborted.' + : cleanStderr.trim() || `${command} exited with ${result.code}.` + ) + if (options.signal?.aborted) { + error.name = 'AbortError' + } + throw Object.assign(error, { + code: result.code, + killed: result.timedOut || result.signal !== null || options.signal?.aborted === true, + signal: result.signal, + stdout, + stderr + }) } function emptyExecFileOutput(options: ExecFileCaptureOptions): string | Buffer { @@ -1077,7 +1171,7 @@ async function buildNetworkSshPolicyEnv(options: GitExecOptions): Promise<{ * Async git command execution. Drop-in replacement for * `execFileAsync('git', args, { cwd, encoding, ... })`. */ -export async function gitExecFileAsync( +async function gitExecFileAsyncUnlocked( args: string[], options: GitExecOptions ): Promise<{ stdout: string; stderr: string }> { @@ -1090,7 +1184,7 @@ export async function gitExecFileAsync( signal: options.signal }) } - let resolved = resolveGitCommand(args, options) + let resolved = resolveGitCommand(args, options, false, options.captureWslLoginShellOutput) const environmentReady = prepareWindowsHostGitEnvironment( resolved, options.env, @@ -1098,33 +1192,57 @@ export async function gitExecFileAsync( ) const env = environmentReady ? await environmentReady : options.env const effectiveOptions = env === options.env ? options : { ...options, env } - resolved = resolveGitCommand(args, effectiveOptions) + resolved = resolveGitCommand( + args, + effectiveOptions, + false, + effectiveOptions.captureWslLoginShellOutput + ) const policy = effectiveOptions.useConfiguredSshCommandForNetwork ? await buildNetworkSshPolicyEnv(effectiveOptions) : { env: nonInteractiveGitEnv(effectiveOptions.env), mode: 'default' as const } const capture = ( command: ResolvedCommand - ): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> => - execFileCapture(command.binary, command.args, { + ): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> => { + const captureOptions = { cwd: command.cwd, encoding: (options.encoding ?? 'utf-8') as BufferEncoding, maxBuffer: options.maxBuffer, timeout: options.timeout, stdin: options.stdin, env: policy.env, - signal: options.signal - }) + signal: options.signal, + terminationBarrier: options.terminationBarrier + } + return options.terminationBarrier + ? execFileCaptureToTermination( + command.binary, + command.args, + captureOptions, + command.termination + ) + : execFileCapture(command.binary, command.args, captureOptions) + } let result: { stdout: string | Buffer; stderr: string | Buffer } try { result = await capture(resolved) } catch (error) { if (directWslGitExitCode(error, resolved) !== null && !options.signal?.aborted) { const wasMissing = invalidateMissingDirectWslGit(error, resolved) - result = await capture(resolveGitCommand(args, effectiveOptions, true)) + const fallback = resolveGitCommand( + args, + effectiveOptions, + true, + effectiveOptions.captureWslLoginShellOutput + ) + result = await capture(fallback) // Why: matching failures can be normal Git control flow; only a successful login retry proves the direct environment was insufficient. disableDirectWslGitAfterSuccessfulFallback(wasMissing, resolved) const { stdout, stderr } = result - return { stdout: stdout as string, stderr: stderr as string } + return { + stdout: readCapturedGitString(stdout as string, fallback), + stderr: stderr as string + } } if (options.useConfiguredSshCommandForNetwork && error && typeof error === 'object') { Object.assign(error, { gitSshPolicyMode: policy.mode }) @@ -1132,11 +1250,22 @@ export async function gitExecFileAsync( throw error } const { stdout, stderr } = result - return { stdout: stdout as string, stderr: stderr as string } + return { stdout: readCapturedGitString(stdout as string, resolved), stderr: stderr as string } } ) } +export function gitExecFileAsync( + args: string[], + options: GitExecOptions +): Promise<{ stdout: string; stderr: string }> { + const run = () => gitExecFileAsyncUnlocked(args, options) + const command = resolveGitFetchHeadCommand(args, options.cwd) + return command.needsLock + ? runWithGitFetchHeadLock(command.cwd, options.signal, run, command.gitDir) + : run() +} + /** * Async command execution with the same WSL cwd translation as repo-scoped git. * Keep this for fixed binary+argv call sites; never pass shell fragments. @@ -1222,7 +1351,7 @@ function readCapturedGitBuffer(stdout: Buffer, resolved: ResolvedCommand): Buffe if (!captured) { return stdout } - const beginIndex = stdout.indexOf(captured.beginMarker, 0, 'utf8') + const beginIndex = stdout.lastIndexOf(captured.beginMarker, undefined, 'utf8') if (beginIndex === -1) { return stdout } @@ -1231,6 +1360,20 @@ function readCapturedGitBuffer(stdout: Buffer, resolved: ResolvedCommand): Buffe return endIndex === -1 ? stdout.subarray(payloadStart) : stdout.subarray(payloadStart, endIndex) } +function readCapturedGitString(stdout: string, resolved: ResolvedCommand): string { + const captured = resolved.captured + if (!captured) { + return stdout + } + const beginIndex = stdout.lastIndexOf(captured.beginMarker) + if (beginIndex === -1) { + return stdout + } + const payloadStart = beginIndex + captured.beginMarker.length + const endIndex = stdout.indexOf(captured.endMarker, payloadStart) + return endIndex === -1 ? stdout.slice(payloadStart) : stdout.slice(payloadStart, endIndex) +} + /** Result of a streamed git command; `stoppedEarly` is true when onStdout asked to stop before the child exited. */ export type GitStreamResult = { stoppedEarly: boolean } diff --git a/src/main/git/status-submodule-path-cache.test.ts b/src/main/git/status-submodule-path-cache.test.ts index 60adccf142c..af74553bdb0 100644 --- a/src/main/git/status-submodule-path-cache.test.ts +++ b/src/main/git/status-submodule-path-cache.test.ts @@ -148,7 +148,7 @@ describe('submodule path cache', () => { if (args[0] === 'remote') { return Promise.resolve({ stdout: 'origin\n' }) } - if (args[0] === 'pull') { + if (args[0] === 'pull' || args[0] === 'fetch') { modulePath = 'fresh-lib' return Promise.resolve({ stdout: '' }) } diff --git a/src/main/git/wsl-process-group-termination.test.ts b/src/main/git/wsl-process-group-termination.test.ts new file mode 100644 index 00000000000..790083786f3 --- /dev/null +++ b/src/main/git/wsl-process-group-termination.test.ts @@ -0,0 +1,95 @@ +import type { ChildProcess } from 'node:child_process' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { runWslProcessMock } = vi.hoisted(() => ({ runWslProcessMock: vi.fn() })) + +vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: runWslProcessMock })) + +import { createWslProcessGroupTermination } from './wsl-process-group-termination' + +describe('WSL process-group termination', () => { + beforeEach(() => { + runWslProcessMock.mockReset() + runWslProcessMock.mockResolvedValue({ code: 0, timedOut: false }) + }) + + it('wraps the guest command in a reported process group', () => { + const termination = createWslProcessGroupTermination('Ubuntu') + const args = termination.wrapGuestArgs(['git', 'fetch']) + + expect(args.slice(0, 2)).toEqual(['sh', '-c']) + expect(args.slice(-2)).toEqual(['git', 'fetch']) + expect(args.join(' ')).toContain('__ORCA_WSL_PROCESS_GROUP_') + expect(args[2]).toContain('setsid --wait sh -c') + }) + + it('runs the guest command unwrapped where setsid --wait is unsupported', () => { + const termination = createWslProcessGroupTermination('Ubuntu') + const script = termination.wrapGuestArgs(['git', 'fetch'])[2] ?? '' + + expect(script).toContain('if setsid --wait true 2>/dev/null; then') + expect(script.trimEnd().endsWith('exec "$@"')).toBe(true) + }) + + it('forces and verifies the reported guest process group', async () => { + const termination = createWslProcessGroupTermination('Ubuntu') + const wrapped = termination.wrapGuestArgs(['git', 'fetch']).join(' ') + const marker = wrapped.match(/(__ORCA_WSL_PROCESS_GROUP_[0-9a-f-]+__=)/)?.[1] + termination.observeStderr?.(Buffer.from(`${marker}43`)) + // The marker line is still truncated; committing 43 would target a stranger. + await expect(termination.signal({} as ChildProcess)).resolves.toBe(false) + termination.observeStderr?.(Buffer.from('21\n')) + + await expect(termination.force({} as ChildProcess)).resolves.toBe(true) + + const spec = runWslProcessMock.mock.calls[0]?.[0] + expect(spec.script).toContain('kill -KILL') + expect(spec.args).toEqual(['4321']) + }) + + it('kills the group through the WSL runner, never a raw wsl.exe spawn', async () => { + const termination = createWslProcessGroupTermination('Ubuntu') + const wrapped = termination.wrapGuestArgs(['git', 'fetch']).join(' ') + const marker = wrapped.match(/(__ORCA_WSL_PROCESS_GROUP_[0-9a-f-]+__=)/)?.[1] + termination.observeStderr?.(Buffer.from(`${marker}4321\n`)) + + await termination.signal({} as ChildProcess) + + const spec = runWslProcessMock.mock.calls[0]?.[0] + expect(spec.distro).toBe('Ubuntu') + // The runner picks the shell; a payload of plain POSIX must not pin bash. + expect(spec.program).toBeUndefined() + expect(spec.shell).toBeUndefined() + // The kill reads no login environment, so it must not pay the probe. + expect(spec.loginPath).toBe('none') + expect(spec.timeoutMs).toBeGreaterThan(0) + expect(spec.script).toContain('kill -TERM') + }) + + it('does not claim termination before the guest reports its identity', async () => { + const termination = createWslProcessGroupTermination('Ubuntu') + + await expect(termination.signal({} as ChildProcess)).resolves.toBe(false) + expect(runWslProcessMock).not.toHaveBeenCalled() + }) + + it('reports failure when the guest kill times out', async () => { + runWslProcessMock.mockResolvedValue({ code: null, timedOut: true }) + const termination = createWslProcessGroupTermination('Ubuntu') + const wrapped = termination.wrapGuestArgs(['git', 'fetch']).join(' ') + const marker = wrapped.match(/(__ORCA_WSL_PROCESS_GROUP_[0-9a-f-]+__=)/)?.[1] + termination.observeStderr?.(Buffer.from(`${marker}4321\n`)) + + await expect(termination.force({} as ChildProcess)).resolves.toBe(false) + }) + + it('retains the guest identity from a large coalesced stderr chunk', async () => { + const termination = createWslProcessGroupTermination('Ubuntu') + const wrapped = termination.wrapGuestArgs(['git', 'fetch']).join(' ') + const marker = wrapped.match(/(__ORCA_WSL_PROCESS_GROUP_[0-9a-f-]+__=)/)?.[1] + termination.observeStderr?.(Buffer.from(`${marker}4321\n${'x'.repeat(1_024)}`)) + + await expect(termination.signal({} as ChildProcess)).resolves.toBe(true) + expect(runWslProcessMock.mock.calls[0]?.[0]?.args).toEqual(['4321']) + }) +}) diff --git a/src/main/git/wsl-process-group-termination.ts b/src/main/git/wsl-process-group-termination.ts new file mode 100644 index 00000000000..dbb7eecef19 --- /dev/null +++ b/src/main/git/wsl-process-group-termination.ts @@ -0,0 +1,80 @@ +import { randomUUID } from 'node:crypto' +import type { ProcessTerminationBarrier } from '../../shared/child-process/run-process' +import { quotePosixShell } from '../../shared/wsl-login-shell-command' +import { runWslProcess } from '../wsl/wsl-runner' + +const GUEST_TERMINATION_ATTEMPTS = 40 +const GUEST_TERMINATION_INTERVAL_SECONDS = '0.025' +const GUEST_TERMINATION_COMMAND_TIMEOUT_MS = 5_000 + +export type WslProcessGroupTermination = ProcessTerminationBarrier & { + wrapGuestArgs: (args: readonly string[]) => string[] + stripControlOutput: (stderr: string) => string +} + +export function createWslProcessGroupTermination(distro: string): WslProcessGroupTermination { + const marker = `__ORCA_WSL_PROCESS_GROUP_${randomUUID()}__=` + let processGroupId: number | null = null + let stderrTail = '' + + const observeStderr = (chunk: Buffer | string): void => { + const combined = `${stderrTail}${chunk.toString()}` + const match = combined.match(new RegExp(`${marker}(\\d+)\\r?\\n`)) + stderrTail = combined.slice(-512) + const parsed = match ? Number(match[1]) : 0 + if (Number.isSafeInteger(parsed) && parsed > 1) { + processGroupId = parsed + } + } + + const terminate = async (signal: 'TERM' | 'KILL'): Promise => { + if (processGroupId === null) { + return false + } + const script = [ + '_orca_group=$1', + `kill -${signal} "-$_orca_group" 2>/dev/null || :`, + '_orca_attempt=0', + 'while kill -0 "-$_orca_group" 2>/dev/null; do', + ` [ "$_orca_attempt" -ge ${GUEST_TERMINATION_ATTEMPTS} ] && exit 1`, + ' _orca_attempt=$((_orca_attempt + 1))', + ` sleep ${GUEST_TERMINATION_INTERVAL_SECONDS}`, + 'done' + ].join('\n') + // loginPath 'none': the payload is builtins and coreutils on the default + // PATH, so a login probe would only add latency to a kill. + const result = await runWslProcess({ + script, + args: [String(processGroupId)], + distro, + loginPath: 'none', + timeoutMs: GUEST_TERMINATION_COMMAND_TIMEOUT_MS, + maxOutputBytes: 1_024 + }) + return result.code === 0 && !result.timedOut + } + + return { + observeStderr, + signal: () => terminate('TERM'), + force: () => terminate('KILL'), + wrapGuestArgs: (args) => { + const reportGroup = [ + `printf '%s%s\\n' ${quotePosixShell(marker)} "$$" >&2`, + 'exec "$@"' + ].join('\n') + // Why probe rather than assume: BusyBox `setsid` has no `--wait`, so there + // the wrapper would fail the Git command outright. Without a new session the + // reported group is not ours to kill, so the fallback reports no identity + // and the caller falls back to waiting for the root exit. + const script = [ + 'if setsid --wait true 2>/dev/null; then', + ` exec setsid --wait sh -c ${quotePosixShell(reportGroup)} orca-wsl-process-group "$@"`, + 'fi', + 'exec "$@"' + ].join('\n') + return ['sh', '-c', script, 'orca-wsl-process-group', ...args] + }, + stripControlOutput: (stderr) => stderr.replace(new RegExp(`${marker}\\d+\\r?\\n?`, 'g'), '') + } +} diff --git a/src/main/gitlab/client.test.ts b/src/main/gitlab/client.test.ts index 038083def0e..1d932d5c5fa 100644 --- a/src/main/gitlab/client.test.ts +++ b/src/main/gitlab/client.test.ts @@ -50,6 +50,9 @@ describe('gitlab client — viewer & paste-URL lookup', () => { username: 'alice', email: 'alice@example.com' }) + expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['api', 'user']) + expect(acquireMock).toHaveBeenCalledOnce() + expect(releaseMock).toHaveBeenCalledOnce() }) it('coerces a missing email to null', async () => { @@ -65,6 +68,7 @@ describe('gitlab client — viewer & paste-URL lookup', () => { it('returns null when glab fails', async () => { glabExecFileAsyncMock.mockRejectedValueOnce(new Error('not authenticated')) await expect(getAuthenticatedViewer()).resolves.toBeNull() + expect(releaseMock).toHaveBeenCalledOnce() }) it('returns null when username is empty', async () => { diff --git a/src/main/gitlab/client.ts b/src/main/gitlab/client.ts index 50c541c0213..9f50ebbaf5d 100644 --- a/src/main/gitlab/client.ts +++ b/src/main/gitlab/client.ts @@ -1,1378 +1,33 @@ -/* eslint-disable max-lines -- co-locates GitLab MR/issue/work-item operations sharing one acquire/release pattern. */ -import type { ClassifiedError } from '../../shared/classified-error' -import type { - GetGitLabRateLimitResult, - GitLabAssignableUser, - GitLabAuthDiagnostic, - GitLabDiscussionResolveResult, - GitLabJobTraceResult, - GitLabMRInlineCommentInput, - GitLabMRReviewersUpdateResult, - GitLabPagedResult, - GitLabPipelineJob, - GitLabRateLimitSnapshot, - GitLabRetryJobResult, - GitLabTodo, - GitLabViewer, - GitLabWorkItem, - ListMergeRequestsResult, - MRComment, - MRInfo, - MRListState -} from '../../shared/gitlab-types' -import type { IssueSourcePreference } from '../../shared/repo-types' -import { derivePipelineStatus, mapIssueToWorkItem, mapMRInfo, mapMRToWorkItem } from './mappers' -import { - acquire, - classifyGlabError, - classifyJobLogError, - classifyListFetchError, - isMissingJobLogError, - getGlabKnownHosts, - getProjectRef, - getProjectRefForRemote, - glabHostnameArgs, - glabRepoExecOptions, - glabApiWithHeaders, - glabExecFileAsync, - parseGlabAuthStatusHosts, - parseGlabJsonList, - release, - resolveIssueSource, - type LocalGitExecOptions, - type ProjectRef -} from './gl-utils' -import { rememberGlabKnownHosts } from './gitlab-known-host-probe' -import type { IssueListState } from './issues' -import { - hasHostedReviewLocalGitOptions, - getHostedReviewLocalGitOptions, - type HostedReviewExecutionOptions -} from '../source-control/hosted-review-git-options' -import { shouldHideNonOpenReviewOnDefaultBranch } from '../source-control/repo-default-branch' - -// Why: glab REST addresses projects by URL-encoded path; escapes slashes for nested groups. -function encodedProject(projectPath: string): string { - return encodeURIComponent(projectPath) -} - -const GITLAB_RATE_LIMIT_CACHE_TTL_MS = 30_000 -const GITLAB_RATE_LIMIT_CACHE_MAX_ENTRIES = 64 -const gitLabRateLimitCache = new Map() - -type HostedReviewLocalGitOptions = ReturnType - -function hostedReviewLocalGitOptionArgs( - options: HostedReviewExecutionOptions = {} -): [] | [HostedReviewLocalGitOptions] { - return hasHostedReviewLocalGitOptions(options) ? [getHostedReviewLocalGitOptions(options)] : [] -} - -/** - * Get the authenticated GitLab viewer. - * Returns null when glab is unavailable, unauthenticated, or the lookup fails. - */ -export async function getAuthenticatedViewer(): Promise { - await acquire() - try { - const { stdout } = await glabExecFileAsync(['api', 'user']) - const viewer = JSON.parse(stdout) as { username?: string; email?: string | null } - if (!viewer.username?.trim()) { - return null - } - return { - username: viewer.username.trim(), - email: viewer.email?.trim() || null - } - } catch { - return null - } finally { - release() - } -} - -export async function diagnoseAuth(): Promise { - const envTokenInProcess = process.env.GITLAB_TOKEN - ? 'GITLAB_TOKEN' - : process.env.GLAB_TOKEN - ? 'GLAB_TOKEN' - : null - try { - // Why: a host-global diagnostic must not wake an unrelated default WSL distro. - const { stdout, stderr } = await glabExecFileAsync(['auth', 'status'], { - allowDefaultWslFallback: false - }) - const output = `${stdout}\n${stderr}` - const hosts = parseGlabAuthStatusHosts(output) - // Why: refreshing auth must advance the provider cache key past a stale null result. - rememberGlabKnownHosts(hosts) - return { - glabAvailable: true, - authenticated: - /logged in|authenticated|token/i.test(output) && !/not logged in/i.test(output), - hosts, - activeHost: hosts[0] ?? null, - envTokenInProcess, - error: null - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return { - glabAvailable: !/ENOENT|not found|spawn/i.test(message), - authenticated: false, - hosts: [], - activeHost: null, - envTokenInProcess, - error: message - } - } -} - -function parseRateLimitHeader( - headers: Record, - keys: readonly string[] -): number | null { - for (const key of keys) { - const parsed = Number.parseInt(headers[key], 10) - if (Number.isFinite(parsed)) { - return parsed - } - } - return null -} - -function parseRateLimitResetAt(headers: Record): number | null { - const numeric = parseRateLimitHeader(headers, ['ratelimit-reset', 'x-ratelimit-reset']) - if (numeric !== null) { - return numeric - } - const resetTime = headers['ratelimit-resettime'] ?? headers['x-ratelimit-resettime'] - if (!resetTime) { - return null - } - const millis = Date.parse(resetTime) - return Number.isFinite(millis) ? Math.floor(millis / 1000) : null -} - -function parseGitLabRateLimitSnapshot( - headers: Record, - host: string | null -): GitLabRateLimitSnapshot { - const limit = parseRateLimitHeader(headers, ['ratelimit-limit', 'x-ratelimit-limit']) - const remaining = parseRateLimitHeader(headers, ['ratelimit-remaining', 'x-ratelimit-remaining']) - const resetAt = parseRateLimitResetAt(headers) - return { - host, - fetchedAt: Date.now(), - rest: - limit === null && remaining === null && resetAt === null - ? null - : { - limit: limit ?? 0, - remaining: remaining ?? 0, - resetAt - } - } -} - -/** @internal — test-only */ -export function _resetGitLabRateLimitCache(): void { - gitLabRateLimitCache.clear() -} - -/** @internal — test-only */ -export function _getGitLabRateLimitCacheSize(): number { - return gitLabRateLimitCache.size -} - -function pruneGitLabRateLimitCache(now = Date.now()): void { - for (const [cacheKey, snapshot] of gitLabRateLimitCache) { - if (now - snapshot.fetchedAt >= GITLAB_RATE_LIMIT_CACHE_TTL_MS) { - gitLabRateLimitCache.delete(cacheKey) - } - } - while (gitLabRateLimitCache.size > GITLAB_RATE_LIMIT_CACHE_MAX_ENTRIES) { - const oldestKey = gitLabRateLimitCache.keys().next().value - if (oldestKey === undefined) { - break - } - gitLabRateLimitCache.delete(oldestKey) - } -} - -function rememberGitLabRateLimitSnapshot( - cacheKey: string, - snapshot: GitLabRateLimitSnapshot -): void { - pruneGitLabRateLimitCache() - // Why: self-managed hostnames come from repo config; keep this cache bounded across many transient hosts. - gitLabRateLimitCache.delete(cacheKey) - gitLabRateLimitCache.set(cacheKey, snapshot) - pruneGitLabRateLimitCache() -} - -export async function getRateLimit(options?: { - force?: boolean - host?: string | null -}): Promise { - const host = options?.host?.trim() || null - const cacheKey = host ?? 'default' - pruneGitLabRateLimitCache() - const cached = gitLabRateLimitCache.get(cacheKey) - if (!options?.force && cached && Date.now() - cached.fetchedAt < GITLAB_RATE_LIMIT_CACHE_TTL_MS) { - return { ok: true, snapshot: cached } - } - - await acquire() - try { - // Why: GitLab exposes REST budget headers inconsistently; a null bucket means this host omitted them. - const args = host ? ['--hostname', host, 'user'] : ['user'] - const { headers } = await glabApiWithHeaders(args) - const snapshot = parseGitLabRateLimitSnapshot(headers, host) - rememberGitLabRateLimitSnapshot(cacheKey, snapshot) - return { ok: true, snapshot } - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - return { ok: false, error: message } - } finally { - release() - } -} - -/** Resolve a project's full GitLab project ref (host + path); null for non-GitLab remotes. */ -export async function getProjectSlug( - repoPath: string, - connectionId?: string | null, - options: HostedReviewExecutionOptions = {} -): Promise { - const localGitArgs = hostedReviewLocalGitOptionArgs(options) - const knownHosts = await getGlabKnownHosts(connectionId, localGitArgs[0]) - return getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs) -} - -/** - * Fetch a single merge request with pipeline status rolled up. - * Returns null when the MR doesn't exist or glab fails. - */ -export async function getMergeRequest( - repoPath: string, - iid: number, - connectionId?: string | null, - options: HostedReviewExecutionOptions = {} -): Promise { - const localGitArgs = hostedReviewLocalGitOptionArgs(options) - const localGitOptions = localGitArgs[0] ?? {} - const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) - const projectRef = await getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs) - await acquire() - try { - const args = projectRef - ? [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}` - ] - : ['mr', 'view', String(iid), '--output', 'json'] - const { stdout } = await glabExecFileAsync( - args, - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - const data = JSON.parse(stdout) as Parameters[0] & { - head_pipeline?: { status?: string } | null - pipeline?: { status?: string } | null - } - // Why: older GitLab instances expose `pipeline` instead of `head_pipeline`; try both. - const pipelineStatus = derivePipelineStatus(data.head_pipeline ?? data.pipeline ?? null) - return mapMRInfo(data, pipelineStatus) - } catch { - return null - } finally { - release() - } -} - -/** - * Find the merge request whose source branch matches the given name. - * Returns the most recently updated MR for the branch, or null when none exists. - */ -export async function getMergeRequestForBranch( - repoPath: string, - branch: string, - linkedMRIid?: number | null, - connectionId?: string | null, - options: HostedReviewExecutionOptions = {}, - // Why: when true, a failed lookup throws instead of returning null, so callers never report a false not_found. - throwOnFailure = false -): Promise { - const branchName = branch.replace(/^refs\/heads\//, '') - if (!branchName && linkedMRIid == null) { - return null - } - const localGitArgs = hostedReviewLocalGitOptionArgs(options) - const localGitOptions = localGitArgs[0] ?? {} - const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) - const projectRef = await getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs) - if (!projectRef) { - return null - } - await acquire() - try { - if (branchName) { - const { stdout } = await glabExecFileAsync( - [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - // Why: GitLab does not proactively recompute merge status on list endpoints, so this row - // can sit at `unchecked` forever — and the sidebar merge button gates on MERGEABLE. Ask - // for the async recalculation (best-effort; ignored for non-Developers when - // `restrict_merge_status_recheck` is on) so polling converges instead of stalling. - `projects/${encodedProject(projectRef.path)}/merge_requests?source_branch=${encodeURIComponent(branchName)}&order_by=updated_at&sort=desc&per_page=1&with_merge_status_recheck=true` - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - const data = JSON.parse(stdout) as (Parameters[0] & { - head_pipeline?: { status?: string } | null - pipeline?: { status?: string } | null - })[] - if (Array.isArray(data) && data.length > 0) { - const raw = data[0] - // Why: older GitLab list payloads expose `pipeline` instead of `head_pipeline`. - const pipelineStatus = derivePipelineStatus(raw.head_pipeline ?? raw.pipeline ?? null) - const info = mapMRInfo(raw, pipelineStatus) - // Why (#9171): discard a non-open implicit branch match on the repo - // default branch and fall through to the linked-iid fallback below. - const hideOnDefaultBranch = await shouldHideNonOpenReviewOnDefaultBranch({ - state: info.state, - reviewNumber: info.number, - linkedReviewNumber: linkedMRIid, - branchName, - repoPath, - connectionId, - localGitOptions - }) - if (!hideOnDefaultBranch) { - return info - } - } - } - if (typeof linkedMRIid !== 'number') { - return null - } - // Why: create-from-MR worktrees may rename the branch; fall back to the durable linked iid. - const { stdout } = await glabExecFileAsync( - [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - `projects/${encodedProject(projectRef.path)}/merge_requests/${linkedMRIid}` - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - const raw = JSON.parse(stdout) as Parameters[0] & { - head_pipeline?: { status?: string } | null - pipeline?: { status?: string } | null - } - const pipelineStatus = derivePipelineStatus(raw.head_pipeline ?? raw.pipeline ?? null) - return mapMRInfo(raw, pipelineStatus) - } catch (error) { - if (throwOnFailure) { - throw error - } - return null - } finally { - release() - } -} - -/** - * Like getMergeRequestForBranch but throws glab failures instead of returning null, so callers report 'unavailable' not a false "not found". - */ -export function getMergeRequestForBranchOrThrow( - repoPath: string, - branch: string, - linkedMRIid?: number | null, - connectionId?: string | null, - options: HostedReviewExecutionOptions = {} -): Promise { - return getMergeRequestForBranch(repoPath, branch, linkedMRIid, connectionId, options, true) -} - -function mrListStateFlags(state: MRListState): string[] { - switch (state) { - case 'opened': - return [] - case 'merged': - return ['--merged'] - case 'closed': - return ['--closed'] - case 'all': - return ['--all'] - } -} - -/** List merge requests for a project via glab CLI, which handles self-hosted auth and project selection. */ -export async function listMergeRequests( - repoPath: string, - state: MRListState = 'opened', - page = 1, - perPage = 20, - preference?: IssueSourcePreference, - query?: string, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) - // Why: MRs live on the user's fork (origin); route through the preference resolver so fork workflows share plumbing. - const { source: projectRef } = await resolveIssueSource( - repoPath, - preference, - knownHosts, - connectionId, - localGitOptions - ) - if (!projectRef) { - if (connectionId) { - // Why: SSH-backed repos have no local cwd; a cwd-less glab could resolve an unrelated project. - return { - items: [], - page, - perPage, - totalCount: 0, - totalPages: 0, - error: { - type: 'not_found', - message: 'No GitLab project found for this repository.' - } - } - } - // Why: fallback — let glab infer the project from cwd when the host isn't in getGlabKnownHosts. - const stateFlag = mrListStateFlags(state) - // Why: apply the same search as the API path, else queries are silently ignored on cwd-inferred repos (#6263). - const searchFlag = query?.trim() ? ['--search', query.trim()] : [] - await acquire() - try { - const { stdout } = await glabExecFileAsync( - [ - 'mr', - 'list', - '--output', - 'json', - '--per-page', - String(perPage), - '--page', - String(page), - '--order', - 'updated_at', - '--sort', - 'desc', - ...stateFlag, - ...searchFlag - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - const data = parseGlabJsonList[0]>(stdout) - return { - items: data.map((d) => mapMRToWorkItem(d, 'unknown')), - page, - perPage, - // Why: the CLI omits x-total headers, so totals are approximate. - totalCount: data.length, - totalPages: data.length < perPage ? page : page + 1 - } - } catch (err) { - return { - items: [], - page, - perPage, - totalCount: 0, - totalPages: 0, - error: classifyListFetchError(err) - } - } finally { - release() - } - } - // Why: GitLab's API uses an absent state param to mean "any"; drop it for 'all'. - const stateParam = state === 'all' ? '' : `&state=${state}` - const searchParam = query?.trim() ? `&search=${encodeURIComponent(query.trim())}` : '' - const path = - `projects/${encodedProject(projectRef.path)}/merge_requests?` + - `page=${page}&per_page=${perPage}&order_by=updated_at&sort=desc&with_merge_status_recheck=false${stateParam}${searchParam}` - const repoId = projectRef.path - - await acquire() - try { - const { body, headers } = await glabApiWithHeaders( - [...glabHostnameArgs(projectRef, connectionId), path], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - const data = parseGlabJsonList[0]>(body) - return { - items: data.map((d) => mapMRToWorkItem(d, repoId, projectRef)), - page, - perPage, - totalCount: parseHeaderInt(headers['x-total'], 0), - // Why: GitLab may omit x-total-pages for 'all' or large per_page; fall back to ceil(total/perPage). - totalPages: - parseHeaderInt(headers['x-total-pages'], 0) || - Math.max(1, Math.ceil(parseHeaderInt(headers['x-total'], 0) / perPage)) - } - } catch (err) { - return { - items: [], - page, - perPage, - totalCount: 0, - totalPages: 0, - error: classifyListFetchError(err) - } - } finally { - release() - } -} - -function parseHeaderInt(value: string | undefined, fallback: number): number { - if (!value) { - return fallback - } - const parsed = Number.parseInt(value, 10) - return Number.isFinite(parsed) ? parsed : fallback -} - -/** - * Fetch a work item (MR or issue) by explicit project ref + iid + type. - * Used by the paste-URL flow, where the URL determines the project directly. - */ -export async function getWorkItemByProjectRef( - repoPath: string, - projectRef: ProjectRef, - iid: number, - type: 'issue' | 'mr', - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - await acquire() - try { - const resource = type === 'mr' ? 'merge_requests' : 'issues' - const { stdout } = await glabExecFileAsync( - [ - 'api', - // Why: preserve the pasted URL's explicit host so cwd remotes can't redirect the lookup. - ...(projectRef.host ? ['--hostname', projectRef.host] : []), - `projects/${encodedProject(projectRef.path)}/${resource}/${iid}` - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - const data = JSON.parse(stdout) - if (type === 'mr') { - return mapMRToWorkItem(data, projectRef.path, projectRef) - } - return mapIssueToWorkItem(data, projectRef.path, projectRef) - } catch { - return null - } finally { - release() - } -} - -function mrStateToIssueState(state: MRListState): IssueListState | null { - // Why: issues have no 'merged' state; return null so the issues fetch is skipped, not mis-mapped. - switch (state) { - case 'opened': - return 'opened' - case 'closed': - return 'closed' - case 'all': - return 'all' - case 'merged': - return null - } -} - -export async function listWorkItems( - repoPath: string, - state: MRListState = 'opened', - page = 1, - perPage = 20, - preference?: IssueSourcePreference, - query?: string, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise> { - const issueState = mrStateToIssueState(state) - const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) - const { source: projectRef } = await resolveIssueSource( - repoPath, - preference, - knownHosts, - connectionId, - localGitOptions - ) - if (!projectRef) { - return { - items: [], - page, - perPage, - totalCount: 0, - totalPages: 0, - error: { - type: 'not_found', - message: 'No GitLab project found for this repository.' - } - } - } - // Why: fan out both reads so latency is the slower of the two, not the sum. - // Why read the raw issues API (not listIssues): IssueInfo strips updated_at, which the combined sort needs. - const [mrs, issues] = await Promise.all([ - listMergeRequests( - repoPath, - state, - page, - perPage, - preference, - query, - connectionId, - localGitOptions - ), - issueState === null - ? Promise.resolve({ - items: [] as GitLabWorkItem[], - error: undefined as ClassifiedError | undefined - }) - : fetchIssuesAsWorkItems( - repoPath, - projectRef, - issueState, - page, - perPage, - query, - connectionId, - localGitOptions - ) - ]) - const merged = [...mrs.items, ...issues.items].sort((a, b) => - (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '') - ) - // Why: MR-side error wins — issues can be disabled per project, making its error less informative. - const error: ClassifiedError | undefined = mrs.error ?? issues.error - return { - items: merged, - page, - perPage, - // Why: approximate totals — GitLab can't paginate across MRs+issues as one set, so MR totals stand in. - totalCount: mrs.totalCount, - totalPages: mrs.totalPages, - ...(error ? { error } : {}) - } -} - -export async function fetchIssuesAsWorkItems( - repoPath: string, - projectRef: ProjectRef, - state: IssueListState, - page: number, - perPage: number, - query?: string, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ items: GitLabWorkItem[]; error: ClassifiedError | undefined }> { - await acquire() - try { - const stateParam = state === 'all' ? '' : `&state=${state}` - const searchParam = query?.trim() ? `&search=${encodeURIComponent(query.trim())}` : '' - const { stdout } = await glabExecFileAsync( - [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - `projects/${encodedProject(projectRef.path)}/issues?page=${page}&per_page=${perPage}&order_by=updated_at&sort=desc${stateParam}${searchParam}` - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - const data = parseGlabJsonList[0]>(stdout) - return { - items: data.map((d) => mapIssueToWorkItem(d, projectRef.path, projectRef)), - error: undefined - } - } catch (err) { - return { - items: [], - error: classifyListFetchError(err) - } - } finally { - release() - } -} - -/** - * List the authenticated user's GitLab todos (gitlab.com/dashboard/todos). - * User-scoped, so cwd is irrelevant; repoPath only satisfies the IPC handler's path-validation guard. - */ -export async function listTodos( - repoPath: string, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - const projectRef = await getProjectRef( - repoPath, - await getGlabKnownHosts(connectionId, localGitOptions), - connectionId, - localGitOptions - ) - if (connectionId && !projectRef) { - return [] - } - await acquire() - try { - // Why: per_page=50 keeps this cross-project view cheap; the UI only shows top-priority todos. - const { stdout } = await glabExecFileAsync( - [ - 'api', - ...(projectRef ? glabHostnameArgs(projectRef, connectionId) : []), - 'todos?state=pending&per_page=50' - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - type RESTTodo = { - id?: number - action_name?: string - target_type?: string - target?: { - iid?: number - title?: string - web_url?: string - } | null - target_url?: string - author?: { username?: string | null; avatar_url?: string | null } | null - project?: { path_with_namespace?: string } | null - updated_at?: string - state?: string - } - const data = JSON.parse(stdout) as RESTTodo[] - return data.map((t) => ({ - id: t.id ?? 0, - actionName: t.action_name ?? '', - targetType: t.target_type ?? '', - targetIid: typeof t.target?.iid === 'number' ? t.target.iid : null, - targetTitle: t.target?.title ?? '', - targetUrl: t.target_url ?? t.target?.web_url ?? '', - projectPath: t.project?.path_with_namespace ?? '', - authorUsername: t.author?.username ?? '', - authorAvatarUrl: t.author?.avatar_url ?? '', - updatedAt: t.updated_at ?? '', - state: t.state === 'done' ? 'done' : 'pending' - })) - } catch { - // Why: silent empty-list on auth/network failure matches the read-side surface; caller UI signals connectivity. - return [] - } finally { - release() - } -} - -// ── MR mutations ────────────────────────────────────────────────── - -async function withProjectRef( - repoPath: string, - preference: IssueSourcePreference | undefined, - connectionId: string | null | undefined, - explicitProjectRef: ProjectRef | null | undefined, - fn: (projectRef: ProjectRef, repoFlag: string) => Promise, - fallback: T, - localGitOptions: LocalGitExecOptions = {} -): Promise { - const projectRef = - explicitProjectRef ?? - ( - await resolveIssueSource( - repoPath, - preference, - await getGlabKnownHosts(connectionId, localGitOptions), - connectionId, - localGitOptions - ) - ).source - if (!projectRef) { - return fallback - } - return fn(projectRef, projectRef.path) -} - -export async function closeMR( - repoPath: string, - iid: number, - preference?: IssueSourcePreference, - connectionId?: string | null, - projectRef?: ProjectRef | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ ok: true } | { ok: false; error: string }> { - return withProjectRef<{ ok: true } | { ok: false; error: string }>( - repoPath, - preference, - connectionId, - projectRef, - async (projectRef, repoFlag) => { - await acquire() - try { - await glabExecFileAsync( - [ - 'mr', - 'close', - String(iid), - '-R', - repoFlag, - ...glabHostnameArgs(projectRef, connectionId) - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - return { ok: true } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - // Why: glab exits non-zero when the MR is already closed — treat as success. - if (msg.toLowerCase().includes('already')) { - return { ok: true } - } - return { ok: false, error: msg } - } finally { - release() - } - }, - { ok: false, error: 'Could not resolve GitLab project for this repository' }, - localGitOptions - ) -} - -export async function reopenMR( - repoPath: string, - iid: number, - preference?: IssueSourcePreference, - connectionId?: string | null, - projectRef?: ProjectRef | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ ok: true } | { ok: false; error: string }> { - return withProjectRef<{ ok: true } | { ok: false; error: string }>( - repoPath, - preference, - connectionId, - projectRef, - async (projectRef, repoFlag) => { - await acquire() - try { - await glabExecFileAsync( - [ - 'mr', - 'reopen', - String(iid), - '-R', - repoFlag, - ...glabHostnameArgs(projectRef, connectionId) - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - return { ok: true } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - if (msg.toLowerCase().includes('already')) { - return { ok: true } - } - return { ok: false, error: msg } - } finally { - release() - } - }, - { ok: false, error: 'Could not resolve GitLab project for this repository' }, - localGitOptions - ) -} - -export async function mergeMR( - repoPath: string, - iid: number, - method: 'merge' | 'squash' | 'rebase' = 'merge', - preference?: IssueSourcePreference, - connectionId?: string | null, - projectRef?: ProjectRef | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ ok: true } | { ok: false; error: string }> { - return withProjectRef<{ ok: true } | { ok: false; error: string }>( - repoPath, - preference, - connectionId, - projectRef, - async (projectRef, repoFlag) => { - await acquire() - try { - // Why: omitting both --squash and --rebase yields a regular merge commit. - const methodFlag = - method === 'squash' ? ['--squash'] : method === 'rebase' ? ['--rebase'] : [] - await glabExecFileAsync( - [ - 'mr', - 'merge', - String(iid), - '-R', - repoFlag, - '--yes', - ...methodFlag, - ...glabHostnameArgs(projectRef, connectionId) - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - return { ok: true } - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) } - } finally { - release() - } - }, - { ok: false, error: 'Could not resolve GitLab project for this repository' }, - localGitOptions - ) -} - -export async function addMRComment( - repoPath: string, - iid: number, - body: string, - preference?: IssueSourcePreference, - connectionId?: string | null, - projectRef?: ProjectRef | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ ok: true; comment: MRComment } | { ok: false; error: string }> { - return withProjectRef<{ ok: true; comment: MRComment } | { ok: false; error: string }>( - repoPath, - preference, - connectionId, - projectRef, - async (projectRef) => { - await acquire() - try { - const { stdout } = await glabExecFileAsync( - [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - '-X', - 'POST', - `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/notes`, - '-f', - `body=${body}` - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - const data = JSON.parse(stdout) as { - id?: number - author?: { username?: string; avatar_url?: string; state?: string } | null - body?: string - created_at?: string - } - return { - ok: true, - comment: { - id: data.id ?? Date.now(), - author: data.author?.username ?? 'You', - authorAvatarUrl: data.author?.avatar_url ?? '', - body: data.body ?? body, - createdAt: data.created_at ?? new Date().toISOString(), - url: '', - isBot: data.author?.state === 'bot' - } - } - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) } - } finally { - release() - } - }, - { ok: false, error: 'Could not resolve GitLab project for this repository' }, - localGitOptions - ) -} - -export async function addMRInlineComment( - repoPath: string, - iid: number, - input: GitLabMRInlineCommentInput, - preference?: IssueSourcePreference, - connectionId?: string | null, - projectRef?: ProjectRef | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ ok: true; comment: MRComment } | { ok: false; error: string }> { - return withProjectRef<{ ok: true; comment: MRComment } | { ok: false; error: string }>( - repoPath, - preference, - connectionId, - projectRef, - async (projectRef) => { - const body = input.body.trim() - if (!body) { - return { ok: false, error: 'Comment body is required' } - } - await acquire() - try { - const oldPath = input.oldPath ?? input.path - const { stdout } = await glabExecFileAsync( - [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - '-X', - 'POST', - `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/discussions`, - '-f', - `body=${body}`, - '-f', - 'position[position_type]=text', - '-f', - `position[base_sha]=${input.baseSha}`, - '-f', - `position[start_sha]=${input.startSha}`, - '-f', - `position[head_sha]=${input.headSha}`, - '-f', - `position[old_path]=${oldPath}`, - '-f', - `position[new_path]=${input.path}`, - '-f', - `position[new_line]=${input.line}` - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - const data = JSON.parse(stdout) as { - id?: string - notes?: { - id?: number - author?: { username?: string; avatar_url?: string; state?: string } | null - body?: string - created_at?: string - position?: { new_path?: string; new_line?: number } | null - }[] - } - const note = data.notes?.[0] - return { - ok: true, - comment: { - id: note?.id ?? Date.now(), - author: note?.author?.username ?? 'You', - authorAvatarUrl: note?.author?.avatar_url ?? '', - body: note?.body ?? body, - createdAt: note?.created_at ?? new Date().toISOString(), - url: '', - threadId: data.id, - isResolved: false, - isBot: note?.author?.state === 'bot', - path: note?.position?.new_path ?? input.path, - line: note?.position?.new_line ?? input.line - } - } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - return { ok: false, error: classifyGlabError(msg).message } - } finally { - release() - } - }, - { ok: false, error: 'Could not resolve GitLab project for this repository' }, - localGitOptions - ) -} - -export async function resolveMRDiscussion( - repoPath: string, - iid: number, - discussionId: string, - resolved: boolean, - preference?: IssueSourcePreference, - connectionId?: string | null, - projectRef?: ProjectRef | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - return withProjectRef( - repoPath, - preference, - connectionId, - projectRef, - async (projectRef) => { - const trimmedDiscussionId = discussionId.trim() - if (!trimmedDiscussionId) { - return { ok: false, error: 'Discussion id is required' } - } - await acquire() - try { - // Why: GitLab resolves/reopens the whole discussion thread, not a single note. - await glabExecFileAsync( - [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - '-X', - 'PUT', - `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/discussions/${encodeURIComponent(trimmedDiscussionId)}`, - '-f', - `resolved=${resolved ? 'true' : 'false'}` - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - return { ok: true } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - return { ok: false, error: classifyGlabError(msg).message } - } finally { - release() - } - }, - { ok: false, error: 'Could not resolve GitLab project for this repository' }, - localGitOptions - ) -} - -function mapRetriedPipelineJob( - data: { - id?: number - pipeline?: { id?: number | null } | null - name?: string - stage?: string - status?: string - web_url?: string - duration?: number | null - }, - fallbackJobId: number -): GitLabPipelineJob { - return { - id: data.id ?? fallbackJobId, - ...(typeof data.pipeline?.id === 'number' ? { pipelineId: data.pipeline.id } : {}), - name: data.name ?? '', - stage: data.stage ?? '', - status: data.status ?? '', - webUrl: data.web_url ?? '', - duration: typeof data.duration === 'number' ? data.duration : null - } -} - -function mapGitLabReviewer(raw: { - id?: number - username?: string | null - name?: string | null - avatar_url?: string | null - state?: string | null -}): GitLabAssignableUser | null { - if (!raw.username) { - return null - } - return { - ...(typeof raw.id === 'number' ? { id: raw.id } : {}), - username: raw.username, - name: raw.name ?? null, - avatarUrl: raw.avatar_url ?? '', - ...(raw.state !== undefined ? { state: raw.state } : {}) - } -} - -export async function updateMRReviewers( - repoPath: string, - iid: number, - reviewerIds: number[], - preference?: IssueSourcePreference, - connectionId?: string | null, - projectRef?: ProjectRef | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - return withProjectRef( - repoPath, - preference, - connectionId, - projectRef, - async (projectRef) => { - await acquire() - try { - const fields = - reviewerIds.length > 0 - ? reviewerIds.flatMap((id) => ['-f', `reviewer_ids[]=${id}`]) - : ['-f', 'reviewer_ids='] - const { stdout } = await glabExecFileAsync( - [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - '-X', - 'PUT', - `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`, - ...fields - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - const data = JSON.parse(stdout) as { reviewers?: Parameters[0][] } - return { - ok: true, - reviewers: (data.reviewers ?? []) - .map(mapGitLabReviewer) - .filter((u): u is GitLabAssignableUser => !!u) - } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - return { ok: false, error: classifyGlabError(msg).message } - } finally { - release() - } - }, - { ok: false, error: 'Could not resolve GitLab project for this repository' }, - localGitOptions - ) -} - -export async function getJobTrace( - repoPath: string, - jobId: number, - preference?: IssueSourcePreference, - connectionId?: string | null, - projectRef?: ProjectRef | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - return withProjectRef( - repoPath, - preference, - connectionId, - projectRef, - async (projectRef) => { - await acquire() - try { - const { stdout } = await glabExecFileAsync( - [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - `projects/${encodedProject(projectRef.path)}/jobs/${jobId}/trace` - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - return { ok: true, trace: stdout } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - // A job with no log is an empty log, not a failure: surfacing the 404 would - // pin an error on the Checks row for a job canceled before it started. - if (isMissingJobLogError(msg)) { - return { ok: true, trace: '' } - } - return { ok: false, error: classifyJobLogError(msg).message } - } finally { - release() - } - }, - { ok: false, error: 'Could not resolve GitLab project for this repository' }, - localGitOptions - ) -} - -export async function retryJob( - repoPath: string, - jobId: number, - preference?: IssueSourcePreference, - connectionId?: string | null, - projectRef?: ProjectRef | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - return withProjectRef( - repoPath, - preference, - connectionId, - projectRef, - async (projectRef) => { - await acquire() - try { - const { stdout } = await glabExecFileAsync( - [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - '-X', - 'POST', - `projects/${encodedProject(projectRef.path)}/jobs/${jobId}/retry` - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - const trimmed = stdout.trim() - return { - ok: true, - ...(trimmed ? { job: mapRetriedPipelineJob(JSON.parse(trimmed), jobId) } : {}) - } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - return { ok: false, error: classifyGlabError(msg).message } - } finally { - release() - } - }, - { ok: false, error: 'Could not resolve GitLab project for this repository' }, - localGitOptions - ) -} - -export async function updateMR( - repoPath: string, - iid: number, - updates: { - title?: string - body?: string - addLabels?: string[] - removeLabels?: string[] - }, - preference?: IssueSourcePreference, - connectionId?: string | null, - projectRef?: ProjectRef | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ ok: true } | { ok: false; error: string }> { - return withProjectRef<{ ok: true } | { ok: false; error: string }>( - repoPath, - preference, - connectionId, - projectRef, - async (projectRef) => { - const fields: string[] = [] - const title = updates.title?.trim() - if (updates.title !== undefined) { - if (!title) { - return { ok: false, error: 'Title is required' } - } - fields.push(`title=${title}`) - } - if (updates.body !== undefined) { - fields.push(`description=${updates.body}`) - } - const addLabels = (updates.addLabels ?? []).filter((label) => label.trim().length > 0) - const removeLabels = (updates.removeLabels ?? []).filter((label) => label.trim().length > 0) - if (addLabels.length > 0) { - fields.push(`add_labels=${addLabels.join(',')}`) - } - if (removeLabels.length > 0) { - fields.push(`remove_labels=${removeLabels.join(',')}`) - } - if (fields.length === 0) { - return { ok: true } - } - - await acquire() - try { - await glabExecFileAsync( - [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - '-X', - 'PUT', - `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`, - ...fields.flatMap((field) => ['-f', field]) - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - return { ok: true } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - return { ok: false, error: classifyGlabError(msg).message } - } finally { - release() - } - }, - { ok: false, error: 'Could not resolve GitLab project for this repository' }, - localGitOptions - ) -} - -/** Re-export so callers don't need to know the gl-utils module split. */ -export { _resetProjectRefCache } from './gl-utils' +export { + _getGitLabRateLimitCacheSize, + _resetGitLabRateLimitCache, + diagnoseAuth, + getAuthenticatedViewer, + getRateLimit +} from './gitlab-auth-and-rate-limit' +export { + getMergeRequest, + getMergeRequestForBranch, + getMergeRequestForBranchOrThrow, + getProjectSlug +} from './merge-request-lookup' +export { listMergeRequests } from './merge-request-list' +export { + fetchIssuesAsWorkItems, + getWorkItemByProjectRef, + listTodos, + listWorkItems +} from './work-item-queries' +export { closeMR, mergeMR, reopenMR } from './merge-request-state-mutations' +export { + addMRComment, + addMRInlineComment, + resolveMRDiscussion, + updateMRReviewers +} from './merge-request-review-mutations' +export { getJobTrace, retryJob } from './pipeline-job-mutations' +export { updateMR } from './merge-request-update' +export { _resetProjectRefCache, getProjectRefForRemote } from './gl-utils' export { addIssueComment, createIssue, getIssue, listIssues } from './issues' export { updateIssue } from './issue-update' export { listAssignableUsers, listLabels } from './project-label-and-member-lookup' - -// Re-exported so paste-URL call sites don't import getProjectRefForRemote from gl-utils directly. -export { getProjectRefForRemote } diff --git a/src/main/gitlab/gitlab-auth-and-rate-limit.ts b/src/main/gitlab/gitlab-auth-and-rate-limit.ts new file mode 100644 index 00000000000..58947d4b307 --- /dev/null +++ b/src/main/gitlab/gitlab-auth-and-rate-limit.ts @@ -0,0 +1,187 @@ +import type { + GetGitLabRateLimitResult, + GitLabAuthDiagnostic, + GitLabRateLimitSnapshot, + GitLabViewer +} from '../../shared/gitlab-types' +import { + acquire, + glabApiWithHeaders, + glabExecFileAsync, + parseGlabAuthStatusHosts, + release +} from './gl-utils' +import { rememberGlabKnownHosts } from './gitlab-known-host-probe' + +const GITLAB_RATE_LIMIT_CACHE_TTL_MS = 30_000 +const GITLAB_RATE_LIMIT_CACHE_MAX_ENTRIES = 64 +const gitLabRateLimitCache = new Map() + +export async function getAuthenticatedViewer(): Promise { + await acquire() + try { + const { stdout } = await glabExecFileAsync(['api', 'user']) + const viewer = JSON.parse(stdout) as { username?: string; email?: string | null } + if (!viewer.username?.trim()) { + return null + } + return { + username: viewer.username.trim(), + email: viewer.email?.trim() || null + } + } catch { + return null + } finally { + release() + } +} + +export async function diagnoseAuth(): Promise { + const envTokenInProcess = process.env.GITLAB_TOKEN + ? 'GITLAB_TOKEN' + : process.env.GLAB_TOKEN + ? 'GLAB_TOKEN' + : null + try { + // Why: a host-global diagnostic must not wake an unrelated default WSL distro. + const { stdout, stderr } = await glabExecFileAsync(['auth', 'status'], { + allowDefaultWslFallback: false + }) + const output = `${stdout}\n${stderr}` + const hosts = parseGlabAuthStatusHosts(output) + // Why: refreshing auth must advance the provider cache key past a stale null result. + rememberGlabKnownHosts(hosts) + return { + glabAvailable: true, + authenticated: + /logged in|authenticated|token/i.test(output) && !/not logged in/i.test(output), + hosts, + activeHost: hosts[0] ?? null, + envTokenInProcess, + error: null + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { + glabAvailable: !/ENOENT|not found|spawn/i.test(message), + authenticated: false, + hosts: [], + activeHost: null, + envTokenInProcess, + error: message + } + } +} + +function parseRateLimitHeader( + headers: Record, + keys: readonly string[] +): number | null { + for (const key of keys) { + const parsed = Number.parseInt(headers[key], 10) + if (Number.isFinite(parsed)) { + return parsed + } + } + return null +} + +function parseRateLimitResetAt(headers: Record): number | null { + const numeric = parseRateLimitHeader(headers, ['ratelimit-reset', 'x-ratelimit-reset']) + if (numeric !== null) { + return numeric + } + const resetTime = headers['ratelimit-resettime'] ?? headers['x-ratelimit-resettime'] + if (!resetTime) { + return null + } + const millis = Date.parse(resetTime) + return Number.isFinite(millis) ? Math.floor(millis / 1000) : null +} + +function parseGitLabRateLimitSnapshot( + headers: Record, + host: string | null +): GitLabRateLimitSnapshot { + const limit = parseRateLimitHeader(headers, ['ratelimit-limit', 'x-ratelimit-limit']) + const remaining = parseRateLimitHeader(headers, ['ratelimit-remaining', 'x-ratelimit-remaining']) + const resetAt = parseRateLimitResetAt(headers) + return { + host, + fetchedAt: Date.now(), + rest: + limit === null && remaining === null && resetAt === null + ? null + : { + limit: limit ?? 0, + remaining: remaining ?? 0, + resetAt + } + } +} + +/** @internal — test-only */ +export function _resetGitLabRateLimitCache(): void { + gitLabRateLimitCache.clear() +} + +/** @internal — test-only */ +export function _getGitLabRateLimitCacheSize(): number { + return gitLabRateLimitCache.size +} + +function pruneGitLabRateLimitCache(now = Date.now()): void { + for (const [cacheKey, snapshot] of gitLabRateLimitCache) { + if (now - snapshot.fetchedAt >= GITLAB_RATE_LIMIT_CACHE_TTL_MS) { + gitLabRateLimitCache.delete(cacheKey) + } + } + while (gitLabRateLimitCache.size > GITLAB_RATE_LIMIT_CACHE_MAX_ENTRIES) { + const oldestKey = gitLabRateLimitCache.keys().next().value + if (oldestKey === undefined) { + break + } + gitLabRateLimitCache.delete(oldestKey) + } +} + +function rememberGitLabRateLimitSnapshot( + cacheKey: string, + snapshot: GitLabRateLimitSnapshot +): void { + pruneGitLabRateLimitCache() + // Why: self-managed hostnames come from repo config; keep this cache bounded across many transient hosts. + gitLabRateLimitCache.delete(cacheKey) + gitLabRateLimitCache.set(cacheKey, snapshot) + pruneGitLabRateLimitCache() +} + +export async function getRateLimit(options?: { + force?: boolean + host?: string | null +}): Promise { + const host = options?.host?.trim() || null + const cacheKey = host ?? 'default' + pruneGitLabRateLimitCache() + const cached = gitLabRateLimitCache.get(cacheKey) + if (!options?.force && cached && Date.now() - cached.fetchedAt < GITLAB_RATE_LIMIT_CACHE_TTL_MS) { + return { ok: true, snapshot: cached } + } + + await acquire() + try { + // Why: GitLab exposes REST budget headers inconsistently; a null bucket means this host omitted them. + const args = host ? ['--hostname', host, 'user'] : ['user'] + const { headers } = await glabApiWithHeaders(args) + const snapshot = parseGitLabRateLimitSnapshot(headers, host) + rememberGitLabRateLimitSnapshot(cacheKey, snapshot) + return { ok: true, snapshot } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { ok: false, error: message } + } finally { + release() + } +} + +/** Resolve a project's full GitLab project ref (host + path); null for non-GitLab remotes. */ diff --git a/src/main/gitlab/merge-request-list.ts b/src/main/gitlab/merge-request-list.ts new file mode 100644 index 00000000000..b8a2e7148bf --- /dev/null +++ b/src/main/gitlab/merge-request-list.ts @@ -0,0 +1,164 @@ +import type { ListMergeRequestsResult, MRListState } from '../../shared/gitlab-types' +import type { IssueSourcePreference } from '../../shared/repo-types' +import { mapMRToWorkItem } from './mappers' +import { + acquire, + classifyListFetchError, + getGlabKnownHosts, + glabApiWithHeaders, + glabHostnameArgs, + glabRepoExecOptions, + glabExecFileAsync, + parseGlabJsonList, + release, + resolveIssueSource, + type LocalGitExecOptions +} from './gl-utils' +import { encodedProject } from './project-path-encoding' + +function mrListStateFlags(state: MRListState): string[] { + switch (state) { + case 'opened': + return [] + case 'merged': + return ['--merged'] + case 'closed': + return ['--closed'] + case 'all': + return ['--all'] + } +} + +/** List merge requests for a project via glab CLI, which handles self-hosted auth and project selection. */ +export async function listMergeRequests( + repoPath: string, + state: MRListState = 'opened', + page = 1, + perPage = 20, + preference?: IssueSourcePreference, + query?: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) + // Why: MRs live on the user's fork (origin); route through the preference resolver so fork workflows share plumbing. + const { source: projectRef } = await resolveIssueSource( + repoPath, + preference, + knownHosts, + connectionId, + localGitOptions + ) + if (!projectRef) { + if (connectionId) { + // Why: SSH-backed repos have no local cwd; a cwd-less glab could resolve an unrelated project. + return { + items: [], + page, + perPage, + totalCount: 0, + totalPages: 0, + error: { + type: 'not_found', + message: 'No GitLab project found for this repository.' + } + } + } + // Why: fallback — let glab infer the project from cwd when the host isn't in getGlabKnownHosts. + const stateFlag = mrListStateFlags(state) + // Why: apply the same search as the API path, else queries are silently ignored on cwd-inferred repos (#6263). + const searchFlag = query?.trim() ? ['--search', query.trim()] : [] + await acquire() + try { + const { stdout } = await glabExecFileAsync( + [ + 'mr', + 'list', + '--output', + 'json', + '--per-page', + String(perPage), + '--page', + String(page), + '--order', + 'updated_at', + '--sort', + 'desc', + ...stateFlag, + ...searchFlag + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const data = parseGlabJsonList[0]>(stdout) + return { + items: data.map((d) => mapMRToWorkItem(d, 'unknown')), + page, + perPage, + // Why: the CLI omits x-total headers, so totals are approximate. + totalCount: data.length, + totalPages: data.length < perPage ? page : page + 1 + } + } catch (err) { + return { + items: [], + page, + perPage, + totalCount: 0, + totalPages: 0, + error: classifyListFetchError(err) + } + } finally { + release() + } + } + // Why: GitLab's API uses an absent state param to mean "any"; drop it for 'all'. + const stateParam = state === 'all' ? '' : `&state=${state}` + const searchParam = query?.trim() ? `&search=${encodeURIComponent(query.trim())}` : '' + const path = + `projects/${encodedProject(projectRef.path)}/merge_requests?` + + `page=${page}&per_page=${perPage}&order_by=updated_at&sort=desc&with_merge_status_recheck=false${stateParam}${searchParam}` + const repoId = projectRef.path + + await acquire() + try { + const { body, headers } = await glabApiWithHeaders( + [...glabHostnameArgs(projectRef, connectionId), path], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const data = parseGlabJsonList[0]>(body) + return { + items: data.map((d) => mapMRToWorkItem(d, repoId, projectRef)), + page, + perPage, + totalCount: parseHeaderInt(headers['x-total'], 0), + // Why: GitLab may omit x-total-pages for 'all' or large per_page; fall back to ceil(total/perPage). + totalPages: + parseHeaderInt(headers['x-total-pages'], 0) || + Math.max(1, Math.ceil(parseHeaderInt(headers['x-total'], 0) / perPage)) + } + } catch (err) { + return { + items: [], + page, + perPage, + totalCount: 0, + totalPages: 0, + error: classifyListFetchError(err) + } + } finally { + release() + } +} + +function parseHeaderInt(value: string | undefined, fallback: number): number { + if (!value) { + return fallback + } + const parsed = Number.parseInt(value, 10) + return Number.isFinite(parsed) ? parsed : fallback +} + +/** + * Fetch a work item (MR or issue) by explicit project ref + iid + type. + * Used by the paste-URL flow, where the URL determines the project directly. + */ diff --git a/src/main/gitlab/merge-request-lookup.ts b/src/main/gitlab/merge-request-lookup.ts new file mode 100644 index 00000000000..fa2909d3aff --- /dev/null +++ b/src/main/gitlab/merge-request-lookup.ts @@ -0,0 +1,183 @@ +import type { MRInfo } from '../../shared/gitlab-types' +import { derivePipelineStatus, mapMRInfo } from './mappers' +import { + acquire, + getGlabKnownHosts, + getProjectRef, + glabHostnameArgs, + glabRepoExecOptions, + glabExecFileAsync, + release, + type ProjectRef +} from './gl-utils' +import { encodedProject } from './project-path-encoding' +import { + hasHostedReviewLocalGitOptions, + getHostedReviewLocalGitOptions, + type HostedReviewExecutionOptions +} from '../source-control/hosted-review-git-options' +import { shouldHideNonOpenReviewOnDefaultBranch } from '../source-control/repo-default-branch' + +type HostedReviewLocalGitOptions = ReturnType + +function hostedReviewLocalGitOptionArgs( + options: HostedReviewExecutionOptions = {} +): [] | [HostedReviewLocalGitOptions] { + return hasHostedReviewLocalGitOptions(options) ? [getHostedReviewLocalGitOptions(options)] : [] +} + +export async function getProjectSlug( + repoPath: string, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + const localGitArgs = hostedReviewLocalGitOptionArgs(options) + const knownHosts = await getGlabKnownHosts(connectionId, localGitArgs[0]) + return getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs) +} + +/** + * Fetch a single merge request with pipeline status rolled up. + * Returns null when the MR doesn't exist or glab fails. + */ +export async function getMergeRequest( + repoPath: string, + iid: number, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + const localGitArgs = hostedReviewLocalGitOptionArgs(options) + const localGitOptions = localGitArgs[0] ?? {} + const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) + const projectRef = await getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs) + await acquire() + try { + const args = projectRef + ? [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}` + ] + : ['mr', 'view', String(iid), '--output', 'json'] + const { stdout } = await glabExecFileAsync( + args, + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const data = JSON.parse(stdout) as Parameters[0] & { + head_pipeline?: { status?: string } | null + pipeline?: { status?: string } | null + } + // Why: older GitLab instances expose `pipeline` instead of `head_pipeline`; try both. + const pipelineStatus = derivePipelineStatus(data.head_pipeline ?? data.pipeline ?? null) + return mapMRInfo(data, pipelineStatus) + } catch { + return null + } finally { + release() + } +} + +/** + * Find the merge request whose source branch matches the given name. + * Returns the most recently updated MR for the branch, or null when none exists. + */ +export async function getMergeRequestForBranch( + repoPath: string, + branch: string, + linkedMRIid?: number | null, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {}, + // Why: when true, a failed lookup throws instead of returning null, so callers never report a false not_found. + throwOnFailure = false +): Promise { + const branchName = branch.replace(/^refs\/heads\//, '') + if (!branchName && linkedMRIid == null) { + return null + } + const localGitArgs = hostedReviewLocalGitOptionArgs(options) + const localGitOptions = localGitArgs[0] ?? {} + const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) + const projectRef = await getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs) + if (!projectRef) { + return null + } + await acquire() + try { + if (branchName) { + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + // Why: GitLab does not proactively recompute merge status on list endpoints, so this row + // can sit at `unchecked` forever — and the sidebar merge button gates on MERGEABLE. Ask + // for the async recalculation (best-effort; ignored for non-Developers when + // `restrict_merge_status_recheck` is on) so polling converges instead of stalling. + `projects/${encodedProject(projectRef.path)}/merge_requests?source_branch=${encodeURIComponent(branchName)}&order_by=updated_at&sort=desc&per_page=1&with_merge_status_recheck=true` + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const data = JSON.parse(stdout) as (Parameters[0] & { + head_pipeline?: { status?: string } | null + pipeline?: { status?: string } | null + })[] + if (Array.isArray(data) && data.length > 0) { + const raw = data[0] + // Why: older GitLab list payloads expose `pipeline` instead of `head_pipeline`. + const pipelineStatus = derivePipelineStatus(raw.head_pipeline ?? raw.pipeline ?? null) + const info = mapMRInfo(raw, pipelineStatus) + // Why (#9171): discard a non-open implicit branch match on the repo + // default branch and fall through to the linked-iid fallback below. + const hideOnDefaultBranch = await shouldHideNonOpenReviewOnDefaultBranch({ + state: info.state, + reviewNumber: info.number, + linkedReviewNumber: linkedMRIid, + branchName, + repoPath, + connectionId, + localGitOptions + }) + if (!hideOnDefaultBranch) { + return info + } + } + } + if (typeof linkedMRIid !== 'number') { + return null + } + // Why: create-from-MR worktrees may rename the branch; fall back to the durable linked iid. + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + `projects/${encodedProject(projectRef.path)}/merge_requests/${linkedMRIid}` + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const raw = JSON.parse(stdout) as Parameters[0] & { + head_pipeline?: { status?: string } | null + pipeline?: { status?: string } | null + } + const pipelineStatus = derivePipelineStatus(raw.head_pipeline ?? raw.pipeline ?? null) + return mapMRInfo(raw, pipelineStatus) + } catch (error) { + if (throwOnFailure) { + throw error + } + return null + } finally { + release() + } +} + +/** + * Like getMergeRequestForBranch but throws glab failures instead of returning null, so callers report 'unavailable' not a false "not found". + */ +export function getMergeRequestForBranchOrThrow( + repoPath: string, + branch: string, + linkedMRIid?: number | null, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + return getMergeRequestForBranch(repoPath, branch, linkedMRIid, connectionId, options, true) +} diff --git a/src/main/gitlab/merge-request-project-resolution.ts b/src/main/gitlab/merge-request-project-resolution.ts new file mode 100644 index 00000000000..4a28a3644ed --- /dev/null +++ b/src/main/gitlab/merge-request-project-resolution.ts @@ -0,0 +1,33 @@ +import type { IssueSourcePreference } from '../../shared/repo-types' +import { + getGlabKnownHosts, + resolveIssueSource, + type LocalGitExecOptions, + type ProjectRef +} from './gl-utils' + +export async function withProjectRef( + repoPath: string, + preference: IssueSourcePreference | undefined, + connectionId: string | null | undefined, + explicitProjectRef: ProjectRef | null | undefined, + fn: (projectRef: ProjectRef, repoFlag: string) => Promise, + fallback: T, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const projectRef = + explicitProjectRef ?? + ( + await resolveIssueSource( + repoPath, + preference, + await getGlabKnownHosts(connectionId, localGitOptions), + connectionId, + localGitOptions + ) + ).source + if (!projectRef) { + return fallback + } + return fn(projectRef, projectRef.path) +} diff --git a/src/main/gitlab/merge-request-review-mutations.ts b/src/main/gitlab/merge-request-review-mutations.ts new file mode 100644 index 00000000000..0b27e0026c4 --- /dev/null +++ b/src/main/gitlab/merge-request-review-mutations.ts @@ -0,0 +1,283 @@ +import type { + GitLabAssignableUser, + GitLabDiscussionResolveResult, + GitLabMRInlineCommentInput, + GitLabMRReviewersUpdateResult, + MRComment +} from '../../shared/gitlab-types' +import type { IssueSourcePreference } from '../../shared/repo-types' +import { + acquire, + classifyGlabError, + glabHostnameArgs, + glabRepoExecOptions, + glabExecFileAsync, + release, + type LocalGitExecOptions, + type ProjectRef +} from './gl-utils' +import { encodedProject } from './project-path-encoding' +import { withProjectRef } from './merge-request-project-resolution' + +export async function addMRComment( + repoPath: string, + iid: number, + body: string, + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ ok: true; comment: MRComment } | { ok: false; error: string }> { + return withProjectRef<{ ok: true; comment: MRComment } | { ok: false; error: string }>( + repoPath, + preference, + connectionId, + projectRef, + async (projectRef) => { + await acquire() + try { + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + '-X', + 'POST', + `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/notes`, + '-f', + `body=${body}` + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const data = JSON.parse(stdout) as { + id?: number + author?: { username?: string; avatar_url?: string; state?: string } | null + body?: string + created_at?: string + } + return { + ok: true, + comment: { + id: data.id ?? Date.now(), + author: data.author?.username ?? 'You', + authorAvatarUrl: data.author?.avatar_url ?? '', + body: data.body ?? body, + createdAt: data.created_at ?? new Date().toISOString(), + url: '', + isBot: data.author?.state === 'bot' + } + } + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) } + } finally { + release() + } + }, + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions + ) +} + +export async function addMRInlineComment( + repoPath: string, + iid: number, + input: GitLabMRInlineCommentInput, + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ ok: true; comment: MRComment } | { ok: false; error: string }> { + return withProjectRef<{ ok: true; comment: MRComment } | { ok: false; error: string }>( + repoPath, + preference, + connectionId, + projectRef, + async (projectRef) => { + const body = input.body.trim() + if (!body) { + return { ok: false, error: 'Comment body is required' } + } + await acquire() + try { + const oldPath = input.oldPath ?? input.path + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + '-X', + 'POST', + `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/discussions`, + '-f', + `body=${body}`, + '-f', + 'position[position_type]=text', + '-f', + `position[base_sha]=${input.baseSha}`, + '-f', + `position[start_sha]=${input.startSha}`, + '-f', + `position[head_sha]=${input.headSha}`, + '-f', + `position[old_path]=${oldPath}`, + '-f', + `position[new_path]=${input.path}`, + '-f', + `position[new_line]=${input.line}` + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const data = JSON.parse(stdout) as { + id?: string + notes?: { + id?: number + author?: { username?: string; avatar_url?: string; state?: string } | null + body?: string + created_at?: string + position?: { new_path?: string; new_line?: number } | null + }[] + } + const note = data.notes?.[0] + return { + ok: true, + comment: { + id: note?.id ?? Date.now(), + author: note?.author?.username ?? 'You', + authorAvatarUrl: note?.author?.avatar_url ?? '', + body: note?.body ?? body, + createdAt: note?.created_at ?? new Date().toISOString(), + url: '', + threadId: data.id, + isResolved: false, + isBot: note?.author?.state === 'bot', + path: note?.position?.new_path ?? input.path, + line: note?.position?.new_line ?? input.line + } + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { ok: false, error: classifyGlabError(msg).message } + } finally { + release() + } + }, + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions + ) +} + +export async function resolveMRDiscussion( + repoPath: string, + iid: number, + discussionId: string, + resolved: boolean, + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + return withProjectRef( + repoPath, + preference, + connectionId, + projectRef, + async (projectRef) => { + const trimmedDiscussionId = discussionId.trim() + if (!trimmedDiscussionId) { + return { ok: false, error: 'Discussion id is required' } + } + await acquire() + try { + // Why: GitLab resolves/reopens the whole discussion thread, not a single note. + await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + '-X', + 'PUT', + `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/discussions/${encodeURIComponent(trimmedDiscussionId)}`, + '-f', + `resolved=${resolved ? 'true' : 'false'}` + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + return { ok: true } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { ok: false, error: classifyGlabError(msg).message } + } finally { + release() + } + }, + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions + ) +} + +function mapGitLabReviewer(raw: { + id?: number + username?: string | null + name?: string | null + avatar_url?: string | null + state?: string | null +}): GitLabAssignableUser | null { + if (!raw.username) { + return null + } + return { + ...(typeof raw.id === 'number' ? { id: raw.id } : {}), + username: raw.username, + name: raw.name ?? null, + avatarUrl: raw.avatar_url ?? '', + ...(raw.state !== undefined ? { state: raw.state } : {}) + } +} + +export async function updateMRReviewers( + repoPath: string, + iid: number, + reviewerIds: number[], + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + return withProjectRef( + repoPath, + preference, + connectionId, + projectRef, + async (projectRef) => { + await acquire() + try { + const fields = + reviewerIds.length > 0 + ? reviewerIds.flatMap((id) => ['-f', `reviewer_ids[]=${id}`]) + : ['-f', 'reviewer_ids='] + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + '-X', + 'PUT', + `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`, + ...fields + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const data = JSON.parse(stdout) as { reviewers?: Parameters[0][] } + return { + ok: true, + reviewers: (data.reviewers ?? []) + .map(mapGitLabReviewer) + .filter((u): u is GitLabAssignableUser => !!u) + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { ok: false, error: classifyGlabError(msg).message } + } finally { + release() + } + }, + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions + ) +} diff --git a/src/main/gitlab/merge-request-state-mutations.ts b/src/main/gitlab/merge-request-state-mutations.ts new file mode 100644 index 00000000000..fb5d33bb670 --- /dev/null +++ b/src/main/gitlab/merge-request-state-mutations.ts @@ -0,0 +1,143 @@ +import type { IssueSourcePreference } from '../../shared/repo-types' +import { + acquire, + glabHostnameArgs, + glabRepoExecOptions, + glabExecFileAsync, + release, + type LocalGitExecOptions, + type ProjectRef +} from './gl-utils' +import { withProjectRef } from './merge-request-project-resolution' + +export async function closeMR( + repoPath: string, + iid: number, + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ ok: true } | { ok: false; error: string }> { + return withProjectRef<{ ok: true } | { ok: false; error: string }>( + repoPath, + preference, + connectionId, + projectRef, + async (projectRef, repoFlag) => { + await acquire() + try { + await glabExecFileAsync( + [ + 'mr', + 'close', + String(iid), + '-R', + repoFlag, + ...glabHostnameArgs(projectRef, connectionId) + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + return { ok: true } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + // Why: glab exits non-zero when the MR is already closed — treat as success. + if (msg.toLowerCase().includes('already')) { + return { ok: true } + } + return { ok: false, error: msg } + } finally { + release() + } + }, + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions + ) +} + +export async function reopenMR( + repoPath: string, + iid: number, + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ ok: true } | { ok: false; error: string }> { + return withProjectRef<{ ok: true } | { ok: false; error: string }>( + repoPath, + preference, + connectionId, + projectRef, + async (projectRef, repoFlag) => { + await acquire() + try { + await glabExecFileAsync( + [ + 'mr', + 'reopen', + String(iid), + '-R', + repoFlag, + ...glabHostnameArgs(projectRef, connectionId) + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + return { ok: true } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + if (msg.toLowerCase().includes('already')) { + return { ok: true } + } + return { ok: false, error: msg } + } finally { + release() + } + }, + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions + ) +} + +export async function mergeMR( + repoPath: string, + iid: number, + method: 'merge' | 'squash' | 'rebase' = 'merge', + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ ok: true } | { ok: false; error: string }> { + return withProjectRef<{ ok: true } | { ok: false; error: string }>( + repoPath, + preference, + connectionId, + projectRef, + async (projectRef, repoFlag) => { + await acquire() + try { + // Why: omitting both --squash and --rebase yields a regular merge commit. + const methodFlag = + method === 'squash' ? ['--squash'] : method === 'rebase' ? ['--rebase'] : [] + await glabExecFileAsync( + [ + 'mr', + 'merge', + String(iid), + '-R', + repoFlag, + '--yes', + ...methodFlag, + ...glabHostnameArgs(projectRef, connectionId) + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + return { ok: true } + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) } + } finally { + release() + } + }, + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions + ) +} diff --git a/src/main/gitlab/merge-request-update.ts b/src/main/gitlab/merge-request-update.ts new file mode 100644 index 00000000000..0c82cfcf8ca --- /dev/null +++ b/src/main/gitlab/merge-request-update.ts @@ -0,0 +1,84 @@ +import type { IssueSourcePreference } from '../../shared/repo-types' +import { + acquire, + classifyGlabError, + glabHostnameArgs, + glabRepoExecOptions, + glabExecFileAsync, + release, + type LocalGitExecOptions, + type ProjectRef +} from './gl-utils' +import { encodedProject } from './project-path-encoding' +import { withProjectRef } from './merge-request-project-resolution' + +export async function updateMR( + repoPath: string, + iid: number, + updates: { + title?: string + body?: string + addLabels?: string[] + removeLabels?: string[] + }, + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ ok: true } | { ok: false; error: string }> { + return withProjectRef<{ ok: true } | { ok: false; error: string }>( + repoPath, + preference, + connectionId, + projectRef, + async (projectRef) => { + const fields: string[] = [] + const title = updates.title?.trim() + if (updates.title !== undefined) { + if (!title) { + return { ok: false, error: 'Title is required' } + } + fields.push(`title=${title}`) + } + if (updates.body !== undefined) { + fields.push(`description=${updates.body}`) + } + const addLabels = (updates.addLabels ?? []).filter((label) => label.trim().length > 0) + const removeLabels = (updates.removeLabels ?? []).filter((label) => label.trim().length > 0) + if (addLabels.length > 0) { + fields.push(`add_labels=${addLabels.join(',')}`) + } + if (removeLabels.length > 0) { + fields.push(`remove_labels=${removeLabels.join(',')}`) + } + if (fields.length === 0) { + return { ok: true } + } + + await acquire() + try { + await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + '-X', + 'PUT', + `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`, + ...fields.flatMap((field) => ['-f', field]) + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + return { ok: true } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { ok: false, error: classifyGlabError(msg).message } + } finally { + release() + } + }, + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions + ) +} + +/** Re-export so callers don't need to know the gl-utils module split. */ diff --git a/src/main/gitlab/pipeline-job-mutations.ts b/src/main/gitlab/pipeline-job-mutations.ts new file mode 100644 index 00000000000..48cd2b2e6c5 --- /dev/null +++ b/src/main/gitlab/pipeline-job-mutations.ts @@ -0,0 +1,128 @@ +import type { + GitLabJobTraceResult, + GitLabPipelineJob, + GitLabRetryJobResult +} from '../../shared/gitlab-types' +import type { IssueSourcePreference } from '../../shared/repo-types' +import { + acquire, + classifyGlabError, + classifyJobLogError, + glabHostnameArgs, + glabRepoExecOptions, + glabExecFileAsync, + isMissingJobLogError, + release, + type LocalGitExecOptions, + type ProjectRef +} from './gl-utils' +import { encodedProject } from './project-path-encoding' +import { withProjectRef } from './merge-request-project-resolution' + +function mapRetriedPipelineJob( + data: { + id?: number + pipeline?: { id?: number | null } | null + name?: string + stage?: string + status?: string + web_url?: string + duration?: number | null + }, + fallbackJobId: number +): GitLabPipelineJob { + return { + id: data.id ?? fallbackJobId, + ...(typeof data.pipeline?.id === 'number' ? { pipelineId: data.pipeline.id } : {}), + name: data.name ?? '', + stage: data.stage ?? '', + status: data.status ?? '', + webUrl: data.web_url ?? '', + duration: typeof data.duration === 'number' ? data.duration : null + } +} + +export async function getJobTrace( + repoPath: string, + jobId: number, + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + return withProjectRef( + repoPath, + preference, + connectionId, + projectRef, + async (projectRef) => { + await acquire() + try { + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + `projects/${encodedProject(projectRef.path)}/jobs/${jobId}/trace` + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + return { ok: true, trace: stdout } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + // A job with no log is an empty log, not a failure: surfacing the 404 would + // pin an error on the Checks row for a job canceled before it started. + if (isMissingJobLogError(msg)) { + return { ok: true, trace: '' } + } + return { ok: false, error: classifyJobLogError(msg).message } + } finally { + release() + } + }, + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions + ) +} + +export async function retryJob( + repoPath: string, + jobId: number, + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + return withProjectRef( + repoPath, + preference, + connectionId, + projectRef, + async (projectRef) => { + await acquire() + try { + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + '-X', + 'POST', + `projects/${encodedProject(projectRef.path)}/jobs/${jobId}/retry` + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const trimmed = stdout.trim() + return { + ok: true, + ...(trimmed ? { job: mapRetriedPipelineJob(JSON.parse(trimmed), jobId) } : {}) + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { ok: false, error: classifyGlabError(msg).message } + } finally { + release() + } + }, + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions + ) +} diff --git a/src/main/gitlab/work-item-queries.ts b/src/main/gitlab/work-item-queries.ts new file mode 100644 index 00000000000..4e6e3c3fc53 --- /dev/null +++ b/src/main/gitlab/work-item-queries.ts @@ -0,0 +1,252 @@ +import type { ClassifiedError } from '../../shared/classified-error' +import type { + GitLabPagedResult, + GitLabTodo, + GitLabWorkItem, + MRListState +} from '../../shared/gitlab-types' +import type { IssueSourcePreference } from '../../shared/repo-types' +import { mapIssueToWorkItem, mapMRToWorkItem } from './mappers' +import { + acquire, + classifyListFetchError, + getGlabKnownHosts, + getProjectRef, + glabHostnameArgs, + glabRepoExecOptions, + glabExecFileAsync, + parseGlabJsonList, + release, + resolveIssueSource, + type LocalGitExecOptions, + type ProjectRef +} from './gl-utils' +import { encodedProject } from './project-path-encoding' +import type { IssueListState } from './issues' +import { listMergeRequests } from './merge-request-list' + +export async function getWorkItemByProjectRef( + repoPath: string, + projectRef: ProjectRef, + iid: number, + type: 'issue' | 'mr', + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + await acquire() + try { + const resource = type === 'mr' ? 'merge_requests' : 'issues' + const { stdout } = await glabExecFileAsync( + [ + 'api', + // Why: preserve the pasted URL's explicit host so cwd remotes can't redirect the lookup. + ...(projectRef.host ? ['--hostname', projectRef.host] : []), + `projects/${encodedProject(projectRef.path)}/${resource}/${iid}` + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const data = JSON.parse(stdout) + if (type === 'mr') { + return mapMRToWorkItem(data, projectRef.path, projectRef) + } + return mapIssueToWorkItem(data, projectRef.path, projectRef) + } catch { + return null + } finally { + release() + } +} + +function mrStateToIssueState(state: MRListState): IssueListState | null { + // Why: issues have no 'merged' state; return null so the issues fetch is skipped, not mis-mapped. + switch (state) { + case 'opened': + return 'opened' + case 'closed': + return 'closed' + case 'all': + return 'all' + case 'merged': + return null + } +} + +export async function listWorkItems( + repoPath: string, + state: MRListState = 'opened', + page = 1, + perPage = 20, + preference?: IssueSourcePreference, + query?: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise> { + const issueState = mrStateToIssueState(state) + const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) + const { source: projectRef } = await resolveIssueSource( + repoPath, + preference, + knownHosts, + connectionId, + localGitOptions + ) + if (!projectRef) { + return { + items: [], + page, + perPage, + totalCount: 0, + totalPages: 0, + error: { + type: 'not_found', + message: 'No GitLab project found for this repository.' + } + } + } + // Why: fan out both reads so latency is the slower of the two, not the sum. + // Why read the raw issues API (not listIssues): IssueInfo strips updated_at, which the combined sort needs. + const [mrs, issues] = await Promise.all([ + listMergeRequests( + repoPath, + state, + page, + perPage, + preference, + query, + connectionId, + localGitOptions + ), + issueState === null + ? Promise.resolve({ + items: [] as GitLabWorkItem[], + error: undefined as ClassifiedError | undefined + }) + : fetchIssuesAsWorkItems( + repoPath, + projectRef, + issueState, + page, + perPage, + query, + connectionId, + localGitOptions + ) + ]) + const merged = [...mrs.items, ...issues.items].sort((a, b) => + (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '') + ) + // Why: MR-side error wins — issues can be disabled per project, making its error less informative. + const error: ClassifiedError | undefined = mrs.error ?? issues.error + return { + items: merged, + page, + perPage, + // Why: approximate totals — GitLab can't paginate across MRs+issues as one set, so MR totals stand in. + totalCount: mrs.totalCount, + totalPages: mrs.totalPages, + ...(error ? { error } : {}) + } +} + +export async function fetchIssuesAsWorkItems( + repoPath: string, + projectRef: ProjectRef, + state: IssueListState, + page: number, + perPage: number, + query?: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ items: GitLabWorkItem[]; error: ClassifiedError | undefined }> { + await acquire() + try { + const stateParam = state === 'all' ? '' : `&state=${state}` + const searchParam = query?.trim() ? `&search=${encodeURIComponent(query.trim())}` : '' + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + `projects/${encodedProject(projectRef.path)}/issues?page=${page}&per_page=${perPage}&order_by=updated_at&sort=desc${stateParam}${searchParam}` + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const data = parseGlabJsonList[0]>(stdout) + return { + items: data.map((d) => mapIssueToWorkItem(d, projectRef.path, projectRef)), + error: undefined + } + } catch (err) { + return { + items: [], + error: classifyListFetchError(err) + } + } finally { + release() + } +} + +/** + * List the authenticated user's GitLab todos (gitlab.com/dashboard/todos). + * User-scoped, so cwd is irrelevant; repoPath only satisfies the IPC handler's path-validation guard. + */ +export async function listTodos( + repoPath: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const projectRef = await getProjectRef( + repoPath, + await getGlabKnownHosts(connectionId, localGitOptions), + connectionId, + localGitOptions + ) + if (connectionId && !projectRef) { + return [] + } + await acquire() + try { + // Why: per_page=50 keeps this cross-project view cheap; the UI only shows top-priority todos. + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...(projectRef ? glabHostnameArgs(projectRef, connectionId) : []), + 'todos?state=pending&per_page=50' + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + type RESTTodo = { + id?: number + action_name?: string + target_type?: string + target?: { + iid?: number + title?: string + web_url?: string + } | null + target_url?: string + author?: { username?: string | null; avatar_url?: string | null } | null + project?: { path_with_namespace?: string } | null + updated_at?: string + state?: string + } + const data = JSON.parse(stdout) as RESTTodo[] + return data.map((t) => ({ + id: t.id ?? 0, + actionName: t.action_name ?? '', + targetType: t.target_type ?? '', + targetIid: typeof t.target?.iid === 'number' ? t.target.iid : null, + targetTitle: t.target?.title ?? '', + targetUrl: t.target_url ?? t.target?.web_url ?? '', + projectPath: t.project?.path_with_namespace ?? '', + authorUsername: t.author?.username ?? '', + authorAvatarUrl: t.author?.avatar_url ?? '', + updatedAt: t.updated_at ?? '', + state: t.state === 'done' ? 'done' : 'pending' + })) + } catch { + // Why: silent empty-list on auth/network failure matches the read-side surface; caller UI signals connectivity. + return [] + } finally { + release() + } +} diff --git a/src/main/index.ts b/src/main/index.ts index ae8fed9981d..0a47efb9335 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -699,7 +699,14 @@ if (app.isPackaged && process.platform !== 'win32') { void hydrateShellPath().then((result) => { if (result.ok) { mergePathSegments(result.segments) + return } + // Why: on failure the seeded fallbacks stay in front, which for an nvm user means + // the newest install rather than their `default`. Name the reason so that shows up + // in a log bundle instead of as an unexplained missing CLI. + console.warn( + `[shell-path] login-shell probe failed (${result.failureReason}); using seeded PATH` + ) }) } configureDevUserDataPath(is.dev) @@ -1905,7 +1912,8 @@ function recordProcessGoneCrash( reason, exitCode, expectedTeardown: getExpectedTeardownScope(webContentsId), - details + details, + ...(webContentsId !== undefined ? { webContentsId } : {}) }) } @@ -2719,6 +2727,9 @@ void app.whenReady().then(async () => { agentHookServer.attestCompatibilityAuthority(candidate), retireAgentHookCompatibilityAuthority: (paneKey) => agentHookServer.retirePaneAuthority(paneKey), + reconcileAgentStatusForEndedProcess: (paneKeys) => { + agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys) + }, canRecoverPersistentLocalPtys: () => getDaemonProvider() !== null, // Why: source codex-home here (runs in window AND serve) so aiVault.listSessions includes managed-Codex sessions; registerCoreHandlers is window-only. getAdditionalAiVaultCodexHomePaths: () => diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts index 5a8e3f69ae4..5615fa38208 100644 --- a/src/main/ipc/agent-hooks.ts +++ b/src/main/ipc/agent-hooks.ts @@ -6,13 +6,9 @@ import type { } from '../../shared/agent-status-types' import type { AgentInterruptInferenceRequest } from '../../shared/agent-interrupt-intent' import type { AgentQuestionAnsweredInferenceRequest } from '../../shared/agent-question-answered-intent' -import { agentHookServer, isValidPaneKey } from '../agent-hooks/server' +import { agentHookServer } from '../agent-hooks/server' import { ampHookService } from '../amp/hook-service' -import { - clearMigrationUnsupportedPtysByTabPrefix, - clearMigrationUnsupportedPtysForPaneKey, - getMigrationUnsupportedPtySnapshot -} from '../agent-hooks/migration-unsupported-pty-state' +import { getMigrationUnsupportedPtySnapshot } from '../agent-hooks/migration-unsupported-pty-state' import { claudeHookService } from '../claude/hook-service' import { codexHookService } from '../codex/hook-service' import { geminiHookService } from '../gemini/hook-service' @@ -27,10 +23,10 @@ import { devinHookService } from '../devin/hook-service' import { kimiHookService } from '../kimi/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' import { registerAgentPaneAuthorityIpcHandlers } from './agent-pane-authority-ipc' +import { registerAgentStatusRowTeardownIpcHandlers } from './agent-status-row-teardown-ipc' import { createAgentPaneAuthorityOwnership } from './agent-pane-authority-ownership' import { enrichAgentStatusIpcPayload, - isValidAgentStatusDropTabId, type AgentStatusRuntimeEnrichment } from './agent-status-ipc-boundary' @@ -70,38 +66,7 @@ export function registerAgentHookHandlers( ipcMain.removeHandler('agentStatus:inferInterrupt') ipcMain.removeHandler('agentStatus:inferQuestionAnswered') ipcMain.removeHandler('agentStatus:getMigrationUnsupportedSnapshot') - // Why: agentStatus:drop is sent fire-and-forget from the renderer via - // ipcRenderer.send(); we listen with ipcMain.on (not handle) so we don't - // round-trip a response. Removing first keeps re-registration safe even - // though the module-level registered guard already prevents re-entry today. - ipcMain.removeAllListeners('agentStatus:drop') - ipcMain.removeAllListeners('agentStatus:dropByTabPrefix') - ipcMain.on('agentStatus:drop', (_event, paneKey: unknown) => { - if (typeof paneKey !== 'string' || !isValidPaneKey(paneKey)) { - return - } - try { - // Why: dropStatusEntry (not clearPaneState) is correct here — the user is - // dismissing a status row, not tearing down a PTY. clearPaneState would also - // wipe the per-pane prompt/tool caches, which the next hook event for that - // (still-alive) pane needs to render a coherent row. - agentHookServer.dropStatusEntry(paneKey) - clearMigrationUnsupportedPtysForPaneKey(paneKey) - } catch (err) { - console.warn('[agent-hooks] dropStatusEntry failed:', err) - } - }) - ipcMain.on('agentStatus:dropByTabPrefix', (_event, tabId: unknown) => { - if (!isValidAgentStatusDropTabId(tabId)) { - return - } - try { - agentHookServer.dropStatusEntriesByTabPrefix(tabId) - clearMigrationUnsupportedPtysByTabPrefix(tabId) - } catch (err) { - console.warn('[agent-hooks] dropStatusEntriesByTabPrefix failed:', err) - } - }) + registerAgentStatusRowTeardownIpcHandlers() registerAgentPaneAuthorityIpcHandlers({ ownsPty: createAgentPaneAuthorityOwnership({ getPtyIdForPaneKey: dependencies.getPtyIdForPaneKey, diff --git a/src/main/ipc/agent-status-row-teardown-ipc.ts b/src/main/ipc/agent-status-row-teardown-ipc.ts new file mode 100644 index 00000000000..e33e1c93789 --- /dev/null +++ b/src/main/ipc/agent-status-row-teardown-ipc.ts @@ -0,0 +1,69 @@ +import { ipcMain } from 'electron' +import { agentHookServer, isValidPaneKey } from '../agent-hooks/server' +import { + clearMigrationUnsupportedPtysByTabPrefix, + clearMigrationUnsupportedPtysForPaneKey +} from '../agent-hooks/migration-unsupported-pty-state' +import { isValidAgentStatusDropTabId } from './agent-status-ipc-boundary' + +/** + * The three renderer-initiated ways a status row goes away. All fire-and-forget + * (`ipcRenderer.send` → `ipcMain.on`), so none round-trips a response; removing the + * listeners first keeps re-registration safe. + * + * They are NOT interchangeable. A dismissal keeps the pane's per-pane caches because its agent may + * still be alive; a confirmed process exit must take them too, or a surviving Claude latch resolves + * the pane's next event straight back to `working`. + */ +export function registerAgentStatusRowTeardownIpcHandlers(): void { + ipcMain.removeAllListeners('agentStatus:drop') + ipcMain.removeAllListeners('agentStatus:reconcileEndedProcess') + ipcMain.removeAllListeners('agentStatus:dropByTabPrefix') + + ipcMain.on('agentStatus:drop', (_event, paneKey: unknown) => { + if (typeof paneKey !== 'string' || !isValidPaneKey(paneKey)) { + return + } + try { + // Why: dropStatusEntry (not clearPaneState) is correct here — the user is + // dismissing a status row, not tearing down a PTY. clearPaneState would also + // wipe the per-pane prompt/tool caches, which the next hook event for that + // (still-alive) pane needs to render a coherent row. + agentHookServer.dropStatusEntry(paneKey) + clearMigrationUnsupportedPtysForPaneKey(paneKey) + } catch (err) { + console.warn('[agent-hooks] dropStatusEntry failed:', err) + } + }) + + ipcMain.on('agentStatus:reconcileEndedProcess', (_event, paneKey: unknown) => { + if (typeof paneKey !== 'string' || !isValidPaneKey(paneKey)) { + return + } + try { + // Why: a process-table-confirmed agent exit is exactly the case the dismissal above excludes + // — the pane's agent is NOT still alive — so its latches must go with the row (STA-4612). + agentHookServer.reconcileEndedProcessForPaneKeys([paneKey], { + // Why: this route only fires on a confirmed shell foreground, so the PTY outlived the + // agent. The row's resume identity is still usable in that very pane — only its live + // claims are dead. + preserveResumeIdentity: true + }) + clearMigrationUnsupportedPtysForPaneKey(paneKey) + } catch (err) { + console.warn('[agent-hooks] reconcileEndedProcessForPaneKeys failed:', err) + } + }) + + ipcMain.on('agentStatus:dropByTabPrefix', (_event, tabId: unknown) => { + if (!isValidAgentStatusDropTabId(tabId)) { + return + } + try { + agentHookServer.dropStatusEntriesByTabPrefix(tabId) + clearMigrationUnsupportedPtysByTabPrefix(tabId) + } catch (err) { + console.warn('[agent-hooks] dropStatusEntriesByTabPrefix failed:', err) + } + }) +} diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index f54a8edbf23..198c5295662 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -15,6 +15,7 @@ import { import { scanSshAiVaultSessions } from '../ai-vault/ssh-session-list' import { AiVaultScanCoordinator } from '../ai-vault/ai-vault-scan-coordinator' import type { AiVaultDeleteSessionArgs } from '../../shared/ai-vault-session-deletion' +import { describeAiVaultScanError } from '../../shared/ai-vault-scan-error-message' import { AI_VAULT_SCOPE_PATHS_MAX_COUNT, isAiVaultScanCancelledError, @@ -237,10 +238,14 @@ async function scanLocalAiVaultSessionsAsIssue( if (isAiVaultScanCancelledError(error)) { throw error } + // Raw supervision text ("restart circuit is open") means nothing to a user, + // so the row carries actionable copy and the log keeps the original. + const raw = error instanceof Error ? error.message : 'Local session scan failed.' + console.error('[ai-vault] local session scan failed:', raw) return aiVaultScanIssueResult({ executionHostId: LOCAL_EXECUTION_HOST_ID, path: 'this computer', - message: error instanceof Error ? error.message : 'Local session scan failed.' + message: describeAiVaultScanError(raw) }) } } diff --git a/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts b/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts index f291580db40..41c108a5cbc 100644 --- a/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts +++ b/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts @@ -76,8 +76,11 @@ function registerHandlersWithStubStore(): void { } as never) } -function emitRendererBreadcrumb(args: unknown): void { - listeners.get('crashReports:recordBreadcrumb')?.(null, args) +function emitRendererBreadcrumb(args: unknown, senderId?: number): void { + listeners.get('crashReports:recordBreadcrumb')?.( + senderId === undefined ? null : { sender: { id: senderId } }, + args + ) } describe('renderer breadcrumb IPC routing', () => { @@ -121,6 +124,17 @@ describe('renderer breadcrumb IPC routing', () => { expect(spanEndMock).toHaveBeenCalledTimes(1) }) + it('routes the trusted sender identity into renderer breadcrumb attribution', () => { + emitRendererBreadcrumb({ name: 'renderer_error', data: { message: 'boom' } }, 42) + + expect(recordCoalescedCrashBreadcrumbMock).toHaveBeenCalledWith( + expect.objectContaining({ + origin: 'renderer:42', + coalesceKey: expect.stringContaining('renderer:42') + }) + ) + }) + it('coalesces renderer rejection breadcrumbs by reason message', () => { emitRendererBreadcrumb({ name: 'renderer_unhandled_rejection', diff --git a/src/main/ipc/crash-reporting-renderer-breadcrumbs.ts b/src/main/ipc/crash-reporting-renderer-breadcrumbs.ts index b6c8f5c51ed..2b2129436fb 100644 --- a/src/main/ipc/crash-reporting-renderer-breadcrumbs.ts +++ b/src/main/ipc/crash-reporting-renderer-breadcrumbs.ts @@ -125,10 +125,10 @@ function rendererBreadcrumbCoalesceKey( return JSON.stringify([name, message, ...sourceIdentity]) } -export function recordRendererBreadcrumbFromRenderer(args?: { - name?: unknown - data?: unknown -}): void { +export function recordRendererBreadcrumbFromRenderer( + args?: { name?: unknown; data?: unknown }, + origin?: string +): void { if (!args || typeof args.name !== 'string') { return } @@ -136,15 +136,20 @@ export function recordRendererBreadcrumbFromRenderer(args?: { if (COALESCED_RENDERER_BREADCRUMB_NAMES.has(args.name)) { const coalesceKey = rendererBreadcrumbCoalesceKey(args.name, data) if (!coalesceKey) { - recordCrashBreadcrumb(args.name, data) + if (origin) { + recordCrashBreadcrumb(args.name, data, origin) + } else { + recordCrashBreadcrumb(args.name, data) + } recordRendererBreadcrumbTrace(args.name, data) return } const coalesceResult = recordCoalescedCrashBreadcrumb({ name: args.name, data, - coalesceKey, - minIntervalMs: RENDERER_BREADCRUMB_COALESCE_MS + coalesceKey: origin ? `${origin}\u0000${coalesceKey}` : coalesceKey, + minIntervalMs: RENDERER_BREADCRUMB_COALESCE_MS, + ...(origin ? { origin } : {}) }) // Why: tracing every suppressed duplicate would preserve the same // serialization and disk churn that breadcrumb coalescing removes. @@ -157,7 +162,11 @@ export function recordRendererBreadcrumbFromRenderer(args?: { ) } } else { - recordCrashBreadcrumb(args.name, data) + if (origin) { + recordCrashBreadcrumb(args.name, data, origin) + } else { + recordCrashBreadcrumb(args.name, data) + } recordRendererBreadcrumbTrace(args.name, data) } } diff --git a/src/main/ipc/crash-reporting-renderer-error-report-attribution.test.ts b/src/main/ipc/crash-reporting-renderer-error-report-attribution.test.ts index 5592ca7ef5d..03a272a64a1 100644 --- a/src/main/ipc/crash-reporting-renderer-error-report-attribution.test.ts +++ b/src/main/ipc/crash-reporting-renderer-error-report-attribution.test.ts @@ -97,4 +97,15 @@ describe('renderer error report attribution', () => { expect(text).toContain('boundary_id') expect(text.indexOf('Attribution: unreliable')).toBeLessThan(text.indexOf('Details:')) }) + + it('does not dedupe identical errors from separate renderer windows', async () => { + const first = makeStore() + const second = makeStore() + + const firstResult = await recordRendererErrorReport(first.store, baseArgs(), 11) + const secondResult = await recordRendererErrorReport(second.store, baseArgs(), 22) + + expect(firstResult).toMatchObject({ ok: true, deduped: false }) + expect(secondResult).toMatchObject({ ok: true, deduped: false }) + }) }) diff --git a/src/main/ipc/crash-reporting-renderer-error-report.ts b/src/main/ipc/crash-reporting-renderer-error-report.ts index 3fac1f73302..581cb62d8fb 100644 --- a/src/main/ipc/crash-reporting-renderer-error-report.ts +++ b/src/main/ipc/crash-reporting-renderer-error-report.ts @@ -11,6 +11,7 @@ import { UNRELIABLE_BOUNDARY_ATTRIBUTION_NOTE } from '../../shared/react-update-depth-attribution' import type { CrashReportStore } from '../crash-reporting/crash-report-store' +import { rendererCrashBreadcrumbOrigin } from '../../shared/crash-breadcrumb-origin' import { getCrashBreadcrumbSnapshot } from '../crash-reporting/crash-breadcrumb-store' export const recentRendererErrorReportKeys = new Map() @@ -119,8 +120,12 @@ function pruneRendererErrorReportKeys(now: number): void { } } -function getRendererErrorReportKey(args: ReactErrorBoundaryReportArgs): string { +function getRendererErrorReportKey( + args: ReactErrorBoundaryReportArgs, + webContentsId?: number +): string { return JSON.stringify({ + webContentsId, boundaryId: args.boundaryId, surface: args.surface, errorName: args.errorName, @@ -131,7 +136,8 @@ function getRendererErrorReportKey(args: ReactErrorBoundaryReportArgs): string { export async function recordRendererErrorReport( store: CrashReportStore, - args: unknown + args: unknown, + webContentsId?: number ): Promise { const normalized = normalizeRendererErrorReportArgs(args) if (!normalized) { @@ -140,7 +146,7 @@ export async function recordRendererErrorReport( const now = Date.now() pruneRendererErrorReportKeys(now) - const key = getRendererErrorReportKey(normalized) + const key = getRendererErrorReportKey(normalized, webContentsId) if (now - (recentRendererErrorReportKeys.get(key) ?? 0) < RENDERER_ERROR_DEDUPE_MS) { return { ok: true, report: null, deduped: true } } @@ -186,7 +192,9 @@ export async function recordRendererErrorReport( }, // Why: React render failures are recoverable only because a boundary // caught them; persist the same recent app breadcrumbs as native crashes. - breadcrumbs: getCrashBreadcrumbSnapshot() + breadcrumbs: getCrashBreadcrumbSnapshot( + webContentsId === undefined ? undefined : rendererCrashBreadcrumbOrigin(webContentsId) + ) }) return { ok: true, report, deduped: false } diff --git a/src/main/ipc/crash-reporting.ts b/src/main/ipc/crash-reporting.ts index 95939a5f139..9b2d11cad7c 100644 --- a/src/main/ipc/crash-reporting.ts +++ b/src/main/ipc/crash-reporting.ts @@ -5,6 +5,7 @@ import { formatCrashReportText } from '../../shared/crash-reporting' import type { CrashReportStore } from '../crash-reporting/crash-report-store' +import { rendererCrashBreadcrumbOrigin } from '../../shared/crash-breadcrumb-origin' import { assertClipboardTextWriteWithinLimit, isClipboardTextWriteTooLargeError @@ -64,8 +65,12 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void { ipcMain.removeAllListeners('crashReports:recordBreadcrumb') ipcMain.on( 'crashReports:recordBreadcrumb', - (_event, args?: { name?: unknown; data?: unknown }) => { - recordRendererBreadcrumbFromRenderer(args) + (event, args?: { name?: unknown; data?: unknown }) => { + const senderId = event?.sender?.id + recordRendererBreadcrumbFromRenderer( + args, + typeof senderId === 'number' ? rendererCrashBreadcrumbOrigin(senderId) : undefined + ) } ) @@ -94,9 +99,9 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void { ) ipcMain.removeHandler('crashReports:recordRendererError') - ipcMain.handle('crashReports:recordRendererError', async (_event, args: unknown) => { + ipcMain.handle('crashReports:recordRendererError', async (event, args: unknown) => { try { - return await recordRendererErrorReport(store, args) + return await recordRendererErrorReport(store, args, event?.sender?.id) } catch (error) { console.error('[crash-reporting] Failed to record renderer error report:', error) return { ok: false, error: 'Failed to record renderer error report.' } diff --git a/src/main/ipc/dashboard-payload-validation.test.ts b/src/main/ipc/dashboard-payload-validation.test.ts index 72c0198fb45..fcfe2948baf 100644 --- a/src/main/ipc/dashboard-payload-validation.test.ts +++ b/src/main/ipc/dashboard-payload-validation.test.ts @@ -99,6 +99,19 @@ function imageIconSrc(bodyBytes: number, withWhitespace = false): string { describe('dashboard payload validation', () => { it('accepts a complete dashboard snapshot', () => { expect(isDashboardSnapshot(SNAPSHOT)).toBe(true) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + cards: [ + { + ...SNAPSHOT.cards[0], + bucket: 'working', + dotState: 'working', + workingMode: 'monitoring' + } + ] + }) + ).toBe(true) }) it('rejects malformed or unbounded snapshot fields', () => { @@ -109,6 +122,18 @@ describe('dashboard payload validation', () => { cards: [{ ...SNAPSHOT.cards[0], bucket: 'unexpected' }] }) ).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + cards: [{ ...SNAPSHOT.cards[0], dotState: 'monitoring' }] + }) + ).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + cards: [{ ...SNAPSHOT.cards[0], dotState: 'done', workingMode: 'monitoring' }] + }) + ).toBe(false) expect( isDashboardSnapshot({ ...SNAPSHOT, diff --git a/src/main/ipc/dashboard-payload-validation.ts b/src/main/ipc/dashboard-payload-validation.ts index e8f8560aa51..8463be64bba 100644 --- a/src/main/ipc/dashboard-payload-validation.ts +++ b/src/main/ipc/dashboard-payload-validation.ts @@ -257,6 +257,8 @@ function isDashboardCard(value: unknown): boolean { DASHBOARD_BUCKETS.has(card.bucket) && typeof card.dotState === 'string' && DASHBOARD_DOT_STATES.has(card.dotState) && + (card.workingMode === undefined || + (card.dotState === 'working' && card.workingMode === 'monitoring')) && isBoundedString(card.task, AGENT_STATUS_MAX_FIELD_LENGTH, true) && isOptionalBoundedString(card.lastUserMessage, AGENT_STATUS_MAX_FIELD_LENGTH) && isOptionalBoundedString(card.lastAgentMessage, AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH) && diff --git a/src/main/ipc/pty-applied-size-reporting.test.ts b/src/main/ipc/pty-applied-size-reporting.test.ts index 5607c7cab61..87b8c5e086f 100644 --- a/src/main/ipc/pty-applied-size-reporting.test.ts +++ b/src/main/ipc/pty-applied-size-reporting.test.ts @@ -75,6 +75,30 @@ describe('registerPtyHandlers', () => { return write } + function setupProviderWithTransientAttachFailure(earlyExit = false): ReturnType { + const spawn = vi.fn(async (opts: { sessionId?: string }) => { + if (spawn.mock.calls.length > 1) { + if (earlyExit) { + return { id: opts.sessionId ?? 'daemon-pty', exitedBeforeSpawnReply: true as const } + } + throw new Error('transport unavailable') + } + return { id: opts.sessionId ?? 'daemon-pty' } + }) + setLocalPtyProvider({ + spawn, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + onData: vi.fn(() => vi.fn()), + onExit: vi.fn(() => vi.fn()), + listProcesses: vi.fn(async () => []), + getForegroundProcess: vi.fn(async () => null) + } as never) + return spawn + } + const resizeListener = (): ((event: unknown, args: unknown) => void) => { const call = onMock.mock.calls.find((entry: unknown[]) => entry[0] === 'pty:resize') if (!call) { @@ -150,6 +174,63 @@ describe('registerPtyHandlers', () => { const reported = await handlers.get('pty:getSize')!(null, { id }) expect(reported).toEqual({ cols: 100, rows: 30 }) }) + it('preserves cached geometry after a transient renderer attach failure', async () => { + setupProviderWithTransientAttachFailure() + handlers.clear() + registerPtyHandlers(mainWindow as never) + const request = { sessionId: 'daemon-existing-size', env: {} } + await handlers.get('pty:spawn')!(null, { ...request, cols: 200, rows: 50 }) + + await expect( + handlers.get('pty:spawn')!(null, { ...request, cols: 80, rows: 24 }) + ).rejects.toThrow('transport unavailable') + + await expect(handlers.get('pty:getSize')!(null, { id: request.sessionId })).resolves.toEqual({ + cols: 200, + rows: 50 + }) + }) + it('preserves cached geometry after a transient runtime attach failure', async () => { + setupProviderWithTransientAttachFailure() + let controller: + | { + spawn: (args: Record) => Promise + getSize: (id: string) => { cols: number; rows: number } | null + } + | undefined + const runtime = { + setPtyController: vi.fn((next) => { + controller = next + }), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const request = { sessionId: 'runtime-existing-size' } + await controller!.spawn({ ...request, cols: 200, rows: 50 }) + + await expect(controller!.spawn({ ...request, cols: 80, rows: 24 })).rejects.toThrow( + 'transport unavailable' + ) + + expect(controller!.getSize(request.sessionId)).toEqual({ cols: 200, rows: 50 }) + }) + it('clears cached geometry when the provider proves the PTY exited before reply', async () => { + setupProviderWithTransientAttachFailure(true) + handlers.clear() + registerPtyHandlers(mainWindow as never) + const request = { sessionId: 'daemon-exited-before-reply', env: {} } + await handlers.get('pty:spawn')!(null, { ...request, cols: 200, rows: 50 }) + + await expect( + handlers.get('pty:spawn')!(null, { ...request, cols: 80, rows: 24 }) + ).rejects.toThrow('agent_session_exited_during_start') + + await expect( + handlers.get('pty:getSize')!(null, { id: request.sessionId }) + ).resolves.toBeNull() + }) it('fans out accepted desktop resizes to the runtime after provider resize', async () => { const resize = vi.fn() setupProviderWithAppliedSize({ applied: { cols: 120, rows: 30 }, resize }) diff --git a/src/main/ipc/pty-buffer-snapshot-dispatch.test.ts b/src/main/ipc/pty-buffer-snapshot-dispatch.test.ts index 04ca18f997f..e08e6aa2090 100644 --- a/src/main/ipc/pty-buffer-snapshot-dispatch.test.ts +++ b/src/main/ipc/pty-buffer-snapshot-dispatch.test.ts @@ -48,8 +48,13 @@ vi.mock('../codex/codex-state-db-backfill-recovery', () => ) describe('registerPtyHandlers', () => { - const { handlers, mainWindow, createMockProc, installObservableDaemonTestProvider } = - setupPtyIpcSuite() + const { + handlers, + mainWindow, + mainWindowIpcEvent, + createMockProc, + installObservableDaemonTestProvider + } = setupPtyIpcSuite() describe('agent_started telemetry', () => { // Why: telemetry-plan.md§Agent launch semantics — agent_started fires only after provider.spawn resolves; a malformed payload must not emit a silent event. @@ -190,7 +195,7 @@ describe('registerPtyHandlers', () => { expect(responseChannelRegistrations.length).toBe(1) // Drain the in-flight requests so the test doesn't leak timers. for (const requestId of getSentRequestIds()) { - listener(null, { requestId, snapshot: null }) + listener(mainWindowIpcEvent, { requestId, snapshot: null }) } await Promise.all(inflight) }) @@ -202,11 +207,11 @@ describe('registerPtyHandlers', () => { const requestIdA = ids[0] const requestIdB = ids[1] - listener(null, { + listener(mainWindowIpcEvent, { requestId: requestIdB, snapshot: { data: 'B-data', cols: 80, rows: 24 } }) - listener(null, { + listener(mainWindowIpcEvent, { requestId: requestIdA, snapshot: { data: 'A-data', cols: 100, rows: 30, lastTitle: 'A-title' } }) @@ -224,11 +229,11 @@ describe('registerPtyHandlers', () => { const pending = controller.serializeBuffer('pty-1') const realRequestId = getSentRequestIds()[0] - listener(null, { + listener(mainWindowIpcEvent, { requestId: 'not-a-real-id', snapshot: { data: 'irrelevant', cols: 1, rows: 1 } }) - listener(null, { requestId: undefined, snapshot: null }) + listener(mainWindowIpcEvent, { requestId: undefined, snapshot: null }) let resolved = false void pending.then(() => { @@ -237,7 +242,10 @@ describe('registerPtyHandlers', () => { await new Promise((r) => setTimeout(r, 0)) expect(resolved).toBe(false) - listener(null, { requestId: realRequestId, snapshot: { data: 'ok', cols: 80, rows: 24 } }) + listener(mainWindowIpcEvent, { + requestId: realRequestId, + snapshot: { data: 'ok', cols: 80, rows: 24 } + }) await expect(pending).resolves.toEqual({ data: 'ok', cols: 80, rows: 24 }) }) it('resolves to null and removes the entry when the 750ms timeout fires', async () => { @@ -255,7 +263,7 @@ describe('registerPtyHandlers', () => { const { listener, controller } = setup() const pending = controller.serializeBuffer('pty-bad') const requestId = getSentRequestIds()[0] - listener(null, { requestId, snapshot: { data: 'ok', cols: 'not-a-number' } }) + listener(mainWindowIpcEvent, { requestId, snapshot: { data: 'ok', cols: 'not-a-number' } }) await expect(pending).resolves.toBeNull() }) }) diff --git a/src/main/ipc/pty-codex-account-attribution.test.ts b/src/main/ipc/pty-codex-account-attribution.test.ts index 9b06723e726..353be0f5909 100644 --- a/src/main/ipc/pty-codex-account-attribution.test.ts +++ b/src/main/ipc/pty-codex-account-attribution.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' import { readFileSyncMock, recordCodexPaneAccountMock, forgetCodexPaneAccountMock } from './pty-ipc-mock-registry' -import { TEST_CODEX_HOME, TEST_CODEX_AUTH_JSON } from './pty-ipc-test-constants' +import { TEST_CODEX_HOME, TEST_CODEX_AUTH_JSON, TEST_MANAGED_ROOT } from './pty-ipc-test-constants' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { registerPtyHandlers, setLocalPtyProvider } from './pty' @@ -52,6 +53,12 @@ vi.mock('../codex/codex-state-db-backfill-recovery', () => import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) ) +const MANAGED_ORIGIN_HOME = join(TEST_MANAGED_ROOT, 'origin', 'home') +const MANAGED_CURRENT_HOME = join(TEST_MANAGED_ROOT, 'current', 'home') +const MANAGED_SHARED_HOME = join(TEST_MANAGED_ROOT, 'shared-mirror', 'home') +const ORIGIN_ROLLOUT = join(MANAGED_ORIGIN_HOME, 'sessions', '2026', '07', '20', 'rollout-a.jsonl') +const SHARED_ROLLOUT = join(MANAGED_SHARED_HOME, 'sessions', '2026', '07', '20', 'rollout-a.jsonl') + describe('registerPtyHandlers', () => { const { handlers, mainWindow } = setupPtyIpcSuite() @@ -145,15 +152,15 @@ describe('registerPtyHandlers', () => { } return '' }) - const resolveHome = vi.fn(() => '/managed/current/home') + const resolveHome = vi.fn(() => MANAGED_CURRENT_HOME) registerPtyHandlers( mainWindow as never, undefined, resolveHome, (() => ({ codexManagedAccounts: [ - { id: 'account-a', managedHomePath: '/managed/origin/home' }, - { id: 'account-b', managedHomePath: '/managed/current/home' } + { id: 'account-a', managedHomePath: MANAGED_ORIGIN_HOME }, + { id: 'account-b', managedHomePath: MANAGED_CURRENT_HOME } ] })) as never, undefined, @@ -161,7 +168,7 @@ describe('registerPtyHandlers', () => { { prepareCodexSessionResume: async () => ({ outcome: 'resume' as const, - codexHomePath: '/managed/origin/home' + codexHomePath: MANAGED_ORIGIN_HOME }) } ) @@ -175,7 +182,7 @@ describe('registerPtyHandlers', () => { resumeProviderSession: { key: 'session_id', id: 'session-a', - transcriptPath: '/managed/origin/home/sessions/2026/07/20/rollout-a.jsonl' + transcriptPath: ORIGIN_ROLLOUT } }) const rejection = expect(launch).rejects.toThrow( @@ -203,21 +210,21 @@ describe('registerPtyHandlers', () => { const getSettings = vi.fn().mockReturnValue({ activeCodexManagedAccountId: 'account-b', codexManagedAccounts: [ - { id: 'account-a', managedHomePath: '/managed/origin/home' }, - { id: 'account-b', managedHomePath: '/managed/current/home' } + { id: 'account-a', managedHomePath: MANAGED_ORIGIN_HOME }, + { id: 'account-b', managedHomePath: MANAGED_CURRENT_HOME } ] }) registerPtyHandlers( mainWindow as never, undefined, - vi.fn(() => '/managed/current/home'), + vi.fn(() => MANAGED_CURRENT_HOME), getSettings as never, undefined, undefined, { prepareCodexSessionResume: async () => ({ outcome: 'resume' as const, - codexHomePath: '/managed/origin/home' + codexHomePath: MANAGED_ORIGIN_HOME }) } ) @@ -231,7 +238,7 @@ describe('registerPtyHandlers', () => { resumeProviderSession: { key: 'session_id', id: 'session-a', - transcriptPath: '/managed/origin/home/sessions/2026/07/20/rollout-a.jsonl' + transcriptPath: ORIGIN_ROLLOUT } }) @@ -240,7 +247,7 @@ describe('registerPtyHandlers', () => { expect(recordCodexPaneAccountMock.mock.calls).toEqual([ ['pty-resumed', { selectionKey: 'host', accountId: 'account-a', homeRoute: 'account-home' }] ]) - expect(readFileSyncMock).toHaveBeenCalledWith('/managed/origin/home/auth.json', 'utf8') + expect(readFileSyncMock).toHaveBeenCalledWith(join(MANAGED_ORIGIN_HOME, 'auth.json'), 'utf8') expect(forgetCodexPaneAccountMock).not.toHaveBeenCalled() }) it('leaves a resumed Codex pane unattributed when no account owns its home', async () => { @@ -257,19 +264,19 @@ describe('registerPtyHandlers', () => { } as never) const getSettings = vi.fn().mockReturnValue({ activeCodexManagedAccountId: 'account-b', - codexManagedAccounts: [{ id: 'account-b', managedHomePath: '/managed/current/home' }] + codexManagedAccounts: [{ id: 'account-b', managedHomePath: MANAGED_CURRENT_HOME }] }) registerPtyHandlers( mainWindow as never, undefined, - vi.fn(() => '/managed/current/home'), + vi.fn(() => MANAGED_CURRENT_HOME), getSettings as never, undefined, undefined, { prepareCodexSessionResume: async () => ({ outcome: 'resume' as const, - codexHomePath: '/managed/shared-mirror/home' + codexHomePath: MANAGED_SHARED_HOME }) } ) @@ -282,7 +289,7 @@ describe('registerPtyHandlers', () => { resumeProviderSession: { key: 'session_id', id: 'session-a', - transcriptPath: '/managed/shared-mirror/home/sessions/2026/07/20/rollout-a.jsonl' + transcriptPath: SHARED_ROLLOUT } }) @@ -320,22 +327,22 @@ describe('registerPtyHandlers', () => { const getSettings = vi.fn().mockReturnValue({ activeCodexManagedAccountId: 'account-b', codexManagedAccounts: [ - { id: 'account-a', managedHomePath: '/managed/origin/home' }, - { id: 'account-b', managedHomePath: '/managed/current/home' } + { id: 'account-a', managedHomePath: MANAGED_ORIGIN_HOME }, + { id: 'account-b', managedHomePath: MANAGED_CURRENT_HOME } ] }) handlers.clear() registerPtyHandlers( mainWindow as never, runtime as never, - vi.fn(() => '/managed/current/home'), + vi.fn(() => MANAGED_CURRENT_HOME), getSettings as never, undefined, undefined, { prepareCodexSessionResume: async () => ({ outcome: 'resume' as const, - codexHomePath: '/managed/origin/home' + codexHomePath: MANAGED_ORIGIN_HOME }) } ) @@ -351,7 +358,7 @@ describe('registerPtyHandlers', () => { resumeProviderSession: { key: 'session_id', id: 'session-a', - transcriptPath: '/managed/origin/home/sessions/2026/07/20/rollout-a.jsonl' + transcriptPath: ORIGIN_ROLLOUT } }) @@ -361,7 +368,7 @@ describe('registerPtyHandlers', () => { { selectionKey: 'host', accountId: 'account-a', homeRoute: 'account-home' } ] ]) - expect(readFileSyncMock).toHaveBeenCalledWith('/managed/origin/home/auth.json', 'utf8') + expect(readFileSyncMock).toHaveBeenCalledWith(join(MANAGED_ORIGIN_HOME, 'auth.json'), 'utf8') expect(forgetCodexPaneAccountMock).not.toHaveBeenCalled() }) it('leaves a runtime-controller resumed Codex pane unattributed when no account owns its home', async () => { @@ -389,10 +396,10 @@ describe('registerPtyHandlers', () => { } const getSettings = vi.fn().mockReturnValue({ activeCodexManagedAccountId: 'account-b', - codexManagedAccounts: [{ id: 'account-b', managedHomePath: '/managed/current/home' }] + codexManagedAccounts: [{ id: 'account-b', managedHomePath: MANAGED_CURRENT_HOME }] }) handlers.clear() - const resolveHome = vi.fn(() => '/managed/shared-mirror/home') + const resolveHome = vi.fn(() => MANAGED_SHARED_HOME) registerPtyHandlers( mainWindow as never, runtime as never, @@ -403,7 +410,7 @@ describe('registerPtyHandlers', () => { { prepareCodexSessionResume: async () => ({ outcome: 'resume' as const, - codexHomePath: '/managed/shared-mirror/home', + codexHomePath: MANAGED_SHARED_HOME, reconcileSharedRuntimeAuth: true }) } @@ -419,7 +426,7 @@ describe('registerPtyHandlers', () => { resumeProviderSession: { key: 'session_id', id: 'session-a', - transcriptPath: '/managed/shared-mirror/home/sessions/2026/07/20/rollout-a.jsonl' + transcriptPath: SHARED_ROLLOUT } }) diff --git a/src/main/ipc/pty-controller-spawn-admission.test.ts b/src/main/ipc/pty-controller-spawn-admission.test.ts index ae4af67434c..65f5ba565c9 100644 --- a/src/main/ipc/pty-controller-spawn-admission.test.ts +++ b/src/main/ipc/pty-controller-spawn-admission.test.ts @@ -1,4 +1,9 @@ import { describe, expect, it, vi } from 'vitest' +import { + openCodeBuildPtyEnvMock, + openCodeClearPtyMock, + piClearPtyMock +} from './pty-ipc-mock-registry' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' import { OrcaRuntimeService } from '../runtime/orca-runtime' @@ -227,6 +232,74 @@ describe('registerPtyHandlers', () => { expect.objectContaining({ sessionId: 'serve-stable-session', isNewSession: true }) ) }) + it('clears a fresh runtime session when host-env setup fails', async () => { + const provider = createAgentClaimProvider({ spawn: vi.fn() }) + setLocalPtyProvider(provider as never) + openCodeBuildPtyEnvMock.mockImplementationOnce(() => { + throw new Error('host env failed') + }) + const controller = registerAgentClaimController() + + await expect( + controller.spawn({ + cols: 80, + rows: 24, + cwd: '/tmp/runtime-host-env-failure', + worktreeId: 'repo::/tmp/runtime-host-env-failure' + }) + ).rejects.toThrow('host env failed') + + expect(provider.spawn).not.toHaveBeenCalled() + expect(openCodeClearPtyMock).toHaveBeenCalledOnce() + expect(piClearPtyMock).toHaveBeenCalledOnce() + }) + it('preserves caller-owned runtime state when host-env setup fails', async () => { + const provider = createAgentClaimProvider({ spawn: vi.fn() }) + setLocalPtyProvider(provider as never) + openCodeBuildPtyEnvMock.mockImplementationOnce(() => { + throw new Error('host env failed') + }) + const controller = registerAgentClaimController() + + await expect( + controller.spawn({ + cols: 80, + rows: 24, + sessionId: 'caller-owned-session', + isNewSession: true + }) + ).rejects.toThrow('host env failed') + + expect(provider.spawn).not.toHaveBeenCalled() + expect(openCodeClearPtyMock).not.toHaveBeenCalled() + expect(piClearPtyMock).not.toHaveBeenCalled() + }) + it('preserves operation-owned runtime state when retry host-env setup fails', async () => { + const physicalSpawn = vi.fn(async (options: { sessionId?: string }) => ({ + id: options.sessionId ?? 'unexpected-spawn' + })) + const provider = createAgentClaimProvider({ spawn: physicalSpawn }) + setLocalPtyProvider(provider as never) + const controller = registerAgentClaimController() + const request = { + cols: 80, + rows: 24, + worktreeId: 'repo::/tmp/operation-retry', + agentSessionCreateOperationId: 'a'.repeat(43) + } + await controller.spawn(request) + openCodeClearPtyMock.mockClear() + piClearPtyMock.mockClear() + openCodeBuildPtyEnvMock.mockImplementationOnce(() => { + throw new Error('host env failed') + }) + + await expect(controller.spawn(request)).rejects.toThrow('host env failed') + + expect(physicalSpawn).toHaveBeenCalledOnce() + expect(openCodeClearPtyMock).not.toHaveBeenCalled() + expect(piClearPtyMock).not.toHaveBeenCalled() + }) it('adopts a daemon owner recovered from provider listing before claimed ensure', async () => { const owner: AgentSessionOwnerBinding = { claim: recoveredAgentClaim, diff --git a/src/main/ipc/pty-daemon-controller-teardown.test.ts b/src/main/ipc/pty-daemon-controller-teardown.test.ts index 73ae897b8b3..63786b361fb 100644 --- a/src/main/ipc/pty-daemon-controller-teardown.test.ts +++ b/src/main/ipc/pty-daemon-controller-teardown.test.ts @@ -153,6 +153,62 @@ describe('registerPtyHandlers', () => { }) expect(runtime.onPtyExit).not.toHaveBeenCalled() }) + it('bounds keep-history inventory polls by the settlement deadline', async () => { + vi.useFakeTimers() + try { + const listProcesses = vi.fn(async (_opts?: { deadlineMs?: number }) => []) + setLocalPtyProvider({ + spawn: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(async () => undefined), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses, + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + const runtime = { + setPtyController: vi.fn(), + onPtyExit: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + stopAndWait: ( + ptyId: string, + opts?: { keepHistory?: boolean; deadlineMs?: number } + ) => Promise + } + const callerDeadlineMs = Date.now() + 5_000 + const stopPromise = controller.stopAndWait('local-pty', { + keepHistory: true, + deadlineMs: callerDeadlineMs + }) + + await vi.advanceTimersByTimeAsync(1_000) + await expect(stopPromise).resolves.toBe(true) + + expect(listProcesses.mock.calls[0]?.[0]).toEqual({ deadlineMs: callerDeadlineMs }) + const settlementDeadlines = listProcesses.mock.calls + .slice(1) + .map(([opts]) => opts?.deadlineMs) + expect(settlementDeadlines.length).toBeGreaterThan(0) + expect(new Set(settlementDeadlines)).toEqual(new Set([callerDeadlineMs - 4_000])) + } finally { + vi.useRealTimers() + } + }) it('uses inventory, not an incarnation-less exit, to confirm the current PTY stopped', async () => { const exitListeners = new Set< (payload: { id: string; code: number; incarnationId?: string }) => void diff --git a/src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts b/src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts index ae4de312c55..c99c7f7b93d 100644 --- a/src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts +++ b/src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts @@ -289,7 +289,9 @@ describe('registerPtyHandlers', () => { await Promise.resolve() expect(runtime.onPtyExit).toHaveBeenCalledTimes(1) - expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0, undefined, undefined) + expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0, undefined, { + providerExitObserved: true + }) expect( mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit') ).toEqual([['pty:exit', { id: 'local-pty', code: 0 }]]) @@ -342,7 +344,9 @@ describe('registerPtyHandlers', () => { await expect(stopPromise).resolves.toBe(true) expect(runtime.onPtyExit).toHaveBeenCalledTimes(1) - expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0, undefined, undefined) + expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0, undefined, { + providerExitObserved: true + }) expect( mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit') ).toEqual([['pty:exit', { id: 'local-pty', code: 0 }]]) diff --git a/src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts b/src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts index f323039191f..bfacfc96935 100644 --- a/src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts +++ b/src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts @@ -198,7 +198,7 @@ describe('registerPtyHandlers', () => { }) expect(warnSpy).toHaveBeenCalledWith( '[pty] hidden-delivery gate is dropping bytes for a visible/active pty', - expect.objectContaining({ id: result.id, visible: true }) + expect.objectContaining({ id: redactPtyIdForDiagnostics(result.id), visible: true }) ) // Unhiding resolves the contradiction. diff --git a/src/main/ipc/pty-ipc-producer-flow-control.test.ts b/src/main/ipc/pty-ipc-producer-flow-control.test.ts index bfc65403daa..8b97751dba7 100644 --- a/src/main/ipc/pty-ipc-producer-flow-control.test.ts +++ b/src/main/ipc/pty-ipc-producer-flow-control.test.ts @@ -206,6 +206,24 @@ describe('registerPtyHandlers', () => { vi.useRealTimers() } }) + it('releases a paused producer when handlers re-register for a replacement window', () => { + vi.useFakeTimers() + try { + const provider = installObservableDaemonTestProvider() + registerPtyHandlers(mainWindow as never) + mainWindow.webContents.send.mockClear() + + provider.emitData('flood-pty', 'x'.repeat(320 * 1024)) + expect(provider.pauseProducer).toHaveBeenCalledTimes(1) + + // Re-registration hands delivery to a new window; the outgoing session must run its real + // lifecycle reset before the bridge is neutralized or this shell stays paused forever. + registerPtyHandlers(mainWindow as never) + expect(provider.resumeProducer).toHaveBeenCalledWith('flood-pty') + } finally { + vi.useRealTimers() + } + }) it('fences synchronous producer data and duplicate exit while releasing an exiting PTY', () => { vi.useFakeTimers() try { diff --git a/src/main/ipc/pty-ipc-suite-environment.ts b/src/main/ipc/pty-ipc-suite-environment.ts index 5e5a9c819f7..95c7dfe3eea 100644 --- a/src/main/ipc/pty-ipc-suite-environment.ts +++ b/src/main/ipc/pty-ipc-suite-environment.ts @@ -54,7 +54,15 @@ import { __resetShellStartupEnvCache } from '../pty/shell-startup-env' import { _resetWslCachesForTests } from '../wsl' /** The mocked webContents each suite asserts sends against. */ -export type PtyIpcTestWebContents = { on: Mock; send: Mock; removeListener: Mock } +export type PtyIpcTestWebContents = { + on: Mock + send: Mock + removeListener: Mock + // Why real Electron has this: webContents can be destroyed a beat before its BrowserWindow + // during close, so renderer-liveness guards check both. Omitting it here let those guards + // pass vacuously in every suite (STA-2373 / STA-5373). + isDestroyed: Mock +} /** The mocked BrowserWindow handed to registerPtyHandlers. */ export type PtyIpcTestMainWindow = { @@ -83,12 +91,18 @@ export function createPtyIpcSuiteEnvironment(): PtyIpcSuiteEnvironment { webContents: { on: vi.fn(), send: vi.fn(), - removeListener: vi.fn() + removeListener: vi.fn(), + isDestroyed: vi.fn(() => false) } } const mainWindowIpcEvent = { sender: mainWindow.webContents } const foreignWindowIpcEvent = { - sender: { on: vi.fn(), send: vi.fn(), removeListener: vi.fn() } + sender: { + on: vi.fn(), + send: vi.fn(), + removeListener: vi.fn(), + isDestroyed: vi.fn(() => false) + } } const envScope = createPtyIpcProcessEnvScope() @@ -155,6 +169,10 @@ export function createPtyIpcSuiteEnvironment(): PtyIpcSuiteEnvironment { mainWindow.webContents.on.mockReset() mainWindow.webContents.send.mockReset() mainWindow.webContents.removeListener.mockReset() + // Why re-stub, not just reset: a bare mockReset returns undefined, which reads as "alive" + // by accident rather than by intent, and hides a test that forgot to restore liveness. + mainWindow.webContents.isDestroyed.mockReset() + mainWindow.webContents.isDestroyed.mockReturnValue(false) // Why: hidden-delivery gate state is module-level (PTY-keyed), so tests must not leak hidden bits across cases. _resetHiddenRendererPtyDeliveryGateForTest() __resetShellStartupEnvCache() diff --git a/src/main/ipc/pty-ipc-test-constants.ts b/src/main/ipc/pty-ipc-test-constants.ts index 003b5165be0..e3eae546ca6 100644 --- a/src/main/ipc/pty-ipc-test-constants.ts +++ b/src/main/ipc/pty-ipc-test-constants.ts @@ -13,6 +13,7 @@ export type PlatformGatedTest = ( export const isWindowsHost = process.platform === 'win32' export const posixOnlyIt: PlatformGatedTest = isWindowsHost ? it.skip : it +export const TEST_MANAGED_ROOT = isWindowsHost ? 'C:\\managed' : '/managed' export const BUNDLED_RESOURCES_PATH = join('/tmp', 'orca-bundled-resources') // Why: this suite forces darwin before every test, including on Linux CI. export const BUNDLED_CLI_PATH = getBundledLauncherPath('darwin', BUNDLED_RESOURCES_PATH) as string diff --git a/src/main/ipc/pty-listener-teardown-and-orphans.test.ts b/src/main/ipc/pty-listener-teardown-and-orphans.test.ts index 46b15d86bcb..ae749f8dbe0 100644 --- a/src/main/ipc/pty-listener-teardown-and-orphans.test.ts +++ b/src/main/ipc/pty-listener-teardown-and-orphans.test.ts @@ -440,6 +440,7 @@ describe('registerPtyHandlers', () => { expect(killSpy).toHaveBeenCalled() expect(runtime.onPtyExit).toHaveBeenCalledWith(spawnResult.id, -1, spawnResult.incarnationId, { + providerExitObserved: true, cause: { kind: 'unknown', reason: 'stop_unverified' } }) const listed = await getLocalPtyProvider().listProcesses() diff --git a/src/main/ipc/pty-pane-claim-arbitration.test.ts b/src/main/ipc/pty-pane-claim-arbitration.test.ts index 8ab7beca71c..f4f57fc6b0b 100644 --- a/src/main/ipc/pty-pane-claim-arbitration.test.ts +++ b/src/main/ipc/pty-pane-claim-arbitration.test.ts @@ -179,12 +179,19 @@ describe('registerPtyHandlers', () => { } }): Promise<{ id: string }> } + let exitCallback: ((event: { exitCode: number }) => void) | undefined + const killSpy = vi.fn(() => { + exitCallback?.({ exitCode: 0 }) + }) const proc = { onData: vi.fn(), - onExit: vi.fn(), + onExit: vi.fn((cb) => { + exitCallback = cb + return { dispose: () => {} } + }), write: vi.fn(), resize: vi.fn(), - kill: vi.fn(), + kill: killSpy, process: 'zsh', pid: 12345 } @@ -239,7 +246,7 @@ describe('registerPtyHandlers', () => { expect(store.persistPtyBinding).toHaveBeenCalledWith( expect.objectContaining({ expectedSourceBinding }) ) - expect(proc.kill).toHaveBeenCalledOnce() + expect(killSpy).toHaveBeenCalledOnce() }) it('reports lower-owner commit before rejecting an early-exited runtime incarnation', async () => { const persistPtyBinding = vi.fn() @@ -498,9 +505,7 @@ describe('registerPtyHandlers', () => { ORCA_WORKTREE_ID: 'repo-1::/tmp' } }) as Promise<{ id: string }> - await Promise.resolve() - - expect(providerSpawn).toHaveBeenCalledTimes(1) + await vi.waitFor(() => expect(providerSpawn).toHaveBeenCalledTimes(1)) resolveSpawn({ id: 'pty-shared' }) await expect(Promise.all([runtimeSpawn, rendererSpawn])).resolves.toEqual([ { id: 'pty-shared' }, diff --git a/src/main/ipc/pty-pane-materialization-race.test.ts b/src/main/ipc/pty-pane-materialization-race.test.ts index bce7b448d3c..ee1bc16c75b 100644 --- a/src/main/ipc/pty-pane-materialization-race.test.ts +++ b/src/main/ipc/pty-pane-materialization-race.test.ts @@ -131,6 +131,124 @@ describe('registerPtyHandlers', () => { await expect(mounted).resolves.toMatchObject({ id: 'pty-after-runtime-claim' }) expect(providerSpawn).toHaveBeenCalledOnce() }) + it('adopts a runtime reservation created during renderer preflight', async () => { + type RuntimeSpawnController = { + spawn(args: { + cols: number + rows: number + command?: string + sessionId?: string + tabId: string + leafId: string + env: Record + }): Promise<{ id: string }> + } + let resolveProviderSpawn!: (result: { id: string }) => void + const providerSpawn = vi.fn( + () => + new Promise<{ id: string }>((resolve) => { + resolveProviderSpawn = resolve + }) + ) + setLocalPtyProvider({ + spawn: providerSpawn, + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + let controller: RuntimeSpawnController | null = null + const runtime = { + setPtyController: vi.fn((value) => { + controller = value + }), + createPreAllocatedTerminalHandle: vi.fn(() => 'term-concurrent'), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + let releaseAuth!: (value: { + configDir: string + envPatch: Record + stripAuthEnv: false + provenance: string + }) => void + const prepareClaudeAuth = vi.fn( + () => + new Promise<{ + configDir: string + envPatch: Record + stripAuthEnv: false + provenance: string + }>((resolve) => { + releaseAuth = resolve + }) + ) + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + prepareClaudeAuth + ) + const tabId = 'tab-mid-preflight-runtime' + const leafId = '55555555-5555-4555-8555-555555555555' + const paneKey = makePaneKey(tabId, leafId) + const paneArgs = { + cols: 80, + rows: 24, + sessionId: 'pty-runtime-reservation', + tabId, + leafId, + env: { ORCA_PANE_KEY: paneKey } + } + + const rendererSpawn = handlers.get('pty:spawn')!(null, { + ...paneArgs, + command: 'claude' + }) + await vi.waitFor(() => expect(prepareClaudeAuth).toHaveBeenCalledOnce()) + + const runtimeSpawn = (controller as unknown as RuntimeSpawnController).spawn({ + ...paneArgs, + command: 'printf runtime' + }) + await vi.waitFor(() => expect(providerSpawn).toHaveBeenCalledOnce()) + releaseAuth({ + configDir: '/tmp/claude', + envPatch: {}, + stripAuthEnv: false, + provenance: 'managed:test' + }) + await new Promise((resolve) => setImmediate(resolve)) + resolveProviderSpawn({ id: 'pty-runtime-reservation' }) + + await expect(runtimeSpawn).resolves.toEqual({ id: 'pty-runtime-reservation' }) + await expect(rendererSpawn).resolves.toMatchObject({ + id: 'pty-runtime-reservation', + isReattach: true + }) + expect(providerSpawn).toHaveBeenCalledOnce() + await expect( + handlers.get('pty:getSize')!(null, { id: 'pty-runtime-reservation' }) + ).resolves.toEqual({ cols: 80, rows: 24 }) + }) it('reuses renderer spawn when runtime materialization starts for the same pane', async () => { type RuntimeSpawnController = { spawn(args: { diff --git a/src/main/ipc/pty-renderer-liveness-guard.test.ts b/src/main/ipc/pty-renderer-liveness-guard.test.ts new file mode 100644 index 00000000000..08a925067d8 --- /dev/null +++ b/src/main/ipc/pty-renderer-liveness-guard.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it, vi } from 'vitest' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' +import { TERMINAL_INPUT_CHUNK_MAX_BYTES } from '../../shared/terminal-input' +import { registerPtyHandlers } from './pty' + +vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) +vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) +vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) +vi.mock('node:child_process', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) +) +vi.mock('../opencode/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../mimo/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../agent-hooks/server', () => + import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../pi/titlebar-extension-service', () => + import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock())) +vi.mock('../wsl', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../telemetry/client', () => + import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../telemetry/classify-error', () => + import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../cli/linux-terminal-orca-cli-shim', () => + import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../memory/pty-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => + import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) +) +vi.mock('../codex/codex-pane-account-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) +) +vi.mock('../codex/codex-state-db-backfill-recovery', () => + import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) + +/** + * A destroyed WebContents under a still-alive BrowserWindow is the STA-2373 state: + * `webContents` can die a beat before its window during close (see createMainWindow.ts + * forceRepaint), and `webContents.send` throws `Object has been destroyed` there. An + * uncaught throw in the main process is fatal — the app exits with every terminal in it, + * clean exit code and no crash report — so both `pty:writeUnavailable` senders must check + * the WebContents, not just the window. #15927 briefly reduced these to a window-only + * check; these tests fail against that regression (STA-5373). + */ +describe('pty:writeUnavailable renderer-liveness guard', () => { + const { + handlers, + mainWindow, + mainWindowIpcEvent, + installDaemonTestProvider, + getPtyWriteListener + } = setupPtyIpcSuite() + + async function spawnDaemonPty(): Promise { + const result = (await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })) as { + id: string + } + mainWindow.webContents.send.mockClear() + return result.id + } + + describe('the per-write sender', () => { + it('asks the renderer to remount while the WebContents is alive', async () => { + installDaemonTestProvider({ + write: vi.fn(() => { + throw new PtyWriteUnavailableError('daemon generation lost') + }) + }) + registerPtyHandlers(mainWindow as never) + const id = await spawnDaemonPty() + + getPtyWriteListener()(mainWindowIpcEvent, { id, data: 'x' }) + + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:writeUnavailable', { id }) + }) + + // Why a chunked write: the single-chunk path is already fenced by + // isPtyWriteEventFromMainWindow, which rejects the event outright once the WebContents + // is gone. A multi-chunk write awaits a macrotask between chunks, so the sender check + // passes while alive and the renderer dies mid-write — the only way this sender is + // reached with a destroyed WebContents, and what makes its own guard load-bearing. + it('stays silent when the WebContents dies mid-write under a live window', async () => { + let writeCount = 0 + installDaemonTestProvider({ + write: vi.fn(() => { + writeCount += 1 + if (writeCount < 2) { + return + } + mainWindow.webContents.isDestroyed.mockReturnValue(true) + throw new PtyWriteUnavailableError('daemon generation lost') + }) + }) + registerPtyHandlers(mainWindow as never) + const id = await spawnDaemonPty() + + getPtyWriteListener()(mainWindowIpcEvent, { + id, + data: 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 3) + }) + // The chunk loop yields a macrotask between chunks; wait for the failing one. + await vi.waitFor(() => expect(writeCount).toBeGreaterThan(1)) + + expect(mainWindow.webContents.isDestroyed).toHaveBeenCalled() + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith( + 'pty:writeUnavailable', + expect.anything() + ) + }) + }) + + describe('the daemon-death fan-out', () => { + /** Registers handlers and returns the fan-out callback the provider hands the session. */ + function installProviderCapturingFanout(): (payload: { id: string }) => void { + let fanout: ((payload: { id: string }) => void) | null = null + installDaemonTestProvider({ + onWriteUnavailable: vi.fn((callback: (payload: { id: string }) => void) => { + fanout = callback + return () => {} + }) + }) + registerPtyHandlers(mainWindow as never) + if (!fanout) { + throw new Error('registerPtyHandlers did not subscribe to onWriteUnavailable') + } + return fanout + } + + it('remounts every signalled pane while the WebContents is alive', () => { + const fanout = installProviderCapturingFanout() + + fanout({ id: 'pane-1' }) + + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:writeUnavailable', { + id: 'pane-1' + }) + }) + + it('stays silent when the WebContents is destroyed under a live window', () => { + const fanout = installProviderCapturingFanout() + mainWindow.webContents.isDestroyed.mockReturnValue(true) + + fanout({ id: 'pane-1' }) + + expect(mainWindow.webContents.isDestroyed).toHaveBeenCalled() + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith( + 'pty:writeUnavailable', + expect.anything() + ) + }) + }) +}) diff --git a/src/main/ipc/pty-renderer-surface.ts b/src/main/ipc/pty-renderer-surface.ts deleted file mode 100644 index 09fff75e681..00000000000 --- a/src/main/ipc/pty-renderer-surface.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { BrowserWindow, WebContents } from 'electron' - -/** - * The renderer surface the PTY handlers talk to, which may not exist. - * - * Why: `orca serve` — and a future Node-only backend — run the same PTY handlers - * with no window. That used to be faked: `registerHeadlessPtyRuntime` built a - * `BrowserWindow` whose `isDestroyed()` returned true and whose `webContents.send` - * was a no-op, purely to satisfy the type — the "looks fine, silently lies" shape - * this codebase rejects elsewhere, and what forced an `electron` value import into a - * path that needs none. It now passes `null`. - * - * An absent renderer is semantically identical to a destroyed one — every call site - * already guards on `isDestroyed()` and skips — so model it as `null` and say so. - */ - -/** True when there is no renderer, or it is gone. Callers already treat these the same. */ -export function isRendererGone(window: BrowserWindow | null): boolean { - return window === null || window.isDestroyed() -} - -/** Send to the renderer if one is listening. Absent renderer drops the message, as a destroyed one does. */ -export function sendToRenderer( - window: BrowserWindow | null, - channel: string, - payload?: unknown -): void { - if (isRendererGone(window)) { - return - } - window!.webContents.send(channel, payload) -} - -/** The renderer's WebContents, or null. Used for identity checks and listener registration. */ -export function rendererWebContents(window: BrowserWindow | null): WebContents | null { - return isRendererGone(window) ? null : window!.webContents -} diff --git a/src/main/ipc/pty-runtime-kill-and-exit.test.ts b/src/main/ipc/pty-runtime-kill-and-exit.test.ts index 478212b6c15..2200b2b6043 100644 --- a/src/main/ipc/pty-runtime-kill-and-exit.test.ts +++ b/src/main/ipc/pty-runtime-kill-and-exit.test.ts @@ -287,7 +287,9 @@ describe('registerPtyHandlers', () => { await handlers.get('pty:kill')!(null, { id: 'local-pty' }) expect(runtime.onPtyExit).toHaveBeenCalledTimes(1) - expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0, undefined, undefined) + expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0, undefined, { + providerExitObserved: true + }) expect(mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit')).toEqual( [['pty:exit', { id: 'local-pty', code: 0 }]] ) @@ -529,7 +531,9 @@ describe('registerPtyHandlers', () => { seq: 13, rawLength: 'daemon output'.length }) - expect(runtime.onPtyExit).toHaveBeenCalledWith(result.id, 0, undefined, undefined) + expect(runtime.onPtyExit).toHaveBeenCalledWith(result.id, 0, undefined, { + providerExitObserved: true + }) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:exit', { id: result.id, code: 0 diff --git a/src/main/ipc/pty-serializer-settlement-mapping.test.ts b/src/main/ipc/pty-serializer-settlement-mapping.test.ts index a203af2743e..7e526ea317e 100644 --- a/src/main/ipc/pty-serializer-settlement-mapping.test.ts +++ b/src/main/ipc/pty-serializer-settlement-mapping.test.ts @@ -408,6 +408,7 @@ describe('registerPtyHandlers', () => { const destroyedListeners: (() => void)[] = [] const sender = { id: 42, + isDestroyed: () => false, once: vi.fn((event: string, listener: () => void) => { if (event === 'destroyed') { destroyedListeners.push(listener) @@ -422,6 +423,20 @@ describe('registerPtyHandlers', () => { destroyedListeners[0]() expect(hasPendingRendererSerializerForPaneKey(paneKey)).toBe(false) }) + it('does not retain a serializer declaration from an already-destroyed renderer', async () => { + registerPtyHandlers(mainWindow as never) + const paneKey = makePaneKey('tab-dead', '77777777-7777-4777-8777-777777777777') + const sender = { + id: 43, + isDestroyed: () => true, + once: vi.fn() + } + + await handlers.get('pty:declarePendingPaneSerializer')!({ sender }, { paneKey }) + + expect(hasPendingRendererSerializerForPaneKey(paneKey)).toBe(false) + expect(sender.once).not.toHaveBeenCalled() + }) it('ignores renderer-provided ORCA_TERMINAL_HANDLE for local PTY spawns', async () => { const runtime = { setPtyController: vi.fn(), diff --git a/src/main/ipc/pty-session-liveness-and-ownership.test.ts b/src/main/ipc/pty-session-liveness-and-ownership.test.ts index e7377dbe927..97c0e614933 100644 --- a/src/main/ipc/pty-session-liveness-and-ownership.test.ts +++ b/src/main/ipc/pty-session-liveness-and-ownership.test.ts @@ -91,6 +91,21 @@ describe('registerPtyHandlers', () => { ).resolves.toBe(null) expect(hasPty).not.toHaveBeenCalled() }) + it.each(['remote:env-1@@terminal-1', 'ssh:missing-host@@pty-1'])( + 'never routes process inspection for %s to the local provider', + async (id) => { + const inspectProcess = vi.fn() + registerPtyHandlers(mainWindow as never) + setLocalPtyProvider({ inspectProcess } as never) + + await expect(handlers.get('pty:inspectProcess')!(null, { id })).resolves.toEqual({ + foregroundProcess: null, + hasChildProcesses: false, + unavailable: true + }) + expect(inspectProcess).not.toHaveBeenCalled() + } + ) it('lists duplicate SSH relay session ids as distinct app sessions', async () => { registerPtyHandlers(mainWindow as never) const shutdownA = vi.fn(async () => undefined) diff --git a/src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts b/src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts index 8dd16f4a63c..56169b208c2 100644 --- a/src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts +++ b/src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' import { spawnMock } from './pty-ipc-mock-registry' -import { posixOnlyIt } from './pty-ipc-test-constants' +import { posixOnlyIt, TEST_MANAGED_ROOT } from './pty-ipc-test-constants' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { prepareCodexSessionResume } from '../codex/codex-session-resume-preparation' import { registerPtyHandlers, setLocalPtyProvider, type PrepareCodexSessionResume } from './pty' @@ -55,9 +56,16 @@ describe('registerPtyHandlers', () => { describe('spawn environment', () => { describe('unverifiable Codex resume provenance', () => { const RESUME_SESSION_ID = '019f81b9-19a9-7651-a8d1-352d9420bd11' - const ORIGIN_HOME = '/managed/origin/home' - const OTHER_HOME = '/managed/other/home' - const ORIGIN_ROLLOUT = `${ORIGIN_HOME}/sessions/2026/07/20/rollout-2026-07-20T12-00-00-${RESUME_SESSION_ID}.jsonl` + const ORIGIN_HOME = join(TEST_MANAGED_ROOT, 'origin', 'home') + const OTHER_HOME = join(TEST_MANAGED_ROOT, 'other', 'home') + const ORIGIN_ROLLOUT = join( + ORIGIN_HOME, + 'sessions', + '2026', + '07', + '20', + `rollout-2026-07-20T12-00-00-${RESUME_SESSION_ID}.jsonl` + ) // Why: main's real provenance rule via the same prepareCodexSessionResume that // index.ts calls, so the outcome wiring is exercised rather than restated. This diff --git a/src/main/ipc/pty-spawn-runtime-handle-binding.test.ts b/src/main/ipc/pty-spawn-runtime-handle-binding.test.ts index 219b3f94d9d..6e5e57f3ad6 100644 --- a/src/main/ipc/pty-spawn-runtime-handle-binding.test.ts +++ b/src/main/ipc/pty-spawn-runtime-handle-binding.test.ts @@ -5,6 +5,12 @@ import { createHash } from 'node:crypto' import { delimiter } from 'node:path' import { makePaneKey } from '../../shared/stable-pane-id' import { OrcaRuntimeService } from '../runtime/orca-runtime' +import { ClaudeAgentTeamsService } from '../runtime/claude-agent-teams-service' +import { + clearPaneSpawnReservation, + makePaneSpawnReservationKey, + reservePaneSpawn +} from './pty/pane/spawn-reservation' import { registerPtyHandlers, registerSshPtyProvider } from './pty' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) @@ -500,4 +506,121 @@ describe('registerPtyHandlers', () => { id: spawned.id }) }) + it('releases the Agent Teams leader of a spawn that loses the pane mid-preflight', async () => { + const leafId = '22222222-2222-4222-8222-222222222222' + const teams = new ClaudeAgentTeamsService() + let handleSeq = 0 + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => `term_agent_teams_${++handleSeq}`), + prepareClaudeAgentTeamsLeaderForHandle: vi.fn( + async (args: { handle: string; baseEnv?: Record }) => + teams.createLaunchEnv({ + leaderHandle: args.handle, + baseEnv: args.baseEnv ?? {}, + shimDir: '/tmp/orca-agent-teams-shim', + shimBin: null + }) + ), + releaseClaudeAgentTeamsLeaderForHandle: vi.fn((handle: string) => { + teams.removeTeamForLeaderHandle(handle) + }), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + getDriver: vi.fn(() => ({ kind: 'host' })), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + + registerPtyHandlers(mainWindow as never, runtime as never) + // Why: no worktreeId, so the reservation key only resolves after env assembly — the + // window where a concurrent winner's reservation appears mid-preflight. + const spawnArgs = { + cols: 80, + rows: 24, + cwd: '/repo', + command: 'claude --teammate-mode auto', + tabId: 'tab-1', + leafId, + env: { ORCA_PANE_KEY: `tab-1:${leafId}`, ORCA_TAB_ID: 'tab-1' }, + launchConfig: { agentCommand: 'claude --teammate-mode auto', agentArgs: '', agentEnv: {} }, + launchAgent: 'claude' + } + const winner = (await handlers.get('pty:spawn')!(mainWindowIpcEvent, spawnArgs)) as { + id: string + } + expect(teams.getActiveTeamCount()).toBe(1) + + const reservationKey = makePaneSpawnReservationKey( + undefined, + undefined, + makePaneKey('tab-1', leafId) + )! + const winnerReservation = reservePaneSpawn(reservationKey) + winnerReservation.resolve({ id: winner.id }) + try { + const loser = await handlers.get('pty:spawn')!(mainWindowIpcEvent, spawnArgs) + expect(loser).toMatchObject({ id: winner.id, isReattach: true }) + } finally { + clearPaneSpawnReservation(reservationKey, winnerReservation) + } + + const loserHandle = runtime.prepareClaudeAgentTeamsLeaderForHandle.mock.calls[1]?.[0].handle + expect(runtime.prepareClaudeAgentTeamsLeaderForHandle).toHaveBeenCalledTimes(2) + expect(runtime.releaseClaudeAgentTeamsLeaderForHandle).toHaveBeenCalledWith(loserHandle) + // The losing request must not grow the team count it can never bind to a PTY. + expect(teams.getActiveTeamCount()).toBe(1) + }) + it('releases the Agent Teams leader when its provider spawn fails', async () => { + const leafId = '33333333-3333-4333-8333-333333333333' + const teams = new ClaudeAgentTeamsService() + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => 'term_agent_teams_failure'), + prepareClaudeAgentTeamsLeaderForHandle: vi.fn( + async (args: { handle: string; baseEnv?: Record }) => + teams.createLaunchEnv({ + leaderHandle: args.handle, + baseEnv: args.baseEnv ?? {}, + shimDir: '/tmp/orca-agent-teams-shim', + shimBin: null + }) + ), + releaseClaudeAgentTeamsLeaderForHandle: vi.fn((handle: string) => { + teams.removeTeamForLeaderHandle(handle) + }), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + getDriver: vi.fn(() => ({ kind: 'host' })), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + spawnMock.mockImplementation(() => { + throw new Error('provider spawn failed') + }) + + registerPtyHandlers(mainWindow as never, runtime as never) + + await expect( + handlers.get('pty:spawn')!(mainWindowIpcEvent, { + cols: 80, + rows: 24, + cwd: '/repo', + command: 'claude --teammate-mode auto', + tabId: 'tab-1', + leafId, + worktreeId: 'wt-1', + env: { ORCA_PANE_KEY: `tab-1:${leafId}`, ORCA_TAB_ID: 'tab-1' }, + launchConfig: { agentCommand: 'claude --teammate-mode auto', agentArgs: '', agentEnv: {} }, + launchAgent: 'claude' + }) + ).rejects.toThrow() + + expect(runtime.releaseClaudeAgentTeamsLeaderForHandle).toHaveBeenCalledWith( + 'term_agent_teams_failure' + ) + expect(teams.getActiveTeamCount()).toBe(0) + }) }) diff --git a/src/main/ipc/pty-startup-barrier-ordering.test.ts b/src/main/ipc/pty-startup-barrier-ordering.test.ts index 95dafaaab8a..dcf8a5d4c44 100644 --- a/src/main/ipc/pty-startup-barrier-ordering.test.ts +++ b/src/main/ipc/pty-startup-barrier-ordering.test.ts @@ -2,26 +2,27 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' +function readRepoSource(relPath: string): string { + return readFileSync(join(process.cwd(), relPath), 'utf8').replace(/\r\n?/g, '\n') +} + describe('PTY startup barrier ordering', () => { it('waits for local startup before resolving the provider for runtime and renderer spawns', () => { - const source = readFileSync(join(process.cwd(), 'src/main/ipc/pty.ts'), 'utf8').replace( - /\r\n?/g, - '\n' - ) - const runtimeSpawnStart = source.indexOf('spawn: async (args) => {') - const runtimeSpawnEnd = source.indexOf(' write:', runtimeSpawnStart) - const runtimeSpawn = source.slice(runtimeSpawnStart, runtimeSpawnEnd) - // Why `ipc.` and not `ipcMain.`: the module registers against an injected surface now - // (pty-host-bindings) so it can run without electron. The ordering this asserts is unchanged. - const rendererSpawnStart = source.indexOf("ipc.handle(\n 'pty:spawn'") - const rendererSpawnEnd = source.indexOf("ipc.handle(\n 'pty:kill'", rendererSpawnStart) - const rendererSpawn = source.slice(rendererSpawnStart, rendererSpawnEnd) + const runtimeSpawn = + readRepoSource('src/main/ipc/pty/runtime/spawn.ts') + + readRepoSource('src/main/ipc/pty/runtime/spawn-early.ts') + + readRepoSource('src/main/ipc/pty/runtime/spawn-preflight.ts') + + const rendererSource = + readRepoSource('src/main/ipc/pty/ipc/spawn.ts') + + readRepoSource('src/main/ipc/pty/ipc/spawn-begin.ts') + + readRepoSource('src/main/ipc/pty/ipc/spawn-preflight.ts') + const rendererSpawnStart = rendererSource.indexOf("ipcMain.handle('pty:spawn'") + const rendererSpawn = rendererSource.slice(rendererSpawnStart) for (const spawnBlock of [runtimeSpawn, rendererSpawn]) { - const barrierIndex = spawnBlock.indexOf( - 'const startupPromise = getLocalPtyStartupPromise(args.connectionId)' - ) - const providerIndex = spawnBlock.indexOf('const provider = getProvider(args.connectionId)') + const barrierIndex = spawnBlock.indexOf('getLocalPtyStartupPromise(args.connectionId)') + const providerIndex = spawnBlock.indexOf('getProvider(args.connectionId)') expect(barrierIndex).toBeGreaterThanOrEqual(0) expect(providerIndex).toBeGreaterThanOrEqual(0) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index cac4538be37..7499cf10a01 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -1,8030 +1,39 @@ -/* eslint-disable max-lines -- Why: PTY IPC is centralized in one main-process module so spawn env scoping, lifecycle cleanup, process inspection, and renderer IPC stay behind one audited boundary. */ -import { join, delimiter } from 'node:path' -import { getAppEnvironment } from '../../shared/app-environment' -import { getPtyIpc, getPtyPower, type PtyPowerSurface } from './pty-host-bindings' -import { isRendererGone, rendererWebContents, sendToRenderer } from './pty-renderer-surface' -import { randomUUID } from 'node:crypto' -import { statSync } from 'node:fs' -import type { BrowserWindow, IpcMainEvent, IpcMainInvokeEvent, WebContents } from 'electron' export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready-bash-rcfile' -import type { OrcaRuntimeService } from '../runtime/orca-runtime' -import type { PtyBindingSourceExpectation, Store } from '../persistence' -import { retireTerminalSurfaceFromPersistence } from '../runtime/mobile-session-terminal-persistence-retirement' -import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../shared/pty-liveness-verdict' -import type { GlobalSettings } from '../../shared/global-settings-types' -import type { TuiAgent } from '../../shared/tui-agent' -import { - LOCAL_EXECUTION_HOST_ID, - toSshExecutionHostId, - type ExecutionHostId -} from '../../shared/execution-host' -import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' -import { terminalOutputBacklogCapChars } from '../../shared/terminal-scrollback-policy' -import type { - PtyDeliveryWriteOff, - PtyRendererDeliveryHealthReply, - PtyRendererDeliveryStateReport -} from '../../shared/pty-renderer-delivery-health' -import { extractHiddenStartupRendererQueryData } from '../../shared/terminal-reply-query-extraction' -import { - INITIAL_MODE_2031_REPLY_SCAN_STATE, - scanMode2031ReplyDecision, - type Mode2031ReplyScanState -} from '../../shared/terminal-color-scheme-protocol' -import { - type PtyMainDeliveryDiagnostics, - type PtyPerPtyDeliveryDiagnostics, - EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS, - createPtyDeliveryBreadcrumbRing, - redactPtyIdForDiagnostics -} from '../../shared/pty-delivery-diagnostics' -import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' -import { isTuiAgent } from '../../shared/tui-agent-config' -import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' -import { - normalizeAgentProviderSession, - type AgentProviderSessionMetadata, - type SleepingAgentLaunchConfig -} from '../../shared/agent-session-resume' -import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' -import { - isWslShellName, - resolveLocalWindowsTerminalRuntimeOptions -} from '../../shared/local-windows-terminal-runtime' -import { applyTerminalGitCredentialPromptGuard } from './terminal-git-credential-guard' -import { openCodeHookService } from '../opencode/hook-service' -import { mimoCodeHookService } from '../mimo/hook-service' -import { - getCommandTokenPathBasename, - getFirstCommandToken -} from '../../shared/command-token-scanner' -import { agentHookServer } from '../agent-hooks/server' -import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' -import { ensureWslHookRelayForReattach } from '../agent-hooks/wsl-hook-relay-reattach' -import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' -import { piTitlebarExtensionService } from '../pi/titlebar-extension-service' -import { - detectExplicitPiAgentKindFromCommand, - isPiCompatibleAgentType, - PRIMARY_AGENT_DIR_ENV_BY_KIND, - SOURCE_AGENT_DIR_ENV_BY_KIND, - type PiAgentKind -} from '../../shared/pi-agent-kind' -import { isPwshAvailableAsync } from '../pwsh' -import { LocalPtyProvider } from '../providers/local-pty-provider' -import type { PtyProcessInfo } from '../providers/pty-process-info' -import { normalizeWindowsTerminalCwd } from '../providers/windows-shell-args' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' -import { isPtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' -import { - inspectPtyProviderProcess, - inspectPtyProviderProcessForRenderer -} from '../providers/pty-process-inspection' -import { - PtyProcessListAdmission, - visitPtyProcessListingsInBatches -} from '../providers/pty-process-list-admission' -import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' -import { - SSH_SESSION_EXPIRED_ERROR, - isSshPtyIdentityMismatchError, - isSshPtyNotFoundError -} from '../providers/ssh-pty-errors' -import { parseAppSshPtyId, toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' -import { createPtySpawnTiming } from './pty-spawn-timing' -import { - isSafePtySessionId, - mintPtySessionId, - ptySessionIdForAgentCreateOperation -} from '../daemon/pty-session-id' -import { resolveWslSessionContext } from '../daemon/wsl-session-context' -import { addNodePtyRecoveryHint } from '../daemon/node-pty-error-hints' -import { recordDaemonStreamBacklogEvent } from '../daemon/daemon-stream-backlog-probe' -import { - isDaemonEndpointGoneError, - TerminalHostGoneError, - TerminalSessionOwnerUnverifiedError -} from '../daemon/daemon-errors' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' -import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' -import { CLAUDE_AUTH_ENV_VARS, hasClaudeAuthEnvConflict } from '../claude-accounts/environment' -import { - isClaudeAuthSwitchInProgress, - markClaudePtyExited, - markClaudePtySpawned -} from '../claude-accounts/live-pty-gate' -import { ensureLinuxTerminalOrcaCliShimDir } from '../cli/linux-terminal-orca-cli-shim' -import { - isLegacyTerminalShimPathEntry, - LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS, - stripLegacyTerminalShimEnv -} from '../pty/legacy-terminal-shim-dir' -import { registerPty, unregisterPty } from '../memory/pty-registry' -import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' -import { track } from '../telemetry/client' -import { classifyError } from '../telemetry/classify-error' -import { getCohortAtEmit } from '../telemetry/cohort-classifier' -import { - agentKindSchema, - launchSourceSchema, - requestKindSchema -} from '../../shared/telemetry-events' -import { - isTerminalInputTooLargeWithDeferredMeasurement, - iterateTerminalInputChunks -} from '../../shared/terminal-input' -import { isRemoteAgentHooksEnabled } from '../../shared/agent-hook-relay' -import { createTerminalSessionStateSaveFailureMessage } from '../../shared/terminal-session-state-save-failure' -import { RendererTerminalSerializerReadiness } from './renderer-terminal-serializer-readiness' -import { readSessionShellStartupEnvVar } from '../pty/shell-startup-env' -import { - isTerminalLeafId, - makePaneKey, - parseLegacyNumericPaneKey, - parsePaneKey -} from '../../shared/stable-pane-id' -import { isValidTerminalTabId } from '../../shared/terminal-tab-id' -import { parseTerminalKittyKeyboardFlags } from '../../shared/terminal-kitty-keyboard-flags' -import { - resolveTerminalStartupCwdForWorkspace, - type TerminalStartupCwdMissingDirFallback -} from '../../shared/terminal-startup-cwd' -import { isWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths' -import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' -import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' -import { - agentSessionOwnerBindingsEqual, - ClaimedAgentPtyOwnerRegistry -} from '../../shared/claimed-agent-pty-owner' -import { - clearMigrationUnsupportedPty, - clearMigrationUnsupportedPtysForPaneKey -} from '../agent-hooks/migration-unsupported-pty-state' -import { parseWslPath, wslUncDirectoryExistsAsync } from '../wsl' -import { mergePersistedWindowsPath, resolvePathEnvKey } from '../pty/windows-environment-path' -import { addOrcaWslInteropEnv, stampWslOrchestrationCompatibilityHost } from '../pty/wsl-orca-env' -import { resolveCodexShellLaunchPreflightCommand } from '../pty/codex-shell-launch-preflight' -import { PtyProducerFlowController } from './pty-producer-flow-control' -import { beginTerminalInstall } from './watcher-removal-gate' -import { - clearHiddenRendererPtyDeliveryState, - getHiddenRendererPtyDeliveryDebug, - getHiddenRendererPtyIds, - isHiddenPtyDeliveryGateEnabled, - isHiddenRendererPty, - markHiddenRendererPty, - recordHiddenRendererPtyDataDrop, - resetHiddenRendererPtyDeliveryDebugCounters, - resetRendererScopedHiddenPtyDeliveryState, - setRendererPtyDeliveryInterest, - shouldDropHiddenRendererPtyData, - unmarkHiddenRendererPty -} from './pty-hidden-delivery-gate' -import { PtyPendingDataDrainQueue, type PendingPtyData } from './pty-pending-data-drain-queue' -import { - appendPendingProjectionAdmission, - compactPendingProjectionAdmissions, - propagatePendingProjectionRemainder, - type PendingProjectionAdmissions -} from './pty-pending-projection-admissions' -import { SshPtyOutputIntake } from './ssh-pty-output-intake' -import { - cancelSshPtySourceDelivery, - installSshPtyOutputIntake, - publishSshPtySourceAck -} from './ssh-pty-output-intake-registry' -import type { LegacySshProjectionSemantics } from './ssh-pty-legacy-projection' -import { - clearNativeWindowsConptyPty, - isNativeWindowsLocalPtySpawn, - markNativeWindowsConptyPty -} from '../runtime/terminal-model-query-authority' -import { setTerminalViewAttributes } from '../runtime/terminal-view-attribute-store' -import { validateTerminalViewAttributes } from '../../shared/terminal-view-attributes' -import type { PtyModelRestoreReason } from '../../shared/pty-model-restore-marker' -import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' -import { - isCodexHomeAuthReadyForLaunch, - waitForManagedCodexAuthReady -} from '../codex-accounts/managed-codex-auth-readiness' -import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership' -import { - forgetCodexPaneAccount, - getCodexPaneAccount, - recordCodexPaneAccount, - type CodexPaneHomeRoute -} from '../codex/codex-pane-account-registry' -import { resolveCodexPaneLaunchAccount } from '../codex/codex-pane-launch-account' -import { getSystemCodexHomePath } from '../codex/codex-home-paths' -import { ensureCodexStateDbBackfillRecoveryStarted } from '../codex/codex-state-db-backfill-recovery' -import { - environmentCodexHomeOverrideContextsEqual, - getCustomCodexHomeOverrideForLaunch, - shellStartupCodexHomeOverrideContextsEqual -} from '../codex/codex-real-home-path' -import type { CodexSessionResumePreparation } from '../codex/codex-session-resume-home' -import { dropUnverifiedCodexResumeArgv } from '../codex/codex-unverified-resume-launch' -import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' -import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy' -import { - resolveSetupAgentSequenceLaunchCommand, - SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV -} from '../../shared/setup-agent-sequencing' -import { dropAgentResumeArgvFromCommand } from '../../shared/agent-resume-argv-drop' -import { parseWorkspaceKey } from '../../shared/workspace-scope' -import { getStartupTerminalColorQueryReplyColors } from './terminal-startup-color-query-replies' -import { - assertFolderWorkspacePathUsable, - getFolderWorkspacePathStatus -} from '../project-groups/folder-workspace-path-status' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution' -import { isPtyIncarnationId } from '../../shared/pty-incarnation' -import type { PtyListedSession } from '../../shared/pty-listed-session' - -// ─── Provider Registry ────────────────────────────────────────────── -// Routes PTY operations by connectionId (null = local provider). - -let localProvider: IPtyProvider = new LocalPtyProvider() -const sshProviders = new Map() -const sshProvidersByGeneration = new Map() - -type RegisteredPtyProvider = { - provider: IPtyProvider - connectionId: string | null -} - -function registeredPtyProviders(): RegisteredPtyProvider[] { - return [ - { provider: localProvider, connectionId: null }, - ...Array.from(sshProviders, ([connectionId, provider]) => ({ provider, connectionId })) - ] -} - -// Why: settling each provider separately only bounds a relay that *rejects*. An -// unanswered relay list runs to the mux's own 30s default — far past the caller's -// budget — so the aggregate still expired and the runtime lost the inventory it -// needs to retire exited PTYs, freezing every retained pane as "active" (STA-517). -// Forward the caller's deadline so a silent relay fails fast and lands in the -// unavailable branch below: unknown, not empty. -async function listRegisteredPtyProcessesWithHostScope( - onSshInventoryUnavailable?: (connectionId: string, error: unknown) => void, - opts?: { deadlineMs?: number } -): Promise<{ - processes: PtyProcessInfo[] - hostIds: ExecutionHostId[] -}> { - const providers = registeredPtyProviders() - const providerSessions = await Promise.all( - providers.map(async ({ provider, connectionId }) => { - try { - const hostId: ExecutionHostId = connectionId - ? toSshExecutionHostId(connectionId) - : LOCAL_EXECUTION_HOST_ID - return { - // Why: the deadline only applies to relay round-trips; the local provider answers in-process. - processes: await (connectionId ? provider.listProcesses(opts) : provider.listProcesses()), - hostId - } - } catch (error) { - if (!connectionId) { - throw error - } - onSshInventoryUnavailable?.(connectionId, error) - return null - } - }) - ) - const respondingSessions = providerSessions.filter((session) => session !== null) - return { - processes: respondingSessions.flatMap((session) => session.processes), - hostIds: respondingSessions.map((session) => session.hostId) - } -} - -const SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS = 30_000 -// Why: kill switch — flip to disable producer flow control (pause/resume) without untangling the wiring. -const PRODUCER_FLOW_CONTROL_ENABLED = true -// Why: post-spawn write/resize/kill calls carry only the PTY ID; map it to its connectionId so ops route to the right provider. -const ptyOwnership = new Map() -const ptyIncarnationById = new Map() - -export function isCurrentPtyExit(payload: { id: string; incarnationId?: string }): boolean { - const current = ptyIncarnationById.get(payload.id) - return !current || payload.incarnationId === current -} -// Why: mobile clients must mirror desktop PTY geometry even before the renderer can provide an xterm snapshot (e.g. right after tab creation). -const ptySizes = new Map() -// Why: the "recent user input" signal is PTY-scoped and must be cleared by every teardown path, incl. SSH/daemon shutdowns that skip the local exit listener. -const lastInputAtByPty = new Map() -const interactiveOutputCharsByPty = new Map() -const activeRendererPtys = new Set() -const visibleRendererPtys = new Set() -const rendererVisibilityKnownPtys = new Set() -let invalidatePendingPtyDrainPriority = (_id?: string, _schedule?: boolean): void => {} -let invalidatePendingPtyDrainPolicy = (_id?: string, _schedule?: boolean): void => {} -const pendingHiddenRendererResizeOutputPtys = new Set() -const deliveredHiddenRendererResizeOutputPtys = new Set() -const KEEP_HISTORY_STOP_SETTLE_MS = 1_000 -const KEEP_HISTORY_STOP_POLL_MS = 100 -// Why: track spawn-time paneKey so teardown can clear the agent-hooks server's per-paneKey cache, which otherwise grows unbounded as panes come and go. -const ptyPaneKey = new Map() -// Why: reverse of ptyPaneKey — callers with a paneKey from outside the PTY lifecycle (e.g. agent-hook status routing) need the ptyId; kept in lock-step via the same sites. -const paneKeyPtyId = new Map() - -const AGENT_HOOK_RUNTIME_ENV_KEYS = [ - 'ORCA_AGENT_HOOK_PORT', - 'ORCA_AGENT_HOOK_TOKEN', - 'ORCA_AGENT_HOOK_ENV', - 'ORCA_AGENT_HOOK_VERSION', - 'ORCA_AGENT_HOOK_ENDPOINT', - // Why: PR 2778 briefly exported this path; keep deleting stale inherited values so older PTYs can't leak the reverted path. - 'ORCA_CLAUDE_AGENT_STATUS_SETTINGS' -] as const - -// Why: Orca never sets these, so an inherited value means a pty host launched from inside a Claude session — Claude reads it as a nested child and silently stops persisting the transcript. -const CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS = [ - 'CLAUDE_CODE_CHILD_SESSION', - 'CLAUDE_CODE_SESSION_ID', - 'CLAUDE_CODE_BRIDGE_SESSION_ID' -] as const - -export function getPtyIdForPaneKey(paneKey: string): string | undefined { - return paneKeyPtyId.get(paneKey) -} - -// Why: let consumers tear down paneKey-scoped state on PTY exit so their timers can't leak; a callback registry keeps the cross-module dependency narrow. -type PaneKeyTeardownListener = (paneKey: string) => void -const paneKeyTeardownListeners = new Set() - -export function registerPaneKeyTeardownListener(listener: PaneKeyTeardownListener): () => void { - paneKeyTeardownListeners.add(listener) - return () => paneKeyTeardownListeners.delete(listener) -} - -// Why: renderer pre-declares serializer ownership before pty:spawn to suppress the daemon-snapshot seed; gen tokens prevent paneKey-reuse races on teardown. See docs/mobile-prefer-renderer-scrollback.md. -let pendingSerializerGenSeq = 0 -const pendingByPaneKey = new Map() -const pendingPaneSerializerCleanupRegistered = new Set() -type PaneSpawnReservation = { - promise: Promise - resolve: (result: PaneSpawnReservationResult) => void - reject: (error: unknown) => void -} -type PaneSpawnReservationResult = { - id: string - launchConfig?: SleepingAgentLaunchConfig - stablePaneOwner?: { - handle: string - tabId: string - leafId: string - } -} & Partial -// Why: identical pane coordinates on different worktrees or hosts are independent owners. -const paneSpawnReservationsByOwnerKey = new Map() -type PendingRuntimePaneCreate = { - count: number - promise: Promise - resolve: () => void -} -const pendingRuntimePaneCreatesByOwnerKey = new Map() -// Why: one main process can route the same remote provider namespace through -// multiple SSH relays; coordinate claims above every provider boundary too. -const agentSessionOwners = new ClaimedAgentPtyOwnerRegistry() -let agentSessionOwnerReconciliation: Promise | null = null - -// Why: restore payloads (reattach snapshot / cold-restore scrollback / relay -// replay + lastTitle) ride spawn RPC results, never onPtyData, so EVERY spawn -// choke point — renderer pty:spawn and the runtime controller — must seed the -// terminal list/read records or headless/CLI-created reattaches stay blank. -// The runtime's empty-record guard makes a second seed for the same session a -// no-op, so overlapping paths cannot double-apply history. -function seedTerminalRestoreRecordsFromSpawnResult( - runtime: OrcaRuntimeService | undefined, - result: PtySpawnResult -): void { - const text = - typeof result.snapshot === 'string' && result.snapshot.length > 0 - ? result.snapshot - : typeof result.coldRestore?.scrollback === 'string' && - result.coldRestore.scrollback.length > 0 - ? result.coldRestore.scrollback - : typeof result.replay === 'string' && result.replay.length > 0 - ? result.replay - : undefined - const lastTitle = - typeof result.lastTitle === 'string' && result.lastTitle.length > 0 - ? result.lastTitle - : typeof result.coldRestore?.lastTitle === 'string' && result.coldRestore.lastTitle.length > 0 - ? result.coldRestore.lastTitle - : undefined - if (text !== undefined || lastTitle !== undefined) { - runtime?.seedTerminalRestoreTail?.(result.id, { - ...(text !== undefined ? { text } : {}), - ...(lastTitle !== undefined ? { lastTitle } : {}) - }) - } -} - -function assertSpawnReplyWasLive(result: PtySpawnResult): void { - if (!result.exitedBeforeSpawnReply) { - return - } - // Why: lower owners can resolve a different canonical id, so controller-local pending ids cannot prove this exit. - throw Object.assign(new Error('agent_session_exited_during_start'), { - agentSessionOperationOutcome: 'unknown' as const - }) -} - -async function reconcileAgentSessionOwnerListings(): Promise { - if (agentSessionOwnerReconciliation) { - return await agentSessionOwnerReconciliation - } - const reconciliation = (async () => { - const providers: { provider: IPtyProvider; connectionId: string | null }[] = [ - { provider: localProvider, connectionId: null }, - ...Array.from(sshProviders, ([connectionId, provider]) => ({ provider, connectionId })) - ] - const listings = await Promise.all( - providers.map(async ({ provider, connectionId }) => ({ - connectionId, - sessions: await provider.listProcesses() - })) - ) - const advertisedOwners: AgentSessionOwnerBinding[] = [] - const advertisedOwnerSessions: { - id: string - connectionId: string | null - incarnationId: string - }[] = [] - for (const { connectionId, sessions } of listings) { - for (const session of sessions) { - const incarnationId = session.incarnationId - let hasAdvertisedOwner = false - for (const owner of session.agentSessionOwners ?? []) { - if (owner.ptyId !== session.id || !isPtyIncarnationId(incarnationId)) { - // Why: a recovered claim without process-incarnation proof cannot safely reject a delayed exit. - throw new Error('agent_session_ownership_unknown') - } - advertisedOwners.push(owner) - hasAdvertisedOwner = true - } - if (hasAdvertisedOwner && isPtyIncarnationId(incarnationId)) { - advertisedOwnerSessions.push({ id: session.id, connectionId, incarnationId }) - } - } - } - agentSessionOwners.reconcileAuthoritative(advertisedOwners, { - // Why: an unregistered relay can still own a live PTY during reconnect; - // only providers that serialize claims may make listing absence authoritative. - isInAuthoritativeScope: (owner) => { - const provider = tryGetProviderForAgentSessionOwner(owner.ptyId) - return provider?.providesAgentSessionOwnerListings?.(owner.ptyId) === true - } - }) - for (const session of advertisedOwnerSessions) { - ptyOwnership.set(session.id, session.connectionId) - ptyIncarnationById.set(session.id, session.incarnationId) - } - })() - agentSessionOwnerReconciliation = reconciliation - try { - await reconciliation - } finally { - if (agentSessionOwnerReconciliation === reconciliation) { - agentSessionOwnerReconciliation = null - } - } -} -// Why: bind the declaration generation directly to its spawn result. PTY ids -// are reusable and teardown callbacks carry no incarnation token, so teardown -// must never guess which pending renderer generation it owns. -const pendingPtyIdBySerializerGeneration = new Map() -// Why: hasRendererSerializer probe needs a ptyId-keyed signal; a later spawn starts a fresh incarnation, subscription abort owns waiter cleanup. -const rendererSerializerReadiness = new RendererTerminalSerializerReadiness() - -function parseValidPaneKey(paneKey: unknown): ReturnType { - if (typeof paneKey !== 'string' || paneKey.length > 256) { - return null - } - return parsePaneKey(paneKey) -} - -function isValidPaneKey(paneKey: unknown): paneKey is string { - return parseValidPaneKey(paneKey) !== null -} - -const AGENT_LAUNCH_TOKEN_MAX_LENGTH = 128 - -function admitRendererAgentLaunchAuthority(args: { - launchToken: unknown - spawnEnv: Record | undefined - launchAgent: unknown - launchConfig: SleepingAgentLaunchConfig | undefined - isReattach: boolean - hasStablePaneOwner: boolean - incarnationId: unknown -}): { launchToken: string; launchAgent: TuiAgent } | null { - if ( - args.isReattach || - args.hasStablePaneOwner || - !args.launchConfig || - !isTuiAgent(args.launchAgent) || - !isPtyIncarnationId(args.incarnationId) || - typeof args.launchToken !== 'string' || - args.launchToken.length === 0 || - args.launchToken.length > AGENT_LAUNCH_TOKEN_MAX_LENGTH || - args.spawnEnv?.ORCA_AGENT_LAUNCH_TOKEN !== args.launchToken - ) { - return null - } - return { launchToken: args.launchToken, launchAgent: args.launchAgent } -} - -function shouldRefreshNativeClaudeAgentTeamsEnv(args: { - command?: string - launchConfig?: SleepingAgentLaunchConfig -}): boolean { - const capturedCommand = args.launchConfig?.agentCommand?.trim() || args.command?.trim() || '' - const capturedArgs = args.launchConfig?.agentArgs?.trim() ?? '' - const capturedLaunch = `${capturedCommand} ${capturedArgs}`.trim() - return /(^|\s)--teammate-mode(?:=|\s+)auto(?:\s|$)/.test(capturedLaunch) -} - -function rememberPaneKeyForPty(ptyId: string, paneKey: unknown): string | null { - const normalizedPaneKey = typeof paneKey === 'string' ? paneKey.trim() : '' - if (!isValidPaneKey(normalizedPaneKey)) { - return null - } - ptyPaneKey.set(ptyId, normalizedPaneKey) - paneKeyPtyId.set(normalizedPaneKey, ptyId) - return normalizedPaneKey -} - -function cleanupPendingPaneSerializersForSender(ownerWebContentsId: number): void { - pendingPaneSerializerCleanupRegistered.delete(ownerWebContentsId) - for (const [paneKey, pending] of pendingByPaneKey) { - if (pending.ownerWebContentsId === ownerWebContentsId) { - pendingByPaneKey.delete(paneKey) - pendingPtyIdBySerializerGeneration.delete(pending.gen) - } - } -} - -function registerPendingPaneSerializerCleanup(sender: WebContents | undefined): void { - if (!sender || pendingPaneSerializerCleanupRegistered.has(sender.id)) { - return - } - pendingPaneSerializerCleanupRegistered.add(sender.id) - sender.once('destroyed', () => cleanupPendingPaneSerializersForSender(sender.id)) -} - -function declarePendingPaneSerializer(paneKey: string, sender: WebContents | undefined): number { - const gen = ++pendingSerializerGenSeq - registerPendingPaneSerializerCleanup(sender) - const replaced = pendingByPaneKey.get(paneKey) - if (replaced) { - pendingPtyIdBySerializerGeneration.delete(replaced.gen) - } - pendingByPaneKey.set(paneKey, { gen, ownerWebContentsId: sender?.id ?? null }) - const existingPtyId = paneKeyPtyId.get(paneKey) - if (existingPtyId) { - pendingPtyIdBySerializerGeneration.set(gen, existingPtyId) - } - return gen -} - -function reservePaneSpawn(paneKey: string): PaneSpawnReservation { - let resolve!: (result: PaneSpawnReservationResult) => void - let reject!: (error: unknown) => void - const promise = new Promise((promiseResolve, promiseReject) => { - resolve = promiseResolve - reject = promiseReject - }) - promise.catch(() => {}) - const reservation = { promise, resolve, reject } - paneSpawnReservationsByOwnerKey.set(paneKey, reservation) - return reservation -} - -function clearPaneSpawnReservation(paneKey: string, reservation: PaneSpawnReservation): void { - if (paneSpawnReservationsByOwnerKey.get(paneKey) === reservation) { - paneSpawnReservationsByOwnerKey.delete(paneKey) - } -} - -function makePaneSpawnReservationKey( - worktreeId: string | undefined, - connectionId: string | null | undefined, - paneKey: string | null | undefined -): string | null { - return paneKey ? JSON.stringify([connectionId ?? null, worktreeId ?? null, paneKey]) : null -} - -function claimRuntimePaneCreate(ownerKey: string): () => void { - const existing = pendingRuntimePaneCreatesByOwnerKey.get(ownerKey) - if (existing) { - existing.count += 1 - return () => releaseRuntimePaneCreate(ownerKey, existing) - } - let resolve!: () => void - const claim = { - count: 1, - promise: new Promise((done) => { - resolve = done - }), - resolve: () => resolve() - } - pendingRuntimePaneCreatesByOwnerKey.set(ownerKey, claim) - return () => releaseRuntimePaneCreate(ownerKey, claim) -} - -function releaseRuntimePaneCreate(ownerKey: string, claim: PendingRuntimePaneCreate): void { - if (pendingRuntimePaneCreatesByOwnerKey.get(ownerKey) !== claim) { - return - } - claim.count -= 1 - if (claim.count > 0) { - return - } - pendingRuntimePaneCreatesByOwnerKey.delete(ownerKey) - claim.resolve() -} - -function rejectPaneSpawnReservation( - paneKey: string | null | undefined, - reservation: PaneSpawnReservation | null | undefined, - error: unknown -): void { - if (!reservation) { - return - } - reservation.reject(error) - if (paneKey) { - clearPaneSpawnReservation(paneKey, reservation) - } -} - -function resolvePaneSpawnReservation( - paneKey: string | null | undefined, - reservation: PaneSpawnReservation | null | undefined, - response: T -): T { - if (!reservation) { - return response - } - reservation.resolve(response) - if (paneKey) { - clearPaneSpawnReservation(paneKey, reservation) - } - return response -} - -type StablePaneOwner = { - handle?: string - tabId: string - leafId: string - ptyId: string - incarnationId?: string - hasPersistedBinding?: true - persistedIncarnationId?: string - runtimeIncarnationId?: string -} -type StablePaneAdoption = { - result: PtySpawnResult - owner: StablePaneOwner - materialized?: true -} | null -const stablePaneAdoptionsByOwnerKey = new Map>() - -function resolvePersistedStablePaneOwner( - store: Store | undefined, - paneKey: string, - worktreeId: string, - connectionId: string | null | undefined -): Pick | null { - if (!store || typeof store.getWorkspaceSession !== 'function') { - return null - } - const parsed = parsePaneKey(paneKey) - if (!parsed) { - return null - } - const session = store.getWorkspaceSession( - connectionId ? toSshExecutionHostId(connectionId) : undefined - ) - const tab = session.tabsByWorktree?.[worktreeId]?.find( - (candidate) => candidate.id === parsed.tabId && candidate.worktreeId === worktreeId - ) - const ptyId = session.terminalLayoutsByTabId?.[parsed.tabId]?.ptyIdsByLeafId?.[parsed.leafId] - if (!tab || typeof ptyId !== 'string' || ptyId.length === 0) { - return null - } - const incarnationId = session.terminalPtyIncarnationsByPaneKey?.[paneKey] - return { - tabId: parsed.tabId, - leafId: parsed.leafId, - ptyId, - ...(incarnationId ? { incarnationId } : {}) - } -} - -function resolveStablePaneOwner( - runtime: OrcaRuntimeService | undefined, - store: Store | undefined, - paneKey: string | null | undefined, - worktreeId: string | undefined, - connectionId: string | null | undefined -): StablePaneOwner | null { - if (!paneKey || !worktreeId) { - return null - } - let resolved: ReturnType | null = null - let resolvedHandleCandidate: ReturnType | null = null - if (runtime && typeof runtime.resolveTerminalPane === 'function') { - try { - const candidate = runtime.resolveTerminalPane(paneKey, worktreeId) - resolvedHandleCandidate = candidate - resolved = candidate.connected === false ? null : candidate - } catch (error) { - if (!(error instanceof Error && error.message === 'terminal_not_found')) { - throw error - } - } - } - const persisted = resolvePersistedStablePaneOwner(store, paneKey, worktreeId, connectionId) - if (resolved?.ptyId && persisted && resolved.ptyId !== persisted.ptyId) { - throw new Error('terminal_pane_owner_conflict') - } - const ptyId = resolved?.ptyId ?? persisted?.ptyId - if (!ptyId) { - return null - } - const registeredConnectionId = ptyOwnership.get(ptyId) - const parsedSshId = registeredConnectionId === undefined ? parseAppSshPtyId(ptyId) : null - const ownerConnectionId = registeredConnectionId ?? parsedSshId?.connectionId ?? null - if (ownerConnectionId !== (connectionId ?? null)) { - throw new Error('terminal_pane_owner_host_mismatch') - } - const runtimeIncarnationId = ptyIncarnationById.get(ptyId) - const parsed = parsePaneKey(paneKey) - if (!parsed) { - return null - } - return { - ...(resolvedHandleCandidate?.ptyId === ptyId ? { handle: resolvedHandleCandidate.handle } : {}), - tabId: resolved?.tabId || persisted?.tabId || parsed.tabId, - leafId: resolved?.leafId || persisted?.leafId || parsed.leafId, - ptyId, - ...(runtimeIncarnationId || persisted?.incarnationId - ? { incarnationId: runtimeIncarnationId ?? persisted?.incarnationId } - : {}), - ...(persisted ? { hasPersistedBinding: true as const } : {}), - ...(persisted?.incarnationId ? { persistedIncarnationId: persisted.incarnationId } : {}), - ...(runtimeIncarnationId ? { runtimeIncarnationId } : {}) - } -} - -function retirePersistedStablePaneOwner( - store: Store | undefined, - owner: StablePaneOwner, - worktreeId: string, - connectionId: string | null | undefined -): boolean { - if (!store) { - return false - } - const paneKey = makePaneKey(owner.tabId, owner.leafId) - const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined - const current = resolvePersistedStablePaneOwner(store, paneKey, worktreeId, connectionId) - if (!current) { - // Why: persistence already dropped this pane binding (an earlier stop retired it while the - // runtime kept history), so there is nothing left to clear — that is a completed retirement, - // not a competing owner. Reporting failure here strands the pane after its PTY is proven dead. - return true - } - if (current.ptyId !== owner.ptyId || current.incarnationId !== owner.persistedIncarnationId) { - return false - } - const session = store.getWorkspaceSession(hostId) - const retired = retireTerminalSurfaceFromPersistence(session, { - worktreeId, - parentTabId: owner.tabId, - leafId: owner.leafId, - ptyId: owner.ptyId, - ...(current.incarnationId ? { incarnationId: current.incarnationId } : {}) - }) - if (retired === session) { - return false - } - store.setWorkspaceSession(retired, hostId) - store.flushOrThrow() - return true -} - -type StablePaneSpawnContext = { - runtime: OrcaRuntimeService | undefined - store?: Store - provider: IPtyProvider - spawnOptions: PtySpawnOptions - owner: StablePaneOwner | null - worktreeId?: string - connectionId?: string | null - resolveOwner?: () => StablePaneOwner | null - onFreshSpawn?: (result: PtySpawnResult) => void -} - -function stablePanePersistenceFence( - owner: StablePaneOwner | null -): { ptyId: string; incarnationId?: string } | undefined { - return owner?.hasPersistedBinding - ? { - ptyId: owner.ptyId, - ...(owner.persistedIncarnationId ? { incarnationId: owner.persistedIncarnationId } : {}) - } - : undefined -} - -function persistAdmittedStablePaneBinding(args: { - store: Store | undefined - owner: StablePaneOwner | null - result: PtySpawnResult - worktreeId: string | undefined - startupCwd: string | undefined - connectionId: string | null | undefined -}): boolean { - const expectedBinding = stablePanePersistenceFence(args.owner) - if (!args.store || !args.owner || !args.worktreeId || !expectedBinding) { - return false - } - const persisted = args.store.persistPtyBinding( - { - worktreeId: args.worktreeId, - tabId: args.owner.tabId, - leafId: args.owner.leafId, - ptyId: args.result.id, - ...(args.result.incarnationId ? { incarnationId: args.result.incarnationId } : {}), - ...(args.startupCwd ? { startupCwd: args.startupCwd } : {}), - expectedBinding - }, - args.connectionId ? toSshExecutionHostId(args.connectionId) : undefined - ) - if (persisted === false) { - throw new Error('terminal_pane_owner_changed') - } - return true -} - -async function attachStablePaneOwner( - args: StablePaneSpawnContext & { owner: StablePaneOwner } -): Promise<{ result: PtySpawnResult; owner: StablePaneOwner } | null> { - const { owner, provider, runtime, spawnOptions } = args - let result: PtySpawnResult - try { - result = await provider.spawn({ - ...spawnOptions, - sessionId: owner.ptyId, - attachOnly: true, - expectedIncarnationId: owner.runtimeIncarnationId ?? owner.persistedIncarnationId, - expectedIncarnationIsAuthoritative: owner.runtimeIncarnationId !== undefined, - isNewSession: undefined, - command: undefined, - commandDelivery: undefined, - startupCommandDelivery: undefined, - launchAgent: undefined, - startupIngress: undefined, - agentSessionEnsure: undefined, - agentSessionCreateOperationId: undefined, - onPtySpawnCommitted: undefined - }) - } catch (error) { - if (error instanceof TerminalSessionOwnerUnverifiedError) { - throw new Error('terminal_pane_owner_unverified') - } - // Why: translate before paired-runtime RPC strips the socket error's code and syscall. - if (isDaemonEndpointGoneError(error)) { - throw new TerminalHostGoneError() - } - if (!isPtyAlreadyGoneError(error)) { - throw error - } - const ownerBeforeRetire = args.resolveOwner?.() - if ( - ownerBeforeRetire && - (ownerBeforeRetire.ptyId !== owner.ptyId || - ownerBeforeRetire.runtimeIncarnationId !== owner.runtimeIncarnationId || - ownerBeforeRetire.hasPersistedBinding !== owner.hasPersistedBinding || - ownerBeforeRetire.persistedIncarnationId !== owner.persistedIncarnationId) - ) { - throw new Error('terminal_pane_owner_changed') - } - runtime?.onPtyExit(owner.ptyId, 0, owner.incarnationId) - clearProviderPtyState(owner.ptyId) - ptyOwnership.delete(owner.ptyId) - if ( - args.worktreeId && - !retirePersistedStablePaneOwner(args.store, owner, args.worktreeId, args.connectionId) - ) { - throw new Error('terminal_pane_owner_changed') - } - if (args.resolveOwner?.()) { - throw new Error('terminal_pane_owner_changed') - } - return null - } - if ( - result.id !== owner.ptyId || - result.isReattach !== true || - (owner.runtimeIncarnationId !== undefined && - result.incarnationId !== owner.runtimeIncarnationId) || - (result.incarnationId === undefined && owner.incarnationId !== undefined) - ) { - throw new Error('terminal_pane_owner_changed') - } - return { result, owner } -} - -async function spawnForStablePane( - args: StablePaneSpawnContext -): Promise<{ result: PtySpawnResult; owner: StablePaneOwner | null }> { - if (args.owner) { - const attached = await attachStablePaneOwner({ ...args, owner: args.owner }) - if (attached) { - return attached - } - } - const result = await args.provider.spawn(args.spawnOptions) - args.onFreshSpawn?.(result) - return { result, owner: null } -} - -function settlePendingPaneSerializer(paneKey: string, gen: number): boolean { - if (pendingByPaneKey.get(paneKey)?.gen !== gen) { - return false - } - pendingByPaneKey.delete(paneKey) - return true -} - -export function hasPendingRendererSerializerForPaneKey(paneKey: string): boolean { - return isValidPaneKey(paneKey) && pendingByPaneKey.has(paneKey) -} - -function getProvider(connectionId: string | null | undefined): IPtyProvider { - if (!connectionId) { - return localProvider - } - const provider = sshProviders.get(connectionId) - if (!provider) { - throw new Error(`No PTY provider for connection "${connectionId}"`) - } - return provider -} - -function getProviderForPty(ptyId: string): IPtyProvider { - const connectionId = ptyOwnership.get(ptyId) - if (connectionId === undefined) { - const parsedSshId = parseAppSshPtyId(ptyId) - if (parsedSshId) { - // Why: disconnected SSH PTYs retain their encoded owner and must never fall through to the HUB-local provider. - return getProvider(parsedSshId.connectionId) - } - return localProvider - } - return getProvider(connectionId) -} - -function hasPtyProviderForInspection(ptyId: string): boolean { - // Why: process inspection is background polling; disconnected SSH hosts should read as idle, not raise repeated IPC errors. - const connectionId = ptyOwnership.get(ptyId) - return connectionId == null || sshProviders.has(connectionId) -} - -function getAppPtyId(connectionId: string | null | undefined, ptyId: string): string { - return connectionId ? toAppSshPtyId(connectionId, ptyId) : ptyId -} - -function getRelayPtyId(connectionId: string | null | undefined, ptyId: string): string { - return connectionId ? toRelaySshPtyId(connectionId, ptyId) : ptyId -} - -function stripRemotePaneEnvWhenHooksDisabled( - connectionId: string | null | undefined, - env: Record | undefined -): Record | undefined { - if (!connectionId || isRemoteAgentHooksEnabled()) { - return env - } - if ( - !env || - (!('ORCA_PANE_KEY' in env) && - !('ORCA_TAB_ID' in env) && - !('ORCA_WORKTREE_ID' in env) && - !('ORCA_AGENT_LAUNCH_TOKEN' in env)) - ) { - return env - } - const stripped = { ...env } - delete stripped.ORCA_PANE_KEY - delete stripped.ORCA_TAB_ID - delete stripped.ORCA_WORKTREE_ID - delete stripped.ORCA_AGENT_LAUNCH_TOKEN - return stripped -} - -function tryGetProviderForPty(ptyId: string): IPtyProvider | undefined { - try { - return getProviderForPty(ptyId) - } catch { - return undefined - } -} - -function closeStartupQueryAuthorityForPty(ptyId: string): void { - try { - void Promise.resolve(tryGetProviderForPty(ptyId)?.closeStartupQueryAuthority?.(ptyId)).catch( - () => {} - ) - } catch { - /* Best-effort handoff; the bounded source deadline remains the fallback. */ - } -} - -function tryGetProviderForAgentSessionOwner(ptyId: string): IPtyProvider | undefined { - const ownedConnectionId = ptyOwnership.get(ptyId) - const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(ptyId) : null - try { - return getProvider(parsedSshId?.connectionId ?? ownedConnectionId) - } catch { - return undefined - } -} - -function normalizeNodePtySpawnError(err: unknown): Error { - const rawMessage = err instanceof Error ? err.message : String(err) - const hintedMessage = addNodePtyRecoveryHint(rawMessage) - if (hintedMessage === rawMessage && err instanceof Error) { - return err - } - if (err instanceof Error) { - // Why: preserve the original stack/name/custom fields while adding the same recovery hint as the pty:spawn path. - err.message = hintedMessage - return err - } - return new Error(hintedMessage) -} - -function isPtyAlreadyGoneError(err: unknown): boolean { - const message = err instanceof Error ? err.message : String(err) - return isSshPtyNotFoundError(err) || /Session not found/i.test(message) -} - -function delay(ms: number): Promise { - return new Promise((resolve) => { - const timer = setTimeout(resolve, ms) - if (typeof timer.unref === 'function') { - timer.unref() - } - }) -} - -async function isProviderPtyLive( - provider: IPtyProvider, - ptyId: string, - deadlineMs?: number -): Promise { - // Why: bound the liveness list RPC by the teardown deadline so a wedged daemon - // fails fast; undefined keeps the provider default for all other callers. - return (await provider.listProcesses(deadlineMs !== undefined ? { deadlineMs } : undefined)).some( - (session) => session.id === ptyId - ) -} - -async function isProviderAgentSessionOwnerLive( - provider: IPtyProvider, - owner: AgentSessionOwnerBinding -): Promise { - const session = (await provider.listProcesses()).find((candidate) => candidate.id === owner.ptyId) - if (!session) { - return false - } - if (provider.providesAgentSessionOwnerListings?.(owner.ptyId) !== true) { - // Why: in-process local owners cannot serialize the controller claim; exact incarnation - // liveness keeps that claim authoritative until the normal PTY exit releases it. - const expectedIncarnation = ptyIncarnationById.get(owner.ptyId) - return expectedIncarnation !== undefined && session.incarnationId === expectedIncarnation - } - return Boolean( - session.agentSessionOwners?.some((candidate) => - agentSessionOwnerBindingsEqual(candidate, owner) - ) - ) -} - -async function verifyPtyStopped( - provider: IPtyProvider, - ptyId: string, - opts: { keepHistory?: boolean; deadlineMs?: number } | undefined -): Promise { - if (await isProviderPtyLive(provider, ptyId, opts?.deadlineMs)) { - return false - } - if (!opts?.keepHistory) { - return true - } - const deadline = Date.now() + KEEP_HISTORY_STOP_SETTLE_MS - while (Date.now() < deadline) { - await delay(KEEP_HISTORY_STOP_POLL_MS) - if (await isProviderPtyLive(provider, ptyId, opts?.deadlineMs)) { - return false - } - } - return true -} - -function finishPtyShutdown( - id: string, - connectionId: string | null | undefined, - store: Store | undefined -): string | undefined { - const incarnationId = ptyIncarnationById.get(id) - clearProviderPtyState(id) - if (connectionId) { - store?.markSshRemotePtyLease(connectionId, getRelayPtyId(connectionId, id), 'terminated') - } - ptyOwnership.delete(id) - markClaudePtyExited(id) - return incarnationId -} - -// ─── Host PTY env assembly ────────────────────────────────────────── -// Why: centralize host-local env injections so both spawn paths (local + daemon) get them; implemented twice they drifted, silently breaking daemon PTYs. - -export type BuildPtyHostEnvOptions = { - isPackaged: boolean - resourcesPath?: string - userDataPath: string - selectedCodexHomePath: string | null - skipCodexHomeEnv?: boolean - /** System-default real-home routing (flag ON): inject no managed CODEX_HOME, - * and strip only an inherited Orca-owned override so nested Orca panes do not - * leak the parent's managed home. A user-set CODEX_HOME is preserved. */ - stripInheritedOrcaCodexHome?: boolean - /** Launch command the renderer chose (e.g. 'pi', 'omp', 'claude'); resolves the per-agent - * extension target for Pi/OMP. Undefined for bare shells → defaults to Pi. NEVER infer from - * disk presence (cross-agent shadowing when both dirs exist). */ - launchCommand?: string - /** Trusted agent identity for wrapped commands that cannot be recognized from text. */ - launchAgent?: TuiAgent - isWsl?: boolean - /** Distro for WSL spawns (null = Windows default distro); drives the WSL hook relay + endpoint repoint. Only read when isWsl. */ - wslDistro?: string | null - agentStatusHooksEnabled: boolean - codexStatusHooksEnabled?: boolean - networkProxySettings?: NetworkProxySettings - /** Keep indexed Git config off the sparse daemon wire; the daemon appends guard entries after merging its inherited env. */ - deferGitConfigGuardToDaemon?: boolean -} - -function readInheritedPath(baseEnv: Record): string { - const pathKey = resolvePathEnvKey(baseEnv, process.platform) - return baseEnv[pathKey] ?? process.env[pathKey] ?? '' -} - -function firstPathEntry(pathValue: string | undefined): string | null { - const first = pathValue?.split(delimiter).find((entry) => entry.trim().length > 0) - return first ?? null -} - -function promoteAgentTeamsShimPath( - env: Record | undefined, - requestedPath: string | undefined -): void { - if (!env?.ORCA_AGENT_TEAMS_TEAM_ID) { - return - } - const shimPath = firstPathEntry(requestedPath) - // Why: requestedPath is captured before buildPtyHostEnv scrubs, so a legacy entry that - // reached the front would be re-prepended here and outlive the scrub. - if (!shimPath || isLegacyTerminalShimPathEntry(shimPath)) { - return - } - const currentPathKey = env.PATH !== undefined || env.Path === undefined ? 'PATH' : 'Path' - const currentPath = env[currentPathKey] ?? '' - const remaining = currentPath - .split(delimiter) - .filter((entry) => entry.length > 0 && entry !== shimPath) - // Why: host env injection prepends Orca's shims; Claude Agent Teams must still resolve our fake tmux before any real tmux. - env[currentPathKey] = [shimPath, ...remaining].join(delimiter) -} - -function deleteRequestedEnvKeys( - env: Record | undefined, - keys: string[] | undefined -): void { - if (!env || !keys) { - return - } - for (const key of keys) { - delete env[key] - } -} - -function shouldSkipCodexHomeEnvForWindowsShell( - shellPath: string | undefined, - cwd: string | undefined -): boolean { - return isWslShellName(shellPath) || (typeof cwd === 'string' && parseWslPath(cwd) !== null) -} - -function isCodexStatusHooksEnabled(settings: GlobalSettings | undefined): boolean { - return ( - isAgentStatusHooksEnabled(settings) && isTuiAgentEnabled('codex', settings?.disabledTuiAgents) - ) -} - -// Why: with the real-home flag ON, a host system-default launch resolves to a -// null managed home. Signal the env builder to strip a nested-Orca-inherited -// override instead of injecting one, so Codex runs on the user's own ~/.codex. -function shouldStripInheritedOrcaCodexHome(args: { - target: CodexAccountSelectionTarget - selectedCodexHomePath: string | null - skipCodexHomeEnv: boolean - settings: GlobalSettings | undefined -}): boolean { - return ( - args.target.runtime === 'host' && args.selectedCodexHomePath === null && !args.skipCodexHomeEnv - ) -} - -const CODEX_HOME_ENV_KEYS = ['CODEX_HOME', 'ORCA_CODEX_HOME'] as const - -// Why: system-default real-home routing runs Codex on the user's own ~/.codex. -// Nested Orca panes inherit the parent's Orca-owned override; strip only that -// (CODEX_HOME matching Orca's private ORCA_CODEX_HOME marker), and always drop -// the marker so a shell-ready wrapper cannot restore the managed home. A -// user-set CODEX_HOME with no Orca marker is preserved untouched (see #8606). -function stripInheritedOrcaCodexHomeOverride(baseEnv: Record): void { - for (const key of getLocalOrcaCodexHomeEnvKeysToDelete(baseEnv)) { - delete baseEnv[key] - } -} - -// Why: in-process spawns share main's inherited environment, so equality with -// the private marker is authoritative here. Persistent daemons compare locally. -function getLocalOrcaCodexHomeEnvKeysToDelete(env: Record): string[] { - const inheritedOrcaOverride = env.ORCA_CODEX_HOME ?? process.env.ORCA_CODEX_HOME - const inheritedCodexHome = env.CODEX_HOME ?? process.env.CODEX_HOME - const keysToDelete = ['ORCA_CODEX_HOME'] - if (inheritedOrcaOverride && inheritedCodexHome === inheritedOrcaOverride) { - keysToDelete.push('CODEX_HOME') - } - return keysToDelete -} - -export type CodexHomeLaunchContext = { - workspacePath?: string - launchAgent?: TuiAgent - unavailableManagedHomePath?: string -} - -export type GetSelectedCodexHomePath = ( - target?: CodexAccountSelectionTarget, - launchEnv?: NodeJS.ProcessEnv, - launchContext?: CodexHomeLaunchContext -) => string | null -export type PrepareCodexSessionResume = (args: { - providerSession: AgentProviderSessionMetadata - target: CodexAccountSelectionTarget - launchEnv?: NodeJS.ProcessEnv - workspacePath?: string -}) => Promise - -export type CodexHomePtySpawnedLifecycleArgs = { - id: string - codexHomePath: string | null - reattached?: boolean - reattachedHomeRoute?: CodexPaneHomeRoute | null - launchEnv?: NodeJS.ProcessEnv - startedAt?: Date - startedSequence?: number -} - -let ptyLifecycleSequence = 0 - -type PrepareClaudeAuth = ( - target?: ClaudeAccountSelectionTarget -) => Promise - -function getCodexSelectionTargetForPty( - shellPath: string | undefined, - cwd: string | undefined, - wslDistro?: string | null -): CodexAccountSelectionTarget { - const wslPath = typeof cwd === 'string' ? parseWslPath(cwd) : null - if (isWslShellName(shellPath) || wslPath) { - return { runtime: 'wsl', wslDistro: wslPath?.distro ?? wslDistro ?? null } - } - return { runtime: 'host' } -} - -function getCompatibleSelectedCodexHomePath( - target: CodexAccountSelectionTarget, - selectedCodexHomePath: string | null -): string | null { - if (!selectedCodexHomePath) { - return null - } - const wslInfo = parseWslPath(selectedCodexHomePath) - if (target.runtime === 'wsl') { - return wslInfo || !isHostCodexHomeForWsl(selectedCodexHomePath) ? selectedCodexHomePath : null - } - return wslInfo || (process.platform === 'win32' && isWslCodexHomeForHost(selectedCodexHomePath)) - ? null - : selectedCodexHomePath -} - -const MANAGED_CODEX_AUTH_UNAVAILABLE_MESSAGE = - 'The selected Codex account credentials are temporarily unavailable. Try opening the terminal again.' -const CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE = - 'The Codex account credentials for this session are temporarily unavailable. Try opening the terminal again.' -const MANAGED_CODEX_HOME_UNAVAILABLE_MESSAGE = - 'Codex account files are temporarily locked. Retry in a moment.' - -/** - * Why: launch prep refuses an unreadable managed home by throwing instead of - * returning `null`, because `null` already means "launch the system default". - * Turn that refusal into the same shape as the auth-unavailable refusal above — - * a user-facing spawn rejection — so the pane never starts on another account's - * credentials (#STA-4422). - */ -function resolveSelectedCodexHomeOrRefuseSpawn(resolve: () => string | null): string | null { - try { - return resolve() - } catch (error) { - if (error instanceof ManagedCodexHomeTemporarilyUnavailableError) { - throw new Error(MANAGED_CODEX_HOME_UNAVAILABLE_MESSAGE, { cause: error }) - } - throw error - } -} - -type ManagedCodexAuthResolutionArgs = { - selectedCodexHomePath: string | null - getSettings: () => GlobalSettings | undefined - requiredCodexHomePath?: string - target: CodexAccountSelectionTarget - resolveCurrent: () => string | null - resolveAfterUnavailable: (unavailableManagedHomePath: string) => string | null -} - -export function resolveCodexHomeAfterManagedAuthReadiness( - args: ManagedCodexAuthResolutionArgs -): string | null | Promise { - const selectedCodexHomePath = args.selectedCodexHomePath - if ( - args.requiredCodexHomePath && - !codexHomePathsEqual(selectedCodexHomePath, args.requiredCodexHomePath) - ) { - throw new Error(CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE) - } - const readiness = waitForManagedCodexAuthReady({ - codexHomePath: selectedCodexHomePath, - settings: args.getSettings(), - target: args.target - }) - return readiness - ? continueCodexHomeAfterManagedAuthWait(args, selectedCodexHomePath, readiness) - : selectedCodexHomePath -} - -async function continueCodexHomeAfterManagedAuthWait( - args: ManagedCodexAuthResolutionArgs, - initialCodexHomePath: string | null, - initialReadiness: Promise -): Promise { - let selectedCodexHomePath = initialCodexHomePath - let readiness = initialReadiness - for (let attempt = 0; attempt < 2; attempt += 1) { - if (await readiness) { - if (args.requiredCodexHomePath) { - return selectedCodexHomePath - } - const currentCodexHomePath = args.resolveCurrent() - if (codexHomeSelectionsEqual(selectedCodexHomePath, currentCodexHomePath)) { - return selectedCodexHomePath - } - selectedCodexHomePath = currentCodexHomePath - if (attempt === 1) { - break - } - const nextReadiness = waitForManagedCodexAuthReady({ - codexHomePath: selectedCodexHomePath, - settings: args.getSettings(), - target: args.target - }) - if (!nextReadiness) { - return selectedCodexHomePath - } - readiness = nextReadiness - continue - } - if (args.requiredCodexHomePath) { - throw new Error(CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE) - } - selectedCodexHomePath = args.resolveAfterUnavailable(selectedCodexHomePath!) - if (attempt === 1) { - break - } - const nextReadiness = waitForManagedCodexAuthReady({ - codexHomePath: selectedCodexHomePath, - settings: args.getSettings(), - target: args.target - }) - if (!nextReadiness) { - return selectedCodexHomePath - } - readiness = nextReadiness - } - if ( - isCodexHomeAuthReadyForLaunch({ - codexHomePath: selectedCodexHomePath, - settings: args.getSettings(), - target: args.target - }) - ) { - return selectedCodexHomePath - } - throw new Error(MANAGED_CODEX_AUTH_UNAVAILABLE_MESSAGE) -} - -function codexHomeSelectionsEqual(left: string | null, right: string | null): boolean { - return ( - left === right || - (left !== null && - right !== null && - normalizeRuntimePathForComparison(left) === normalizeRuntimePathForComparison(right)) - ) -} - -function codexHomePathsEqual(left: string | null, right: string): boolean { - return codexHomeSelectionsEqual(left, right) -} - -// Why: CODEX_HOME is fixed in a shell's environment at spawn and the daemon -// keeps that shell alive across app restarts, so the launch account is the only -// way to tell later that a pane still runs Codex as the previously selected -// account. A reattach inherits that baked environment rather than choosing one, -// so re-recording it under the current selection would erase the very evidence -// that the pane is stale. -function recordCodexPaneAccountForSpawn(args: { - ptyId: string | undefined - isDaemonHostSpawn: boolean - isReattach: boolean - pinnedByResume: boolean - launchCodexHomePath: string | null - launchEnv?: NodeJS.ProcessEnv - target: CodexAccountSelectionTarget - settings: GlobalSettings | undefined -}): void { - if (!args.ptyId || !args.isDaemonHostSpawn || args.isReattach) { - return - } - const customHomeOverride = getCustomCodexHomeOverrideForLaunch(args.launchEnv) - const processHomeOverride = customHomeOverride ? getCustomCodexHomeOverrideForLaunch() : null - const recheckableEnvironmentOverride = - customHomeOverride?.source === 'environment' && - processHomeOverride?.source === 'environment' && - environmentCodexHomeOverrideContextsEqual( - customHomeOverride.context, - processHomeOverride.context - ) - ? customHomeOverride.context - : undefined - const recheckableShellStartupOverride = - customHomeOverride?.source === 'shell-startup' && - processHomeOverride?.source === 'shell-startup' && - shellStartupCodexHomeOverrideContextsEqual( - customHomeOverride.context, - processHomeOverride.context - ) - ? customHomeOverride.context - : undefined - const record = args.settings - ? resolveCodexPaneLaunchAccount({ - pinnedByResume: args.pinnedByResume, - launchCodexHomePath: args.launchCodexHomePath, - // Why: pane-local overrides cannot be re-derived when a restart builds - // a fresh launch env, so route prompts would guess and block valid input. - recordComparableHomeRoute: - args.pinnedByResume || - ((customHomeOverride?.source !== 'environment' || - recheckableEnvironmentOverride !== undefined) && - (customHomeOverride?.source !== 'shell-startup' || - recheckableShellStartupOverride !== undefined)), - shellStartupHomeOverride: args.pinnedByResume ? undefined : recheckableShellStartupOverride, - environmentHomeOverride: args.pinnedByResume ? undefined : recheckableEnvironmentOverride, - systemCodexHomePath: getSystemCodexHomePath(), - settings: args.settings, - target: args.target - }) - : null - if (!record) { - forgetCodexPaneAccount(args.ptyId) - return - } - recordCodexPaneAccount(args.ptyId, record) -} - -function snapshotCodexPaneHomeRoutes( - ptyIds: readonly (string | null | undefined)[] -): ReadonlyMap { - const routes = new Map() - for (const ptyId of ptyIds) { - if (!ptyId || routes.has(ptyId)) { - continue - } - routes.set(ptyId, getCodexPaneAccount(ptyId)?.homeRoute ?? null) - } - return routes -} - -function codexReattachedHomeRouteField( - routes: ReadonlyMap, - ptyId: string, - reattached: boolean -): { reattachedHomeRoute?: CodexPaneHomeRoute | null } { - if (!reattached || !routes.has(ptyId)) { - return {} - } - return { reattachedHomeRoute: routes.get(ptyId) ?? null } -} - -function readEnvWithProcessFallback( - baseEnv: Record, - key: string -): string | undefined { - return baseEnv[key] ?? process.env[key] -} - -function resolvePiAgentSourceDir( - baseEnv: Record, - kind: PiAgentKind -): string | undefined { - const sourceKey = SOURCE_AGENT_DIR_ENV_BY_KIND[kind] - const primaryKey = PRIMARY_AGENT_DIR_ENV_BY_KIND[kind] - - const sourceDir = readEnvWithProcessFallback(baseEnv, sourceKey) - if (sourceDir) { - return sourceDir - } - - if (kind === 'prime-agent') { - return ( - readEnvWithProcessFallback(baseEnv, primaryKey) ?? - readSessionShellStartupEnvVar(primaryKey, baseEnv) - ) - } - - const overlayKey = kind === 'omp' ? 'ORCA_OMP_CODING_AGENT_DIR' : 'ORCA_PI_CODING_AGENT_DIR' - const otherOverlayKey = kind === 'omp' ? 'ORCA_PI_CODING_AGENT_DIR' : 'ORCA_OMP_CODING_AGENT_DIR' - - const publicDir = readEnvWithProcessFallback(baseEnv, primaryKey) - const ownOverlayDir = readEnvWithProcessFallback(baseEnv, overlayKey) - const otherOverlayDir = readEnvWithProcessFallback(baseEnv, otherOverlayKey) - // Why: if PI_CODING_AGENT_DIR is a restored Orca overlay with no source shadow, remirroring leaks another agent's overlay tree; fall through to defaults. - if (publicDir && publicDir !== ownOverlayDir && publicDir !== otherOverlayDir) { - return publicDir - } - - return readSessionShellStartupEnvVar(primaryKey, baseEnv) -} - -function resolveScopedPiAgentSourceDir( - baseEnv: Record, - kind: PiAgentKind -): string | undefined { - return readEnvWithProcessFallback(baseEnv, SOURCE_AGENT_DIR_ENV_BY_KIND[kind]) -} - -function clearPiAgentShadowEnv(baseEnv: Record, kind: PiAgentKind): void { - if (kind === 'omp') { - delete baseEnv.ORCA_OMP_CODING_AGENT_DIR - delete baseEnv.ORCA_OMP_SOURCE_AGENT_DIR - delete baseEnv.ORCA_OMP_STATUS_EXTENSION - return - } - if (kind === 'prime-agent') { - delete baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR - delete baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION - return - } - delete baseEnv.ORCA_PI_CODING_AGENT_DIR - delete baseEnv.ORCA_PI_SOURCE_AGENT_DIR -} - -function exposePiManagedExtensionEnv( - baseEnv: Record, - kind: PiAgentKind, - managedEnv: Record -): void { - if (kind === 'omp') { - delete baseEnv.ORCA_OMP_CODING_AGENT_DIR - if (managedEnv.ORCA_OMP_SOURCE_AGENT_DIR) { - baseEnv.ORCA_OMP_SOURCE_AGENT_DIR = managedEnv.ORCA_OMP_SOURCE_AGENT_DIR - } else { - delete baseEnv.ORCA_OMP_SOURCE_AGENT_DIR - } - if (managedEnv.ORCA_OMP_STATUS_EXTENSION) { - baseEnv.ORCA_OMP_STATUS_EXTENSION = managedEnv.ORCA_OMP_STATUS_EXTENSION - } else { - delete baseEnv.ORCA_OMP_STATUS_EXTENSION - } - return - } - if (kind === 'prime-agent') { - if (managedEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR) { - baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR = managedEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR - } else { - delete baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR - } - return - } - delete baseEnv.ORCA_PI_CODING_AGENT_DIR - if (managedEnv.ORCA_PI_SOURCE_AGENT_DIR) { - baseEnv.ORCA_PI_SOURCE_AGENT_DIR = managedEnv.ORCA_PI_SOURCE_AGENT_DIR - } else { - delete baseEnv.ORCA_PI_SOURCE_AGENT_DIR - } -} - -// Why: variadic because a nested call per source made intermediate `string[] | undefined` collide with the parameter type. -function mergePtyEnvDeletions( - existingKeys: string[] | undefined, - ...additionalKeyGroups: readonly (readonly string[])[] -): string[] | undefined { - if (!existingKeys && additionalKeyGroups.every((keys) => keys.length === 0)) { - return undefined - } - return Array.from(new Set([...(existingKeys ?? []), ...additionalKeyGroups.flat()])) -} - -function removeCodexHomeDeletionRequests(keys: string[] | undefined): string[] | undefined { - // Why: resume provenance is launch-authoritative; late deletions must not fall back to the current account. - const filtered = keys?.filter((key) => key !== 'CODEX_HOME' && key !== 'ORCA_CODEX_HOME') - return filtered?.length ? filtered : undefined -} - -function getInheritedAgentHookEnvKeysToDelete( - spawnEnv: Record | undefined -): string[] { - const env = spawnEnv ?? {} - // Why: providers merge process.env after cleanup; delete stale hook keys without dropping fresh coordinates buildPtyHostEnv set. - return AGENT_HOOK_RUNTIME_ENV_KEYS.filter((key) => env[key] === undefined) -} - -function getInheritedClaudeSessionStampEnvKeysToDelete( - spawnEnv: Record | undefined -): string[] { - const env = spawnEnv ?? {} - // Why: strip only values inherited from the pty host; a caller that explicitly - // provides a stamp (deliberately spawning a nested Claude child) keeps it. - return CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS.filter((key) => env[key] === undefined) -} - -// Why: a nested terminal can inherit prior OpenCode/Pi/OMP overlay env; restore the user's recorded source dir, else strip only Orca-owned values. -function restoreOrStripOverlayEnv( - baseEnv: Record, - keys: { - primary: string - overlay: string - source: string - } -): void { - const sourceValue = baseEnv[keys.source] ?? process.env[keys.source] - const overlayValue = baseEnv[keys.overlay] ?? process.env[keys.overlay] - if (sourceValue) { - baseEnv[keys.primary] = sourceValue - } else if (overlayValue && baseEnv[keys.primary] === overlayValue) { - delete baseEnv[keys.primary] - } - delete baseEnv[keys.overlay] - delete baseEnv[keys.source] -} - -function isMimoLaunchCommand(launchCommand: string | undefined): boolean { - const binary = getCommandTokenPathBasename(getFirstCommandToken(launchCommand ?? '')) - .toLowerCase() - .replace(/\.(?:cmd|exe|sh)$/, '') - return binary === 'mimo' -} - -function resolveMimocodeSourceHome(baseEnv: Record): string | undefined { - const sourceHome = baseEnv.ORCA_MIMOCODE_SOURCE_HOME ?? process.env.ORCA_MIMOCODE_SOURCE_HOME - if (sourceHome) { - return sourceHome - } - const configHome = baseEnv.MIMOCODE_HOME ?? process.env.MIMOCODE_HOME - const orcaHome = baseEnv.ORCA_MIMOCODE_HOME ?? process.env.ORCA_MIMOCODE_HOME - if (configHome && orcaHome && configHome === orcaHome) { - return undefined - } - return configHome -} - -function resolveOpenCodeSourceConfigDir(baseEnv: Record): string | undefined { - const sourceDir = - baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR ?? process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR - if (sourceDir) { - return sourceDir - } - - const configDir = baseEnv.OPENCODE_CONFIG_DIR ?? process.env.OPENCODE_CONFIG_DIR - const orcaConfigDir = baseEnv.ORCA_OPENCODE_CONFIG_DIR ?? process.env.ORCA_OPENCODE_CONFIG_DIR - // Why: with no recorded source dir, an inherited OPENCODE_CONFIG_DIR is Orca-owned, not user config; treating it as user config makes child Orcas mirror the hook dir. - if (configDir && orcaConfigDir && configDir === orcaConfigDir) { - return undefined - } - - return configDir ?? readSessionShellStartupEnvVar('OPENCODE_CONFIG_DIR', baseEnv) -} - -/** - * Mutates `baseEnv` in place with all host-local PTY env vars and returns it. - * - * Do NOT call when `args.connectionId` is set (SSH): every injection is host-loopback - * or references local filesystem paths meaningless to a remote shell. - */ -export function buildPtyHostEnv( - id: string, - baseEnv: Record, - opts: BuildPtyHostEnvOptions -): Record { - mergePersistedWindowsPath(baseEnv) - Object.assign(baseEnv, buildConfiguredProxyEnv(opts.networkProxySettings)) - - // Why: local path's baseEnv includes process.env but the daemon path doesn't (fork inheritance, not IPC); check both sources so guards stay in lock-step across spawn paths. - const preexistingOpenCodeConfigDir = resolveOpenCodeSourceConfigDir(baseEnv) - const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(baseEnv, opts.launchCommand) - const explicitPiAgentKind = isPiCompatibleAgentType(opts.launchAgent) - ? opts.launchAgent - : opts.launchAgent === undefined - ? detectExplicitPiAgentKindFromCommand(launchCommandHint) - : null - const piAgentKind = explicitPiAgentKind ?? 'pi' - const hasLaunchCommand = - typeof launchCommandHint === 'string' && launchCommandHint.trim().length > 0 - - // Why: unattended agents must fail instead of looping on OS credential prompts; user terminals keep normal Git behavior. - applyTerminalGitCredentialPromptGuard(baseEnv, { - launchCommand: launchCommandHint, - isUnattended: opts.launchAgent !== undefined, - deferGitConfigGuardToHost: opts.deferGitConfigGuardToDaemon - }) - - const shouldPrepareOmpShadow = piAgentKind === 'omp' || !hasLaunchCommand - // Why: source shadows are agent-scoped; trusting the other kind's source reintroduces Pi/OMP extension-state shadowing. - const preexistingPiAgentDir = resolvePiAgentSourceDir(baseEnv, 'pi') - const preexistingOmpAgentDir = - piAgentKind === 'omp' - ? resolvePiAgentSourceDir(baseEnv, 'omp') - : resolveScopedPiAgentSourceDir(baseEnv, 'omp') - const preexistingPrimeAgentDir = - piAgentKind === 'prime-agent' - ? resolvePiAgentSourceDir(baseEnv, 'prime-agent') - : resolveScopedPiAgentSourceDir(baseEnv, 'prime-agent') - - if (opts.agentStatusHooksEnabled) { - // Why: OPENCODE_CONFIG_DIR is a single path, not a colon-list; mirror the user's value into an overlay so their plugins and Orca's status plugin coexist. See docs/opencode-config-dir-collision.md. - Object.assign(baseEnv, openCodeHookService.buildPtyEnv(id, preexistingOpenCodeConfigDir)) - if (baseEnv.OPENCODE_CONFIG_DIR) { - // Why: ~/.zshrc can re-export the user's default after spawn; shell-ready wrappers restore this PTY-scoped value. - baseEnv.ORCA_OPENCODE_CONFIG_DIR = baseEnv.OPENCODE_CONFIG_DIR - if (preexistingOpenCodeConfigDir) { - // Why: nested Orca terminals inherit the overlay as OPENCODE_CONFIG_DIR; keep the real source so overlays don't mirror overlays. - baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR = preexistingOpenCodeConfigDir - } else { - delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR - } - } - if (isMimoLaunchCommand(launchCommandHint)) { - const preexistingMimocodeHome = resolveMimocodeSourceHome(baseEnv) - Object.assign(baseEnv, mimoCodeHookService.buildPtyEnv(id, preexistingMimocodeHome)) - if (baseEnv.MIMOCODE_HOME) { - baseEnv.ORCA_MIMOCODE_HOME = baseEnv.MIMOCODE_HOME - if (preexistingMimocodeHome) { - baseEnv.ORCA_MIMOCODE_SOURCE_HOME = preexistingMimocodeHome - } else { - delete baseEnv.ORCA_MIMOCODE_SOURCE_HOME - } - } - } - } else { - restoreOrStripOverlayEnv(baseEnv, { - primary: 'OPENCODE_CONFIG_DIR', - overlay: 'ORCA_OPENCODE_CONFIG_DIR', - source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR' - }) - restoreOrStripOverlayEnv(baseEnv, { - primary: 'MIMOCODE_HOME', - overlay: 'ORCA_MIMOCODE_HOME', - source: 'ORCA_MIMOCODE_SOURCE_HOME' - }) - } - - // Why: strip inherited hook coordinates before injecting this PTY's fresh loopback receiver, so nested-terminal callbacks route to the owning pane. - for (const key of AGENT_HOOK_RUNTIME_ENV_KEYS) { - delete baseEnv[key] - } - if (opts.agentStatusHooksEnabled) { - Object.assign(baseEnv, agentHookServer.buildPtyEnv()) - if (opts.isWsl === true) { - // Why: hook POSTs to 127.0.0.1 die inside WSL's NAT namespace; use the guest-resident relay's endpoint instead of the Windows one. - const distro = opts.wslDistro ?? null - wslHookRelayManager.ensureForDistro(distro) - const guestEndpoint = wslHookRelayManager.getGuestEndpointFilePath(distro) - if (guestEndpoint) { - baseEnv.ORCA_AGENT_HOOK_ENDPOINT = guestEndpoint - } - // Why: OpenCode loads its status plugin from a guest config overlay, so point OPENCODE_CONFIG_DIR at the guest dir the relay materialized. - const opencodeOverlayDir = wslHookRelayManager.getOpenCodeOverlayDir(distro) - if (opencodeOverlayDir) { - baseEnv.OPENCODE_CONFIG_DIR = opencodeOverlayDir - baseEnv.ORCA_OPENCODE_CONFIG_DIR = opencodeOverlayDir - delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR - } else { - // Why: relay not connected yet (or older guest bundle) — never cross the Windows overlay path into WSL; drop it so in-guest OpenCode uses its own config (pre-fix behavior, no status but no regression). - delete baseEnv.OPENCODE_CONFIG_DIR - delete baseEnv.ORCA_OPENCODE_CONFIG_DIR - delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR - } - } - } - - // Why: PI_CODING_AGENT_DIR is the user's config/session root; install only Orca-owned extension files, don't override it. - if (opts.agentStatusHooksEnabled) { - clearPiAgentShadowEnv(baseEnv, 'pi') - clearPiAgentShadowEnv(baseEnv, 'omp') - clearPiAgentShadowEnv(baseEnv, 'prime-agent') - // Why: bare shells historically defaulted to Pi + OMP shadow prep and - // created ~/./agent even when the user never launches those agents - // (#10196). Only create default homes on an explicit Pi/OMP launch; - // otherwise install only into an existing agent dir (or userData for OMP - // status so a typed `omp` still gets the shell wrapper extension). - if (piAgentKind === 'pi') { - const piEnv = piTitlebarExtensionService.buildPtyEnv(id, preexistingPiAgentDir, 'pi', { - materializeDefaultHome: explicitPiAgentKind === 'pi' - }) - Object.assign(baseEnv, piEnv) - exposePiManagedExtensionEnv(baseEnv, 'pi', piEnv) - } - - if (shouldPrepareOmpShadow) { - const ompEnv = piTitlebarExtensionService.buildPtyEnv(id, preexistingOmpAgentDir, 'omp', { - materializeDefaultHome: explicitPiAgentKind === 'omp' - }) - Object.assign(baseEnv, ompEnv) - exposePiManagedExtensionEnv(baseEnv, 'omp', ompEnv) - } - - if (piAgentKind === 'prime-agent' && !opts.isWsl) { - const primeEnv = piTitlebarExtensionService.buildPtyEnv( - id, - preexistingPrimeAgentDir, - 'prime-agent', - { materializeDefaultHome: explicitPiAgentKind === 'prime-agent' } - ) - Object.assign(baseEnv, primeEnv) - exposePiManagedExtensionEnv(baseEnv, 'prime-agent', primeEnv) - } - } else { - // Why: nested PTYs must not inherit stale source or overlay state from another agent. - restoreOrStripOverlayEnv(baseEnv, { - primary: 'PI_CODING_AGENT_DIR', - overlay: 'ORCA_PI_CODING_AGENT_DIR', - source: 'ORCA_PI_SOURCE_AGENT_DIR' - }) - restoreOrStripOverlayEnv(baseEnv, { - primary: 'PI_CODING_AGENT_DIR', - overlay: 'ORCA_OMP_CODING_AGENT_DIR', - source: 'ORCA_OMP_SOURCE_AGENT_DIR' - }) - delete baseEnv.ORCA_OMP_STATUS_EXTENSION - delete baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR - delete baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION - } - - // Why: keep the Codex home override PTY-scoped so dev/prod Orcas don't share hooks through ~/.codex. - if (opts.skipCodexHomeEnv) { - delete baseEnv.CODEX_HOME - delete baseEnv.ORCA_CODEX_HOME - delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT - } else if (opts.selectedCodexHomePath) { - baseEnv.CODEX_HOME = opts.selectedCodexHomePath - // Why: user startup files may re-export CODEX_HOME; shell-ready wrappers restore this runtime home before Codex launches. - baseEnv.ORCA_CODEX_HOME = opts.selectedCodexHomePath - const preflightCommand = resolveCodexShellLaunchPreflightCommand({ - hooksEnabled: opts.codexStatusHooksEnabled ?? opts.agentStatusHooksEnabled, - isPackaged: opts.isPackaged, - isWsl: opts.isWsl, - managedHomePath: opts.selectedCodexHomePath, - userDataPath: opts.userDataPath, - resourcesPath: opts.resourcesPath - }) - if (preflightCommand) { - baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT = preflightCommand - } else { - delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT - } - } else if (opts.stripInheritedOrcaCodexHome) { - stripInheritedOrcaCodexHomeOverride(baseEnv) - delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT - } else { - delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT - } - - // Why: WSL shells need the managed userData root for shell-ready wrappers; dev-mode terminals need the same export so `orca` targets the live dev instance. - if (opts.isWsl) { - baseEnv.ORCA_USER_DATA_PATH = opts.userDataPath - // Why: managed WSL registration uses `orca-ide`; exposing that literal scopes agent guidance to WSL without a bare-orca shim. - baseEnv.ORCA_CLI_COMMAND = opts.isPackaged ? 'orca-ide' : 'orca-dev' - } else { - if (!opts.isPackaged) { - baseEnv.ORCA_USER_DATA_PATH ??= opts.userDataPath - } - delete baseEnv.ORCA_CLI_COMMAND - } - // Why: dev mode needs the launcher PATH override so `orca` resolves to the dev build instead of the production binary at /usr/local/bin/orca. - if (!opts.isPackaged) { - const devCliBin = join(opts.userDataPath, 'cli', 'bin') - const inheritedPath = readInheritedPath(baseEnv) - // Why: an empty PATH segment resolves as `.` in some shells (commands run from cwd); avoid a trailing delimiter. - baseEnv[resolvePathEnvKey(baseEnv, process.platform)] = inheritedPath - ? `${devCliBin}${delimiter}${inheritedPath}` - : devCliBin - } else if (process.platform === 'linux') { - // Why: bare-`orca` shim scoped to Orca PTYs — Linux CLI installs as `orca-ide` to avoid shadowing GNOME's /usr/bin/orca screen reader (stablyai/orca#7904). - const shimDir = ensureLinuxTerminalOrcaCliShimDir({ userDataPath: opts.userDataPath }) - if (shimDir) { - const inheritedEntries = readInheritedPath(baseEnv) - .split(delimiter) - .filter((entry) => entry.length > 0 && entry !== shimDir) - baseEnv.PATH = [shimDir, ...inheritedEntries].join(delimiter) - } - } else if ( - opts.resourcesPath && - (process.platform === 'darwin' || process.platform === 'win32') - ) { - // Why: global CLI registration is optional, but agents in Orca-managed PTYs must always reach this app's bundled CLI. - const bundledCliBin = join(opts.resourcesPath, 'bin') - const inheritedPath = readInheritedPath(baseEnv) - baseEnv[resolvePathEnvKey(baseEnv, process.platform)] = inheritedPath - ? `${bundledCliBin}${delimiter}${inheritedPath}` - : bundledCliBin - } - - // Why: must run after the prepends above — they re-read PATH from the unscrubbed - // process.env when baseEnv carries none, which is the daemon path's normal shape. - stripLegacyTerminalShimEnv(baseEnv, process.platform) - - return baseEnv -} - -function isClaudeLaunchCommand(command: string | undefined): boolean { - if (!command) { - return false - } - return /(^|[\s;&|('"`])(?:[^\s;&|('"`]*[\\/])?claude(?:\.cmd|\.exe)?($|[\s;&|)'"`])/i.test( - command - ) -} - -function routesFreshSpawnsToLocalProvider(provider: IPtyProvider): boolean { - return provider.routesFreshSpawnsToLocalProvider === true -} - -function recoverFreshSpawnProviderRouting( - provider: IPtyProvider, - connectionId: string | null | undefined, - sessionId: string | undefined, - isNewSession = sessionId === undefined -): Promise | undefined { - if (connectionId || (!isNewSession && sessionId) || !routesFreshSpawnsToLocalProvider(provider)) { - return - } - return provider.recoverFreshSpawnRouting?.() -} - -function beginPtySpawnForWorktree( - worktreeId: string | undefined, - cwd: string | undefined, - connectionId: string | null | undefined -): () => void { - const worktreePath = worktreeId - ? splitWorktreeIdForFilesystem(worktreeId)?.worktreePath - : undefined - const installPaths = new Map() - for (const candidate of [worktreePath, cwd]) { - if (candidate) { - installPaths.set(normalizeRuntimePathForComparison(candidate), candidate) - } - } - const finishes: (() => void)[] = [] - try { - for (const candidate of installPaths.values()) { - finishes.push(beginTerminalInstall(candidate, connectionId ?? undefined)) - } - } catch (error) { - // Why: worktree ID and cwd can be different roots; release earlier admissions before rejecting. - finishes.toReversed().forEach((finish) => finish()) - throw error - } - return () => finishes.toReversed().forEach((finish) => finish()) -} - -/** Register an SSH PTY provider for a connection. */ -export function registerSshPtyProvider(connectionId: string, provider: IPtyProvider): void { - sshProviders.set(connectionId, provider) - const generation = (provider as { providerGeneration?: number }).providerGeneration - if (Number.isSafeInteger(generation) && generation! > 0) { - sshProvidersByGeneration.set(generation!, provider) - } -} - -/** Remove an SSH PTY provider when a connection is closed. */ -export function unregisterSshPtyProvider(connectionId: string): void { - const provider = sshProviders.get(connectionId) - const generation = (provider as { providerGeneration?: number } | undefined)?.providerGeneration - if (generation !== undefined && sshProvidersByGeneration.get(generation) === provider) { - sshProvidersByGeneration.delete(generation) - } - sshProviders.delete(connectionId) -} - -/** Get the SSH PTY provider for a connection (for dispose on cleanup). */ -export function getSshPtyProvider(connectionId: string): IPtyProvider | undefined { - return sshProviders.get(connectionId) -} - -/** Get the installed PTY provider (for direct access in tests/runtime). - * After daemon init this may be a DaemonPtyAdapter/DaemonPtyRouter, not LocalPtyProvider; - * callers needing LocalPtyProvider-specific methods must type-narrow or import the class. */ -export function getLocalPtyProvider(): IPtyProvider { - return localProvider -} - -/** Replace the local PTY provider with a daemon-backed one. - * Call before registerPtyHandlers so the IPC layer routes through the daemon. */ -export function setLocalPtyProvider(provider: IPtyProvider): void { - localProvider = provider -} - -/** Get all PTY IDs owned by a given connectionId (for reconnection reattach). */ -export function getPtyIdsForConnection(connectionId: string): string[] { - const ids: string[] = [] - for (const [ptyId, connId] of ptyOwnership) { - if (connId === connectionId) { - ids.push(ptyId) - } - } - return ids -} - -/** - * Remove transient PTY routing entries for a disconnected connection. - * Claimed agent owners remain fenced because the relay process may survive and - * prove the exact same generation after reconnect. - */ -export function clearPtyOwnershipForConnection(connectionId: string): void { - for (const [ptyId, connId] of ptyOwnership) { - if (connId === connectionId) { - // Why: pane-scoped caches cannot route while disconnected, but claimed - // ownership must survive until reconnect makes absence authoritative. - clearProviderPtyState(ptyId, { preserveAgentSessionOwners: true }) - ptyOwnership.delete(ptyId) - } - } -} - -// ─── Provider-scoped PTY state cleanup ────────────────────────────── - -export function clearProviderPtyState( - id: string, - opts: { preserveAgentSessionOwners?: boolean } = {} -): void { - if (!opts.preserveAgentSessionOwners) { - agentSessionOwners.release(id) - // Why: the launch-account record outlives the app, so only a real teardown - // may drop it — a disconnect that can reconnect is not a death, and a reused - // id must never inherit a dead pane's Codex account. - forgetCodexPaneAccount(id) - } - // Why: OpenCode and Pi both allocate PTY-scoped runtime state outside the - // node-pty process table. Centralizing provider cleanup avoids drift where a - // new teardown path forgets to remove one provider's overlay/hook state. - openCodeHookService.clearPty(id) - piTitlebarExtensionService.clearPty(id) - // Why: SSH exit/teardown paths bypass pty.ts's local onExit but still must release Claude account-switch guards. - markClaudePtyExited(id) - ptySizes.delete(id) - ptyIncarnationById.delete(id) - lastInputAtByPty.delete(id) - interactiveOutputCharsByPty.delete(id) - const activeChanged = activeRendererPtys.delete(id) - visibleRendererPtys.delete(id) - rendererVisibilityKnownPtys.delete(id) - pendingHiddenRendererResizeOutputPtys.delete(id) - deliveredHiddenRendererResizeOutputPtys.delete(id) - // Why: every teardown path funnels through here — hidden/interest gate bits must not outlive the PTY or a reused map entry could silently gate a new one. - const deliveryPolicyChanged = isHiddenRendererPty(id) - clearHiddenRendererPtyDeliveryState(id) - if (activeChanged) { - invalidatePendingPtyDrainPriority(id, false) - } - if (deliveryPolicyChanged) { - invalidatePendingPtyDrainPolicy(id, false) - } - clearBackgroundedDeliverySyncForPty(id) - providerSnapshotRequiredPtys.delete(id) - // Why: the Phase-5 ConPTY DA1 spawn record must not leak onto a reused id. - clearNativeWindowsConptyPty(id) - const paneKey = ptyPaneKey.get(id) - const stillOwnsPaneKey = paneKey ? paneKeyPtyId.get(paneKey) === id : false - // Why: drop the memory-collector registration so a dead PTY doesn't resolve its dead pid on every snapshot; no-op for never-registered (SSH-owned) PTYs. - unregisterPty(id) - // Why: cover paths that bypass runtime.onPtyExit (SSH reattach/shutdown, daemon spawn-failure) — else the watcher's per-PTY buffer and worktree binding outlive the PTY. - advertisedUrlWatcher.unbindPty(id) - clearMigrationUnsupportedPty(id) - agentHookServer.clearPaneKeyAliasesForPty(id, { - shouldClearStablePaneKey: (stablePaneKey) => { - // Why: when this PTY never rebuilt ptyPaneKey after restart, alias ownership is our only proof — don't erase a newer PTY that now owns the same stable paneKey. - const stablePaneOwner = paneKeyPtyId.get(stablePaneKey) - if (stablePaneOwner && stablePaneOwner !== id) { - return false - } - return !paneKey || (stillOwnsPaneKey && stablePaneKey === paneKey) - } - }) - // Why: clear the hook server's per-paneKey caches (via the spawn-time paneKey mapping, its only ptyId→paneKey correlation) so dead panes don't accumulate over process lifetime. - if (paneKey) { - if (stillOwnsPaneKey) { - agentHookServer.clearPaneState(paneKey) - paneKeyPtyId.delete(paneKey) - } - ptyPaneKey.delete(id) - if (stillOwnsPaneKey) { - // Why: notify AFTER dropping the paneKey↔ptyId entries so a listener re-reading the map sees post-teardown state; wrap each so one throw can't block the rest. - for (const listener of paneKeyTeardownListeners) { - try { - listener(paneKey) - } catch (err) { - console.error('[pty] paneKey teardown listener threw', err) - } - } - } - } -} - -export function deletePtyOwnership(id: string): void { - ptyOwnership.delete(id) -} - -export function setPtyOwnership(id: string, connectionId: string | null): void { - ptyOwnership.set(id, connectionId) -} - -export function restorePtyIncarnation(id: string, incarnationId: string): void { - if (!isPtyIncarnationId(incarnationId)) { - throw new Error('Invalid PTY incarnation') - } - ptyIncarnationById.set(id, incarnationId) -} - -// Why: localProvider.onData/onExit return unsubscribe functions. Without -// storing and calling these on re-registration, macOS app re-activation -// creates a new BrowserWindow and re-calls registerPtyHandlers, leaking -// duplicate listeners that forward every event twice. -let localDataUnsub: (() => void) | null = null -let localExitUnsub: (() => void) | null = null -let localBackgroundStreamUnsub: (() => void) | null = null -let localWriteUnavailableUnsub: (() => void) | null = null -let didFinishLoadHandler: (() => void) | null = null -let didFinishLoadWebContents: WebContents | null = null -let rendererLifecycleResetWebContents: WebContents | null = null -let rendererLifecycleResetHandler: (() => void) | null = null -// Why: the hidden-delivery gate registries mirror renderer state; a reload/crash destroys owners without unregistering, so they reset when the renderer is replaced (drop memory preserved). -let rendererGateResetLoadHandler: (() => void) | null = null -let rendererGateResetGoneHandler: (() => void) | null = null -let rendererGateResetWebContents: WebContents | null = null -// Why: the backgrounded-delivery dedupe map lives in the registerPtyHandlers closure but teardown funnels through module-scope clearProviderPtyState. -let clearBackgroundedDeliverySyncForPty: (id: string) => void = () => {} -// Why: after daemon keep-tail thinning main's mirror holds only the kept tail, so recovery must keep consulting the daemon's complete model until exit. -const providerSnapshotRequiredPtys = new Set() -type RendererNavigationDetails = { - isMainFrame: boolean - isSameDocument: boolean -} - -// Why: navigation details identify the triggering frame; querying aggregate load state can misclassify an overlapping subframe load. -let rendererDidStartNavigationHandler: ((details: RendererNavigationDetails) => void) | null = null - -// Why: Restart daemon must re-bind provider→renderer listeners after replaceDaemonProvider swaps localProvider, else subscribers stay bound to the disposed adapter and new PTY data silently drops. -let rebindProviderListeners: (() => void) | null = null -let sshOutputIntakeCleanup: (() => void) | null = null - -export function rebindLocalProviderListeners(): void { - rebindProviderListeners?.() -} - -export type PtyRendererDeliveryDebugSnapshot = { - pendingPtyCount: number - pendingChars: number - maxPendingCharsByPty: number - rendererInFlightPtyCount: number - rendererInFlightChars: number - maxRendererInFlightCharsByPty: number - activeRendererPtyCount: number - flushScheduled: boolean - peakPendingChars: number - peakMaxPendingCharsByPty: number - peakRendererInFlightChars: number - peakMaxRendererInFlightCharsByPty: number - ackGatedFlushSkipCount: number - hiddenDeliveryGatedPtyCount: number - /** Hidden-gated ptys the renderer ALSO reports visible/active — a contradiction that should be zero (v1.4.124-rc.2.perf field lead). */ - hiddenDeliveryGatedVisiblePtyCount: number - hiddenDeliveryGatedActivePtyCount: number - deliveryInterestPtyCount: number - hiddenDeliveryDroppedChars: number - hiddenDeliveryDroppedChunks: number - pendingDroppedChars: number - /** One-paste freeze diagnostics: per-pty delivery table + event history. */ - diagnostics: PtyMainDeliveryDiagnostics - // Why: a nonzero lastLifecycleResetClearedChars is the exact signature of the leaked-accounting freeze this reset fixes. - rendererLifecycleResetCount: number - lastLifecycleResetClearedChars: number - // Why: the boot-window hold early-returns before ackGatedFlushSkipCount++, so these expose an otherwise-invisible held gate; forcedCount > 0 flags a watchdog self-heal. - rendererPtyDispatcherReady: boolean - rendererDispatcherReadyForcedCount: number -} - -// Why module scope: breadcrumb writers live both inside registerPtyHandlers and outside it (renderer lifecycle resets). -const mainDeliveryBreadcrumbs = createPtyDeliveryBreadcrumbRing() -let lastPowerSuspendAtMs: number | null = null -let lastPowerResumeAtMs: number | null = null -let powerSignalBreadcrumbsInstalled = false - -// Why: both field freeze variants correlate with display sleep; suspend/resume timestamps let breadcrumbs line up against the wake. -function installPowerSignalBreadcrumbs(power: PtyPowerSurface): void { - if (powerSignalBreadcrumbsInstalled) { - return - } - powerSignalBreadcrumbsInstalled = true - power.on('suspend', () => { - lastPowerSuspendAtMs = Date.now() - mainDeliveryBreadcrumbs.record('power-suspend') - }) - power.on('resume', () => { - lastPowerResumeAtMs = Date.now() - mainDeliveryBreadcrumbs.record('power-resume') - }) -} - -const EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT: PtyRendererDeliveryDebugSnapshot = { - pendingPtyCount: 0, - pendingChars: 0, - maxPendingCharsByPty: 0, - rendererInFlightPtyCount: 0, - rendererInFlightChars: 0, - maxRendererInFlightCharsByPty: 0, - activeRendererPtyCount: 0, - flushScheduled: false, - peakPendingChars: 0, - peakMaxPendingCharsByPty: 0, - peakRendererInFlightChars: 0, - peakMaxRendererInFlightCharsByPty: 0, - ackGatedFlushSkipCount: 0, - hiddenDeliveryGatedPtyCount: 0, - hiddenDeliveryGatedVisiblePtyCount: 0, - hiddenDeliveryGatedActivePtyCount: 0, - deliveryInterestPtyCount: 0, - hiddenDeliveryDroppedChars: 0, - hiddenDeliveryDroppedChunks: 0, - pendingDroppedChars: 0, - diagnostics: EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS, - rendererLifecycleResetCount: 0, - lastLifecycleResetClearedChars: 0, - rendererPtyDispatcherReady: false, - rendererDispatcherReadyForcedCount: 0 -} - -let readPtyRendererDeliveryDebugSnapshot = (): PtyRendererDeliveryDebugSnapshot => ({ - ...EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT -}) -let resetPtyRendererDeliveryDebugSnapshot = (): void => {} -// Bridged into the registerPtyHandlers closure so the module-scope lifecycle-reset handler can zero closure-owned delivery accounting on renderer reload/crash. -let resetRendererDeliveryAccountingForLifecycleReset = (): void => {} -// Bridged so a re-registration can cancel the prior closure's dispatcher-ready watchdog before wiring its own. -let clearRendererDispatcherReadyWatchdog = (): void => {} - -export function getPtyRendererDeliveryDebugSnapshot(): PtyRendererDeliveryDebugSnapshot { - return readPtyRendererDeliveryDebugSnapshot() -} - -export function resetPtyRendererDeliveryDebug(): void { - resetPtyRendererDeliveryDebugSnapshot() -} - -function clearDidFinishLoadHandler(): void { - if (didFinishLoadHandler && didFinishLoadWebContents) { - didFinishLoadWebContents.removeListener('did-finish-load', didFinishLoadHandler) - } - didFinishLoadHandler = null - didFinishLoadWebContents = null -} - -function markRendererPtysHiddenForRendererLifecycleReset(): void { - // A reload/crash in the breadcrumb history is load-bearing context for any freeze report. - mainDeliveryBreadcrumbs.record('renderer-lifecycle-reset') - // Why: renderer-owned hints die with the page; clear visibility so surviving daemon/SSH PTYs fail closed until the new renderer reports. - const activePriorityChanged = activeRendererPtys.size > 0 - activeRendererPtys.clear() - visibleRendererPtys.clear() - // Why: the dead page never ACKs its in-flight bytes, so leaked accounting would delivery-gate surviving PTYs forever after a reload/crash. - resetRendererDeliveryAccountingForLifecycleReset() - if (activePriorityChanged) { - invalidatePendingPtyDrainPriority() - } -} - -function clearRendererLifecycleResetHandlers(): void { - if (!rendererLifecycleResetWebContents) { - return - } - if (rendererDidStartNavigationHandler) { - rendererLifecycleResetWebContents.removeListener( - 'did-start-navigation', - rendererDidStartNavigationHandler - ) - } - if (rendererLifecycleResetHandler) { - rendererLifecycleResetWebContents.removeListener( - 'render-process-gone', - rendererLifecycleResetHandler - ) - rendererLifecycleResetWebContents.removeListener('destroyed', rendererLifecycleResetHandler) - } - rendererLifecycleResetWebContents = null - rendererLifecycleResetHandler = null - rendererDidStartNavigationHandler = null -} - -function registerRendererLifecycleResetHandlers(webContents: WebContents): void { - clearRendererLifecycleResetHandlers() - markRendererPtysHiddenForRendererLifecycleReset() - rendererLifecycleResetWebContents = webContents - rendererLifecycleResetHandler = markRendererPtysHiddenForRendererLifecycleReset - rendererDidStartNavigationHandler = (details) => { - if (!details.isMainFrame || details.isSameDocument) { - return - } - markRendererPtysHiddenForRendererLifecycleReset() - } - webContents.on('did-start-navigation', rendererDidStartNavigationHandler) - webContents.on('render-process-gone', rendererLifecycleResetHandler) - webContents.on('destroyed', rendererLifecycleResetHandler) -} - -function clearRendererGateResetHandlers(): void { - if (rendererGateResetWebContents) { - if (rendererGateResetLoadHandler) { - rendererGateResetWebContents.removeListener('did-finish-load', rendererGateResetLoadHandler) - } - if (rendererGateResetGoneHandler) { - rendererGateResetWebContents.removeListener( - 'render-process-gone', - rendererGateResetGoneHandler - ) - } - } - rendererGateResetLoadHandler = null - rendererGateResetGoneHandler = null - rendererGateResetWebContents = null -} - -// Why: Restart daemon must detach listeners AFTER synthetic pty:exit events fan out but BEFORE replaceDaemonProvider swaps the adapter; this export narrows that window to the caller. -export function unbindLocalProviderListeners(): void { - localDataUnsub?.() - localExitUnsub?.() - localBackgroundStreamUnsub?.() - localWriteUnavailableUnsub?.() - localDataUnsub = null - localExitUnsub = null - localBackgroundStreamUnsub = null - localWriteUnavailableUnsub = null -} - -// ─── IPC Registration ─────────────────────────────────────────────── - -export function registerPtyHandlers( - mainWindow: BrowserWindow | null, - runtime?: OrcaRuntimeService, - getSelectedCodexHomePath?: GetSelectedCodexHomePath, - getSettings?: () => GlobalSettings, - prepareClaudeAuth?: PrepareClaudeAuth, - store?: Store, - options?: { - prepareCodexSessionResume?: PrepareCodexSessionResume - awaitLocalPtyStartup?: () => Promise - awaitLocalPtyProviderStartup?: () => Promise - // Why: returns true once for the crash-recovery reload so its did-finish-load skips the orphan sweep and keeps live PTYs (#5787). - isRecoveryReloadInFlight?: (webContentsId: number) => boolean - onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void - onPtyExit?: (id: string, exitSequence: number) => void - } -): void { - const ipc = getPtyIpc() - // Why: a re-registration means a new window owns delivery — cancel the prior closure's watchdog and neutralize its bridged reset so mark-hidden below can't arm a timer against the dead closure. - clearRendererDispatcherReadyWatchdog() - resetRendererDeliveryAccountingForLifecycleReset = () => {} - invalidatePendingPtyDrainPriority = () => {} - invalidatePendingPtyDrainPolicy = () => {} - const rendererContents = rendererWebContents(mainWindow) - if (rendererContents) { - registerRendererLifecycleResetHandlers(rendererContents) - } - - const getLocalPtyStartupPromise = (connectionId?: string | null): Promise | undefined => { - if (connectionId) { - return undefined - } - // Why: during cold start the daemon provider swap overlaps first paint, so local spawns must wait; SSH/headless don't use the desktop daemon. - return options?.awaitLocalPtyStartup?.() - } - - const getLocalPtyProviderStartupPromise = ( - connectionId?: string | null - ): Promise | undefined => { - if (connectionId) { - return undefined - } - return options?.awaitLocalPtyProviderStartup?.() ?? options?.awaitLocalPtyStartup?.() - } - - // Remove prior handlers so re-registration (e.g. macOS re-activate creating a new window) doesn't double-register. - ipc.removeHandler('pty:spawn') - ipc.removeHandler('pty:kill') - ipc.removeHandler('pty:listSessions') - ipc.removeHandler('pty:hasPty') - ipc.removeHandler('pty:hasChildProcesses') - ipc.removeHandler('pty:getForegroundProcess') - ipc.removeHandler('pty:inspectProcess') - ipc.removeHandler('pty:confirmForegroundProcess') - ipc.removeHandler('pty:getCwd') - ipc.removeHandler('pty:getSize') - ipc.removeHandler('pty:getAuthoritativeBufferSnapshotCapabilities') - ipc.removeHandler('pty:declarePendingPaneSerializer') - ipc.removeHandler('pty:settlePaneSerializer') - ipc.removeHandler('pty:clearPendingPaneSerializer') - ipc.removeHandler('pty:reportRendererSerializerReady') - ipc.removeHandler('pty:getMainBufferSnapshot') - ipc.removeHandler('pty:sideEffectSnapshot') - ipc.removeHandler('pty:getRendererDeliveryDebugSnapshot') - ipc.removeHandler('pty:resetRendererDeliveryDebug') - ipc.removeHandler('pty:reportRendererDeliveryState') - ipc.removeHandler('pty:writeAccepted') - ipc.removeAllListeners('pty:write') - ipc.removeAllListeners('pty:ackColdRestore') - ipc.removeAllListeners('pty:ackData') - ipc.removeAllListeners('pty:deliveryResyncResponse') - ipc.removeAllListeners('pty:serializeBuffer:response') - - // Why: only LocalPtyProvider needs main-process hook injection; daemon-backed providers spawn subprocesses internally. - if (localProvider instanceof LocalPtyProvider) { - localProvider.configure({ - isHistoryEnabled: () => getSettings?.()?.terminalScopeHistoryByWorktree ?? true, - getWindowsShell: () => getSettings?.()?.terminalWindowsShell, - getWindowsPowerShellImplementation: () => - getSettings - ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') - : undefined, - pwshAvailable: () => isPwshAvailableAsync(), - buildSpawnEnv: (id, baseEnv, ctx) => { - const codexSelectionTarget: CodexAccountSelectionTarget = - ctx?.isWsl === true - ? { runtime: 'wsl', wslDistro: ctx.wslDistro ?? null } - : { runtime: 'host' } - const selectedCodexHomePath = getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - ctx?.codexHomePathOverride - ? ctx.codexHomePathOverride.value - : (getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { - workspacePath: ctx?.cwd, - launchAgent: ctx?.launchAgent - }) ?? null) - ) - const skipCodexHomeEnv = ctx?.isWsl === true && !selectedCodexHomePath - const ptySettings = getSettings?.() - const env = buildPtyHostEnv(id, baseEnv, { - isPackaged: getAppEnvironment().isPackaged(), - resourcesPath: process.resourcesPath, - userDataPath: getAppEnvironment().getPath('userData'), - selectedCodexHomePath, - skipCodexHomeEnv, - stripInheritedOrcaCodexHome: shouldStripInheritedOrcaCodexHome({ - target: codexSelectionTarget, - selectedCodexHomePath, - skipCodexHomeEnv, - settings: ptySettings - }), - launchCommand: ctx?.command, - launchAgent: ctx?.launchAgent, - isWsl: ctx?.isWsl, - wslDistro: ctx?.wslDistro ?? null, - agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings), - codexStatusHooksEnabled: isCodexStatusHooksEnabled(ptySettings), - networkProxySettings: ptySettings - }) - // Why: agents need their terminal handle at process start to self-identify in orchestration messages without an extra RPC. - const requestedHandle = baseEnv.ORCA_TERMINAL_HANDLE - const preAllocatedHandle = - requestedHandle && trustedTerminalHandleEnv.has(requestedHandle) - ? requestedHandle - : runtime?.preAllocateHandleForPty(id) - if (requestedHandle && requestedHandle !== preAllocatedHandle) { - delete env.ORCA_TERMINAL_HANDLE - } - if (preAllocatedHandle) { - env.ORCA_TERMINAL_HANDLE = preAllocatedHandle - } - stampWslOrchestrationCompatibilityHost( - env, - runtime?.getOrchestrationCompatibilityHostId?.(), - ctx?.isWsl === true ? ctx.wslDistro : null - ) - if (ctx?.isWsl === true) { - addOrcaWslInteropEnv(env) - } - return env - }, - onSpawned: (id, incarnationId) => runtime?.onPtySpawned(id, incarnationId), - onExit: (id, code, incarnationId, cause) => { - if (!isCurrentPtyExit({ id, incarnationId })) { - return - } - clearProviderPtyState(id) - ptyOwnership.delete(id) - markClaudePtyExited(id) - runtime?.onPtyExit(id, code, incarnationId, cause ? { cause } : undefined) - }, - onData: (id, data, timestamp, sequenceChars, transformed) => - runtime?.onPtyData(id, data, timestamp, sequenceChars ?? data.length, transformed) - }) - } - - type PtyDataPayload = { - id: string - data: string - seq?: number - rawLength?: number - transformed?: boolean - background?: boolean - droppedOutput?: boolean - } - - // Why: bounded batch windows amortize renderer IPC; keystroke echo/redraws bypass them below. - const pendingData = new PtyPendingDataDrainQueue( - (id) => { - const runnableLane = activeRendererPtys.has(id) ? 'active' : 'background' - // Why first: hidden bytes are dropped from main's pending queue even when renderer credit is exhausted. - if (shouldDropHiddenRendererPtyData(id, getSettings?.())) { - return runnableLane - } - if ( - !rendererPtyDispatcherReady || - !canSendPtyDataToRenderer(id, { interactive: activeRendererPtys.has(id) }) - ) { - return 'blocked' - } - return runnableLane - }, - () => isHiddenPtyDeliveryGateEnabled(getSettings?.()) - ) - let sshOutputIntake: SshPtyOutputIntake | null = null - // Why: resuming a paused producer during exit can synchronously emit; those bytes must not queue behind pty:exit. - const rendererExitingPtyIds = new Set() - const rendererCreditBeforeExitByPty = new Map() - const rendererDeliveryRestoreNeededPtys = new Set() - - function transitionHiddenRendererPtyDeliveryState(id: string, hidden: boolean) { - const settings = getSettings?.() - const wasDroppable = shouldDropHiddenRendererPtyData(id, settings) - let droppedWhileHidden = false - if (hidden) { - markHiddenRendererPty(id) - } else { - droppedWhileHidden = unmarkHiddenRendererPty(id).droppedWhileHidden - } - const droppable = shouldDropHiddenRendererPtyData(id, settings) - return { droppable, droppedWhileHidden, policyChanged: wasDroppable !== droppable } - } - - function transitionSpawnHiddenRendererPtyDeliveryState(id: string, hidden: boolean): void { - const transition = transitionHiddenRendererPtyDeliveryState(id, hidden) - if (transition.policyChanged) { - invalidatePendingPtyDrainPolicy(id) - } - } - // Why: one restore marker per overflow episode — cleared on full drain so a later overflow re-marks exactly once. - const pendingOverflowMarkedPtys = new Set() - // Why: TCP-style cumulative accounting — monotonic sent/acked totals self-heal on any later ACK, where relative in-flight counters would make each lost ACK a permanent debt. - type RendererPtyDeliveryAccounting = { - sentChars: number - ackedChars: number - lastSendAtMs: number - lastAckAtMs: number | null - } - const rendererDeliveryAccountingByPty = new Map() - const trustedTerminalHandleEnv = new Set() - let flushTimer: ReturnType | null = null - let pendingDataFlushActive = false - let pendingDataCreditReleasedDuringFlush = false - let rendererInFlightTotalChars = 0 - let pendingDroppedChars = 0 - let deliveryResyncRequestSerial = 0 - let deliveryResyncOutstandingRequestId: number | null = null - let deliveryResyncTimer: ReturnType | null = null - let deliveryResyncUnansweredWarnLogged = false - let lastAckReceivedAtMs: number | null = null - // Why 2ms: pairs with the daemon stream batcher (daemon-stream-data-batcher.ts); keeps flood coalescing at negligible IPC overhead while cutting the pipeline's latency tax. - const PTY_BATCH_INTERVAL_MS = 2 - const PTY_BATCH_DRAIN_CONTINUE_MS = 1 - const PTY_BATCH_FLUSH_CHUNK_CHARS = 16 * 1024 - const PTY_BATCH_FLUSH_MAX_WRITES = 2 - const PTY_RENDERER_IN_FLIGHT_HIGH_WATER_CHARS = 512 * 1024 - const PTY_RENDERER_TOTAL_IN_FLIGHT_HIGH_WATER_CHARS = 8 * 1024 * 1024 - // Why: cap unbounded pendingData growth when the renderer can't receive (frozen/reloading); beyond it bytes drop and the pane heals from the main buffer snapshot via the droppedOutput sentinel (#7630). - // Why read settings live: the cap scales with the user's scrollback so power users don't lose lines their scrollback would have retained. - const pendingDataCapChars = (): number => - terminalOutputBacklogCapChars(getSettings?.().terminalScrollbackRows) - // Why: self-heal bound — if a reloaded page never sends pty:rendererDispatcherReady, force sends on after this window so a lost handshake can't become a permanent hold. - const PTY_DISPATCHER_READY_WATCHDOG_MS = 10_000 - const PTY_RENDERER_INTERACTIVE_RESERVE_CHARS = 256 * 1024 - // Why: reserve a bounded lane so an active pane's keystroke redraw reaches the renderer ahead of hidden bulk output's ACKs. - const PTY_RENDERER_ACTIVE_PTY_IN_FLIGHT_RESERVE_CHARS = 512 * 1024 - // Why: request/response hygiene only (never mutates delivery state) — clears the outstanding-probe flag so a later arrival can re-probe, logs once per silent streak. - const PTY_DELIVERY_RESYNC_TIMEOUT_MS = 5_000 - // Why: a heal write-off destroys delivery accounting; require this much main-side ACK silence (independent of renderer evidence) before declaring the channel dead. - const PTY_DELIVERY_HEAL_MIN_ACK_SILENCE_MS = 10_000 - // Why: keep the immediate path to keystroke-sized TUI redraws; large/non-interactive output must use the batcher. - const INTERACTIVE_OUTPUT_WINDOW_MS = 100 - const INTERACTIVE_OUTPUT_MAX_CHARS = 1024 - const INTERACTIVE_REDRAW_MAX_CHARS = PTY_BATCH_FLUSH_CHUNK_CHARS - const INTERACTIVE_OUTPUT_BUDGET_CHARS = 32 * 1024 - let peakPendingChars = 0 - let peakMaxPendingCharsByPty = 0 - let peakRendererInFlightChars = 0 - let peakMaxRendererInFlightCharsByPty = 0 - let ackGatedFlushSkipCount = 0 - let rendererLifecycleResetCount = 0 - let lastLifecycleResetClearedChars = 0 - // Why: count of watchdog gate force-opens (no handshake arrived); nonzero flags a dropped-handshake self-heal. - let rendererDispatcherReadyForcedCount = 0 - // Why: gate sends until the page's pty:data listener exists; else webContents.send drops bytes but still counts them in-flight, permanently pinning the gate. - let rendererPtyDispatcherReady = false - let dispatcherReadyWatchdogTimer: ReturnType | null = null - - // Why: watermark producer pause/resume keyed on per-PTY pendingData (in-flight is already ACK-bounded); providers without pauseProducer no-op, memory still bounded by the pending cap. - const producerFlowControl = new PtyProducerFlowController({ - pauseProducer: (id) => tryGetProviderForPty(id)?.pauseProducer?.(id), - resumeProducer: (id) => tryGetProviderForPty(id)?.resumeProducer?.(id) - }) - const sourceCreditPendingPtys = new Set() - - function updateProducerFlowControl(id: string): void { - if (!PRODUCER_FLOW_CONTROL_ENABLED) { - return - } - if (sourceCreditPendingPtys.has(id)) { - if (pendingData.get(id)) { - return - } - sourceCreditPendingPtys.delete(id) - } - producerFlowControl.update(id, pendingData.get(id)?.data.length ?? 0) - } - - // Why: hidden ptys are exempt from pendingData flow control, so background agents can run 100MB+ ahead in the daemon stream buffer; this sync tells the provider transport which ptys to keep-tail thin. - // Why keyed on the visibility registry (not gate marks): thinning asks "does any visible view show this PTY?"; remote-view subscribers consume raw bytes, so their presence vetoes thinning. - const backgroundedDeliverySyncByPty = new Map() - function syncPtyBackgroundedDelivery(id: string, caller: string): void { - const background = - rendererPtyIsKnownHidden(id) && !(runtime?.hasRawTerminalViewSubscriber?.(id) ?? false) - if (backgroundedDeliverySyncByPty.get(id) === background) { - return - } - const provider = tryGetProviderForPty(id) - if (!provider?.setPtyBackgrounded) { - return - } - recordDaemonStreamBacklogEvent('mainBackgroundSync', { - sessionIdSuffix: id.slice(-10), - background, - caller, - known: rendererVisibilityKnownPtys.has(id), - visible: visibleRendererPtys.has(id) - }) - backgroundedDeliverySyncByPty.set(id, background) - provider.setPtyBackgrounded(id, background) - } - clearBackgroundedDeliverySyncForPty = (id: string) => { - backgroundedDeliverySyncByPty.delete(id) - } - if (runtime) { - runtime.onRemoteTerminalViewPresenceChanged = (id) => - syncPtyBackgroundedDelivery(id, 'remote-view') - } - function resyncBackgroundedDeliveriesAfterGateReset(): void { - for (const id of backgroundedDeliverySyncByPty.keys()) { - syncPtyBackgroundedDelivery(id, 'gate-reset') - } - } - - function getRendererInFlightCharsForPty(id: string): number { - const accounting = rendererDeliveryAccountingByPty.get(id) - return accounting ? accounting.sentChars - accounting.ackedChars : 0 - } - - // Why touched PTY only: pressure peaks are monotonic between explicit resets. - function recordPtyRendererDeliveryPressure(id: string): void { - peakPendingChars = Math.max(peakPendingChars, pendingData.totalPendingChars) - peakMaxPendingCharsByPty = Math.max( - peakMaxPendingCharsByPty, - pendingData.get(id)?.data.length ?? 0 - ) - peakRendererInFlightChars = Math.max(peakRendererInFlightChars, rendererInFlightTotalChars) - peakMaxRendererInFlightCharsByPty = Math.max( - peakMaxRendererInFlightCharsByPty, - getRendererInFlightCharsForPty(id) - ) - } - - function setPendingPtyData(id: string, pending: PendingPtyData): void { - pendingData.set(id, pending) - recordPtyRendererDeliveryPressure(id) - } - - function deletePendingPtyData(id: string): void { - pendingData.delete(id) - } - - function clearPendingPtyData(): void { - for (const pending of pendingData.values()) { - if (pending.projectionAdmissionIds) { - sshOutputIntake?.transferProjections( - pending.projectionAdmissionIds, - 'renderer-lifecycle-reset' - ) - } - } - pendingData.clear() - sourceCreditPendingPtys.clear() - } - - function readCurrentPtyRendererDeliveryDebugSnapshot(): PtyRendererDeliveryDebugSnapshot { - let pendingChars = 0 - let maxPendingCharsByPty = 0 - for (const pending of pendingData.values()) { - const chars = pending.data.length - pendingChars += chars - maxPendingCharsByPty = Math.max(maxPendingCharsByPty, chars) - } - const hiddenDeliveryDebug = getHiddenRendererPtyDeliveryDebug() - let rendererInFlightPtyCount = 0 - let maxRendererInFlightCharsByPty = 0 - for (const accounting of rendererDeliveryAccountingByPty.values()) { - const inFlight = accounting.sentChars - accounting.ackedChars - if (inFlight > 0) { - rendererInFlightPtyCount++ - } - maxRendererInFlightCharsByPty = Math.max(maxRendererInFlightCharsByPty, inFlight) - } - // Why: a pty both hidden-gated and reported visible means main is starving a visible pane (v1.4.124-rc.2.perf field lead). - let hiddenDeliveryGatedVisiblePtyCount = 0 - for (const id of visibleRendererPtys) { - if (isHiddenRendererPty(id)) { - hiddenDeliveryGatedVisiblePtyCount++ - } - } - let hiddenDeliveryGatedActivePtyCount = 0 - for (const id of activeRendererPtys) { - if (isHiddenRendererPty(id)) { - hiddenDeliveryGatedActivePtyCount++ - } - } - return { - pendingPtyCount: pendingData.size, - pendingChars, - maxPendingCharsByPty, - rendererInFlightPtyCount, - rendererInFlightChars: rendererInFlightTotalChars, - maxRendererInFlightCharsByPty, - activeRendererPtyCount: activeRendererPtys.size, - flushScheduled: flushTimer !== null, - peakPendingChars, - peakMaxPendingCharsByPty, - peakRendererInFlightChars, - peakMaxRendererInFlightCharsByPty, - ackGatedFlushSkipCount, - ...hiddenDeliveryDebug, - hiddenDeliveryGatedVisiblePtyCount, - hiddenDeliveryGatedActivePtyCount, - pendingDroppedChars, - diagnostics: buildMainDeliveryDiagnostics(), - rendererLifecycleResetCount, - lastLifecycleResetClearedChars, - rendererPtyDispatcherReady, - rendererDispatcherReadyForcedCount - } - } - - const DELIVERY_DIAGNOSTICS_MAX_PTYS = 30 - - // Built only when the debug snapshot is read (never on the data path): the per-pty table + breadcrumb history says WHICH pty is wedged and WHEN, unlike aggregate counters. - function buildMainDeliveryDiagnostics(): PtyMainDeliveryDiagnostics { - const now = Date.now() - // Include hidden/visible/active members even without an accounting entry: a pty gated before its first byte is exactly the wedge case to surface. - const ids = new Set([ - ...rendererDeliveryAccountingByPty.keys(), - ...pendingData.keys(), - ...getHiddenRendererPtyIds(), - ...visibleRendererPtys, - ...activeRendererPtys - ]) - const perPty: PtyPerPtyDeliveryDiagnostics[] = [] - for (const id of ids) { - const accounting = rendererDeliveryAccountingByPty.get(id) - perPty.push({ - id: redactPtyIdForDiagnostics(id), - sentChars: accounting?.sentChars ?? 0, - ackedChars: accounting?.ackedChars ?? 0, - inFlightChars: accounting ? accounting.sentChars - accounting.ackedChars : 0, - pendingChars: pendingData.get(id)?.data.length ?? 0, - hidden: isHiddenRendererPty(id), - visible: visibleRendererPtys.has(id), - active: activeRendererPtys.has(id), - msSinceLastSend: accounting ? now - accounting.lastSendAtMs : null, - msSinceLastAck: accounting?.lastAckAtMs == null ? null : now - accounting.lastAckAtMs - }) - } - perPty.sort((a, b) => b.inFlightChars + b.pendingChars - (a.inFlightChars + a.pendingChars)) - const windowAlive = !isRendererGone(mainWindow) - return { - appVersion: getAppEnvironment().getVersion(), - mainUptimeMs: Math.round(process.uptime() * 1000), - windowFocused: windowAlive ? mainWindow!.isFocused() : null, - windowVisible: windowAlive ? mainWindow!.isVisible() : null, - windowMinimized: windowAlive ? mainWindow!.isMinimized() : null, - msSinceLastPowerSuspend: lastPowerSuspendAtMs === null ? null : now - lastPowerSuspendAtMs, - msSinceLastPowerResume: lastPowerResumeAtMs === null ? null : now - lastPowerResumeAtMs, - perPty: perPty.slice(0, DELIVERY_DIAGNOSTICS_MAX_PTYS), - breadcrumbs: mainDeliveryBreadcrumbs.snapshot() - } - } - - // Why rate-limited: the contradiction persists chunk after chunk while latched; one line per minute keeps field logs readable but present. - let lastHiddenDropContradictionWarnAtMs = 0 - function warnIfDroppingHiddenBytesForVisiblePty(id: string, droppedChars: number): void { - if (!visibleRendererPtys.has(id) && !activeRendererPtys.has(id)) { - return - } - // Recorded before the warn rate limit: the ring coalesces repeats, and the contradiction must appear in the freeze report either way. - mainDeliveryBreadcrumbs.record('hidden-drop-visible', { - id: redactPtyIdForDiagnostics(id), - droppedChars - }) - const now = Date.now() - if (now - lastHiddenDropContradictionWarnAtMs < 60_000) { - return - } - lastHiddenDropContradictionWarnAtMs = now - console.warn('[pty] hidden-delivery gate is dropping bytes for a visible/active pty', { - id, - droppedChars, - visible: visibleRendererPtys.has(id), - active: activeRendererPtys.has(id), - ...readCurrentPtyRendererDeliveryDebugSnapshot() - }) - } - - function seedPtyRendererDeliveryPeaksFromCurrentState(): void { - let pendingChars = 0 - let maxPendingCharsByPty = 0 - for (const pending of pendingData.values()) { - const chars = pending.data.length - pendingChars += chars - maxPendingCharsByPty = Math.max(maxPendingCharsByPty, chars) - } - peakPendingChars = pendingChars - peakMaxPendingCharsByPty = maxPendingCharsByPty - peakRendererInFlightChars = rendererInFlightTotalChars - let maxRendererInFlightCharsByPty = 0 - for (const accounting of rendererDeliveryAccountingByPty.values()) { - maxRendererInFlightCharsByPty = Math.max( - maxRendererInFlightCharsByPty, - accounting.sentChars - accounting.ackedChars - ) - } - peakMaxRendererInFlightCharsByPty = maxRendererInFlightCharsByPty - } - - readPtyRendererDeliveryDebugSnapshot = readCurrentPtyRendererDeliveryDebugSnapshot - resetPtyRendererDeliveryDebugSnapshot = () => { - peakPendingChars = 0 - peakMaxPendingCharsByPty = 0 - peakRendererInFlightChars = 0 - peakMaxRendererInFlightCharsByPty = 0 - ackGatedFlushSkipCount = 0 - pendingDroppedChars = 0 - resetHiddenRendererPtyDeliveryDebugCounters() - seedPtyRendererDeliveryPeaksFromCurrentState() - } - resetRendererDeliveryAccountingForLifecycleReset = () => { - // Why lossless: pendingData bytes were bound for the dead page; the replacement repaints from main's authoritative sources, which superset it. - lastLifecycleResetClearedChars = rendererInFlightTotalChars - rendererLifecycleResetCount += 1 - // Why release before clearing: pending bytes and credits belonged to the dead page; releasing producer pauses first keeps no shell wedged. - producerFlowControl.releaseAll() - clearDeliveryResyncProbe() - deliveryResyncUnansweredWarnLogged = false - for (const id of rendererDeliveryAccountingByPty.keys()) { - sshOutputIntake?.transferPtyProjections(id, 'renderer-lifecycle-reset') - } - rendererDeliveryAccountingByPty.clear() - rendererInFlightTotalChars = 0 - clearPendingPtyData() - pendingOverflowMarkedPtys.clear() - rendererDeliveryRestoreNeededPtys.clear() - // Why hold sends: the reloading page's pty:data listener is gone until it re-registers/handshakes, so bytes would drop into a listener-less page and re-pin the gate. - rendererPtyDispatcherReady = false - // Why: arm the self-heal watchdog so a never-arriving handshake can't hold the gate forever; the real handshake cancels it. - armDispatcherReadyWatchdog() - } - // Why the bridge: let a later re-registration cancel this closure's watchdog (armed via a hoisted fn, so this assignment can precede its definition). - clearRendererDispatcherReadyWatchdog = clearDispatcherReadyWatchdog - - function isLikelyInteractiveRedraw(data: string): boolean { - if (data.length <= INTERACTIVE_OUTPUT_MAX_CHARS) { - return true - } - // Why the ANSI check: Codex-style TUIs repaint >1 KB per keypress (latency-sensitive), while plain command output should stay on the throughput batch path. - return data.length <= INTERACTIVE_REDRAW_MAX_CHARS && data.includes('\x1b[') - } - - function shouldSendInteractiveOutputNow(id: string, data: string, now: number): boolean { - const lastInputAt = lastInputAtByPty.get(id) - if (lastInputAt === undefined || now - lastInputAt > INTERACTIVE_OUTPUT_WINDOW_MS) { - interactiveOutputCharsByPty.delete(id) - return false - } - if (!isLikelyInteractiveRedraw(data)) { - interactiveOutputCharsByPty.set(id, INTERACTIVE_OUTPUT_BUDGET_CHARS) - return false - } - const usedChars = interactiveOutputCharsByPty.get(id) ?? 0 - if (usedChars + data.length > INTERACTIVE_OUTPUT_BUDGET_CHARS) { - interactiveOutputCharsByPty.set(id, INTERACTIVE_OUTPUT_BUDGET_CHARS) - return false - } - interactiveOutputCharsByPty.set(id, usedChars + data.length) - return true - } - - function makePtyDataPayload( - id: string, - data: string, - startSeq: number | undefined, - containsBackgroundOutput: boolean | undefined, - rawLength = data.length, - transformed = false - ): PtyDataPayload { - const payload: PtyDataPayload = { id, data } - if (typeof startSeq === 'number') { - payload.seq = startSeq + rawLength - } - if (typeof startSeq === 'number' || rawLength !== data.length || transformed) { - payload.rawLength = rawLength - } - if (transformed) { - payload.transformed = true - } - if (containsBackgroundOutput === true) { - payload.background = true - } - return payload - } - - function getPtyPayloadCharCount(payload: { data: string; rawLength?: number }): number { - return Math.max(0, payload.rawLength ?? payload.data.length) - } - - function canSendPtyDataToRenderer(id: string, options: { interactive?: boolean } = {}): boolean { - const totalLimit = - PTY_RENDERER_TOTAL_IN_FLIGHT_HIGH_WATER_CHARS + - (options.interactive === true ? PTY_RENDERER_INTERACTIVE_RESERVE_CHARS : 0) - // Why per-PTY (not global) reserve: keep one active pane responsive without letting every background pane burst past the cap. - const ptyLimit = - PTY_RENDERER_IN_FLIGHT_HIGH_WATER_CHARS + - (options.interactive === true ? PTY_RENDERER_ACTIVE_PTY_IN_FLIGHT_RESERVE_CHARS : 0) - return getRendererInFlightCharsForPty(id) < ptyLimit && rendererInFlightTotalChars < totalLimit - } - - // Why max-merge cumulative totals: idempotent and reorder-tolerant — replayed/out-of-order ACKs can't double-credit and a lost ACK self-heals. Returns the newly acknowledged delta. - function applyCumulativeAck(id: string, processedChars: number): number { - const accounting = rendererDeliveryAccountingByPty.get(id) - if (!accounting) { - return 0 - } - // Clamped to sentChars so a corrupt payload cannot drive in-flight negative. - const nextAckedChars = Math.min( - accounting.sentChars, - Math.max(accounting.ackedChars, processedChars) - ) - const acknowledged = nextAckedChars - accounting.ackedChars - accounting.ackedChars = nextAckedChars - if (acknowledged > 0) { - accounting.lastAckAtMs = Date.now() - } - rendererInFlightTotalChars = Math.max(0, rendererInFlightTotalChars - acknowledged) - if (acknowledged > 0) { - sshOutputIntake?.settleProjectionPrefix(id, acknowledged) - } - return acknowledged - } - - function schedulePendingDataAfterCreditReport(creditedAny: boolean): void { - if (creditedAny) { - pendingData.reactivateBlocked() - } - if (pendingData.size > 0 && !flushTimer) { - schedulePendingDataFlush(0) - } - } - - function clearDeliveryResyncProbe(): void { - deliveryResyncOutstandingRequestId = null - if (deliveryResyncTimer) { - clearTimeout(deliveryResyncTimer) - deliveryResyncTimer = null - } - } - - // Why: data for a fully gated PTY signals delivery may be stuck on lost ACKs (e.g. dropped across suspend); ask the renderer for authoritative totals instead of a wall-clock guess. - function requestDeliveryResyncForGatedPty(): void { - if (deliveryResyncOutstandingRequestId !== null || isRendererGone(mainWindow)) { - return - } - deliveryResyncRequestSerial += 1 - const requestId = deliveryResyncRequestSerial - deliveryResyncOutstandingRequestId = requestId - deliveryResyncTimer = setTimeout(() => { - if (deliveryResyncOutstandingRequestId !== requestId) { - return - } - clearDeliveryResyncProbe() - // Why no mutation on timeout: unanswered means dead IPC that only a reload cures; log once per silent streak to avoid spamming every probe. - if (deliveryResyncUnansweredWarnLogged) { - return - } - deliveryResyncUnansweredWarnLogged = true - console.warn('[pty] delivery resync probe unanswered — renderer IPC unresponsive', { - msSinceLastAck: lastAckReceivedAtMs === null ? null : Date.now() - lastAckReceivedAtMs, - ...readCurrentPtyRendererDeliveryDebugSnapshot() - }) - }, PTY_DELIVERY_RESYNC_TIMEOUT_MS) - deliveryResyncTimer.unref?.() - sendToRenderer(mainWindow, 'pty:requestDeliveryResync', { requestId }) - } - - // Why write off: bytes sent but never received after a confirmed wedge are gone (no ACK can repay them); hand back restore markers so panes repaint from the snapshot. - function writeOffLostRendererDelivery( - report: PtyRendererDeliveryStateReport - ): PtyDeliveryWriteOff[] { - const writtenOff: PtyDeliveryWriteOff[] = [] - for (const [id, accounting] of rendererDeliveryAccountingByPty) { - if (accounting.sentChars - accounting.ackedChars <= 0) { - continue - } - const received = report.receivedCharsByPty?.[id] - const receivedChars = - typeof received === 'number' && Number.isFinite(received) ? Math.max(0, received) : 0 - // Why skip: received-but-unparsed bytes are alive in the renderer write queue; their deferred ACK still repays this debt. - if (receivedChars > accounting.ackedChars) { - continue - } - const acknowledged = applyCumulativeAck(id, accounting.sentChars) - if (acknowledged <= 0) { - continue - } - tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged) - // Why drop pending: everything at/before markerSeq comes from the snapshot, so flushing pre-marker bytes would double-paint the restore. - const pending = pendingData.get(id) - if (pending) { - if (pending.projectionAdmissionIds) { - sshOutputIntake?.transferProjections( - pending.projectionAdmissionIds, - 'renderer-delivery-writeoff' - ) - } - pendingDroppedChars += pending.data.length - deletePendingPtyData(id) - pendingOverflowMarkedPtys.delete(id) - updateProducerFlowControl(id) - } - const markerSeq = runtime?.getPtyOutputSequence(id) - writtenOff.push({ - id, - ...(typeof markerSeq === 'number' ? { markerSeq } : {}), - writtenOffChars: acknowledged - }) - } - if (writtenOff.length > 0) { - clearDeliveryResyncProbe() - deliveryResyncUnansweredWarnLogged = false - mainDeliveryBreadcrumbs.record('delivery-heal-writeoff', { - writtenOffPtyCount: writtenOff.length, - writtenOffChars: writtenOff.reduce((sum, { writtenOffChars }) => sum + writtenOffChars, 0) - }) - console.warn('[pty] delivery heal: wrote off renderer-bound bytes lost in push channel', { - rendererPtyDataListenerCount: report.rendererPtyDataListenerCount ?? null, - msSinceLastAck: lastAckReceivedAtMs === null ? null : Date.now() - lastAckReceivedAtMs, - writtenOffByPty: writtenOff.map(({ id, writtenOffChars }) => ({ id, writtenOffChars })), - ...readCurrentPtyRendererDeliveryDebugSnapshot() - }) - } - return writtenOff - } - - function sendPtyDataToRenderer( - id: string, - payload: PtyDataPayload, - projectionAdmissionIds?: readonly string[] - ): { sent: boolean; projectionsTransferred: boolean } { - const charCount = getPtyPayloadCharCount(payload) - const accounting = rendererDeliveryAccountingByPty.get(id) - const hadAccounting = accounting !== undefined - if (accounting) { - accounting.sentChars += charCount - accounting.lastSendAtMs = Date.now() - } else { - rendererDeliveryAccountingByPty.set(id, { - sentChars: charCount, - ackedChars: 0, - lastSendAtMs: Date.now(), - lastAckAtMs: null - }) - } - rendererInFlightTotalChars += charCount - recordPtyRendererDeliveryPressure(id) - try { - sendToRenderer(mainWindow, 'pty:data', payload) - } catch (error) { - const current = rendererDeliveryAccountingByPty.get(id) - if (current) { - const inFlightBeforeRollback = current.sentChars - current.ackedChars - current.sentChars = Math.max(0, current.sentChars - charCount) - current.ackedChars = Math.min(current.ackedChars, current.sentChars) - const inFlightAfterRollback = current.sentChars - current.ackedChars - rendererInFlightTotalChars = Math.max( - 0, - rendererInFlightTotalChars - (inFlightBeforeRollback - inFlightAfterRollback) - ) - if (!hadAccounting && current.sentChars === 0) { - rendererDeliveryAccountingByPty.delete(id) - } - } - rendererDeliveryRestoreNeededPtys.add(id) - if (projectionAdmissionIds) { - sshOutputIntake?.transferProjections(projectionAdmissionIds, 'renderer-send-failed') - } - mainDeliveryBreadcrumbs.record('pty-data-send-failed', { - id: redactPtyIdForDiagnostics(id), - chars: charCount - }) - console.error('[pty] renderer data send failed; payload will not be retried', error) - return { sent: false, projectionsTransferred: projectionAdmissionIds !== undefined } - } - let projectionsTransferred = false - if (projectionAdmissionIds) { - try { - sshOutputIntake?.publishProjectionPrefix( - projectionAdmissionIds, - payload.data.length, - charCount - ) - } catch { - sshOutputIntake?.transferProjections(projectionAdmissionIds, 'projection-publish-failed') - projectionsTransferred = true - } - } - if (rendererDeliveryRestoreNeededPtys.has(id)) { - try { - sendModelRestoreNeededMarker(id, 'delivery-heal', runtime?.getPtyOutputSequence(id)) - rendererDeliveryRestoreNeededPtys.delete(id) - } catch (error) { - console.error( - '[pty] renderer delivery-heal marker send failed; restore remains pending', - error - ) - } - } - return { sent: true, projectionsTransferred } - } - - function rendererPtyIsKnownHidden(id: string): boolean { - return rendererVisibilityKnownPtys.has(id) && !visibleRendererPtys.has(id) - } - - function ptyHasHiddenRendererResizeOutput(id: string): boolean { - return ( - pendingHiddenRendererResizeOutputPtys.has(id) || - deliveredHiddenRendererResizeOutputPtys.has(id) - ) - } - - function markHiddenRendererResizeOutputDelivered(id: string): void { - if (!pendingHiddenRendererResizeOutputPtys.delete(id)) { - return - } - deliveredHiddenRendererResizeOutputPtys.add(id) - } - - function clearDeliveredHiddenRendererResizeOutput(id: string): void { - deliveredHiddenRendererResizeOutputPtys.delete(id) - } - - function clearHiddenRendererResizeOutput(id: string): void { - pendingHiddenRendererResizeOutputPtys.delete(id) - deliveredHiddenRendererResizeOutputPtys.delete(id) - } - - // Why out-of-band (not pty:data): an in-band empty chunk is indistinguishable from one fully consumed by renderer-side OSC-9999 stripping, which spuriously restored visible panes. - function sendModelRestoreNeededMarker( - id: string, - reason: PtyModelRestoreReason, - markerSeq: number | undefined - ): void { - if (isRendererGone(mainWindow)) { - return - } - sendToRenderer(mainWindow, 'pty:modelRestoreNeeded', { - id, - reason, - ...(typeof markerSeq === 'number' ? { markerSeq } : {}) - }) - } - - const pendingDataDropWarnedPtys = new Set() - - // Why capped: keeps O(1) memory per PTY; salvaged query bytes are tiny, so past the cap a pathological stream can degrade to the plain sentinel. - const DROPPED_QUERY_SALVAGE_MAX_CHARS = 4096 - - // Why carve out queries: a bulk drop must not swallow reply-eliciting probes (DSR/CPR, DA1/DA2, DECRQM, OSC 10/11) the program blocks on; snapshot heals content so replies can't double-fire. - function extractDroppedPtyQueryBytes(data: string): string { - if (!data.includes('\x1b')) { - return '' - } - const extracted = extractHiddenStartupRendererQueryData(data, '') - return extracted.statelessQueryData + extracted.statefulQueryData + extracted.oscColorQueryData - } - - function scanDroppedMode2031Data( - data: string, - previous: Mode2031ReplyScanState - ): { data: string; state: Mode2031ReplyScanState } { - const result = scanMode2031ReplyDecision(previous, data) - const decisionData = - result.decision === 'subscribed' - ? '\x1b[?2031h' - : result.decision === 'unsubscribed' - ? '\x1b[?2031l' - : '' - return { data: decisionData, state: result.state } - } - - function getDroppedMode2031RendererData(pending: PendingPtyData): string { - const state = pending.droppedMode2031ScanState - if (!state) { - return pending.droppedMode2031Data ?? '' - } - const pendingSubscribe = state.pendingSubscribe ? '\x1b[?2031h' : '' - return (pending.droppedMode2031Data ?? '') + pendingSubscribe + state.tail - } - - function dropOversizedPendingPtyData(id: string, pending: PendingPtyData): PendingPtyData { - const capChars = pendingDataCapChars() - if (pending.droppedOutput === true || pending.data.length <= capChars) { - return pending - } - if (!pendingDataDropWarnedPtys.has(id)) { - pendingDataDropWarnedPtys.add(id) - console.error( - `[pty] dropped ${pending.data.length} buffered chars for ${id}: renderer not receiving and per-PTY pending cap exceeded; pane will restore from the main-owned snapshot` - ) - // Why: field visibility for cap tuning (issue #2836 / #7017); no pty id since session ids can embed workspace paths. - recordCrashBreadcrumb('terminal_pending_output_dropped', { - droppedChars: pending.data.length, - capChars - }) - } - if (isHiddenPtyDeliveryGateEnabled(getSettings?.()) && !pendingOverflowMarkedPtys.has(id)) { - pendingOverflowMarkedPtys.add(id) - } - pendingDroppedChars += pending.data.length - if (pending.projectionAdmissionIds) { - sshOutputIntake?.transferProjections(pending.projectionAdmissionIds, 'pending-cap') - } - const mode2031 = scanDroppedMode2031Data(pending.data, INITIAL_MODE_2031_REPLY_SCAN_STATE) - // Why no trimmed content tail: a mid-stream gap would corrupt the pane; the droppedOutput sentinel repaints from the snapshot and realigns by sequence (only query bytes ride along). - return { - data: extractDroppedPtyQueryBytes(pending.data).slice(0, DROPPED_QUERY_SALVAGE_MAX_CHARS), - droppedOutput: true, - droppedMode2031Data: mode2031.data, - droppedMode2031ScanState: mode2031.state - } - } - - function updatePendingProjectionAdmissions( - pending: PendingPtyData, - state: PendingProjectionAdmissions - ): void { - delete pending.projectionAdmissionIds - delete pending.projectionAdmissionsTransferred - if (state.projectionAdmissionIds) { - pending.projectionAdmissionIds = state.projectionAdmissionIds - } - if (state.projectionAdmissionsTransferred) { - pending.projectionAdmissionsTransferred = true - } - } - - function compactPendingProjectionState( - pending: PendingProjectionAdmissions, - projectionSemanticsId?: string - ): PendingProjectionAdmissions { - const options = pendingProjectionAdmissionOptions() - const compacted = compactPendingProjectionAdmissions(pending, options) - return projectionSemanticsId - ? appendPendingProjectionAdmission(compacted, projectionSemanticsId, options) - : compacted - } - - function pendingProjectionAdmissionOptions() { - return { - isPending: (id: string) => sshOutputIntake?.hasUnpublishedProjection(id) ?? false, - transfer: (ids: readonly string[], reason: string) => - sshOutputIntake?.transferProjections(ids, reason) - } - } - - function appendPendingPtyData( - id: string, - existing: PendingPtyData | undefined, - data: string, - startSeq: number | undefined, - preservesSeq: boolean, - containsBackgroundOutput: boolean, - rawLength = data.length, - transformed = false, - projectionSemanticsId?: string - ): PendingPtyData { - // Why stay dropped at O(1): once over the cap the restore sentinel supersedes interim bytes; queries still get carved out (bounded) so replies survive the whole episode. - if (existing?.droppedOutput === true) { - if (projectionSemanticsId) { - sshOutputIntake?.transferProjections([projectionSemanticsId], 'pending-cap') - } - const mode2031 = scanDroppedMode2031Data( - data, - existing.droppedMode2031ScanState ?? INITIAL_MODE_2031_REPLY_SCAN_STATE - ) - const remainingQueryCapacity = Math.max( - 0, - DROPPED_QUERY_SALVAGE_MAX_CHARS - existing.data.length - ) - const salvaged = extractDroppedPtyQueryBytes(data).slice(0, remainingQueryCapacity) - return { - ...existing, - data: existing.data + salvaged, - droppedMode2031Data: mode2031.data || existing.droppedMode2031Data, - droppedMode2031ScanState: mode2031.state - } - } - const projectionState = compactPendingProjectionState(existing ?? {}, projectionSemanticsId) - const nextContainsBackgroundOutput = - existing?.containsBackgroundOutput === true || containsBackgroundOutput - if (!existing) { - const pending: PendingPtyData = { - data, - ...(typeof startSeq === 'number' ? { startSeq } : {}), - ...(rawLength !== data.length ? { rawLength } : {}), - ...(transformed ? { transformed: true } : {}), - ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}) - } - updatePendingProjectionAdmissions(pending, projectionState) - return dropOversizedPendingPtyData(id, pending) - } - const existingRawLength = existing.rawLength ?? existing.data.length - const next: PendingPtyData = { - data: existing.data + data, - ...(!preservesSeq || existing.transformed || transformed - ? { rawLength: existingRawLength + rawLength, transformed: true as const } - : {}), - ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}) - } - updatePendingProjectionAdmissions(next, projectionState) - if (typeof existing.startSeq === 'number') { - next.startSeq = existing.startSeq - } - return dropOversizedPendingPtyData(id, next) - } - - function schedulePendingDataFlush(delayMs: number): void { - if (flushTimer) { - return - } - flushTimer = setTimeout(flushPendingData, delayMs) - } - - function invalidatePendingPtyDrainClassification(id?: string, schedule = true): void { - const invalidated = - typeof id === 'string' ? pendingData.invalidate(id) : pendingData.invalidateAll() - if (invalidated && schedule && !flushTimer) { - schedulePendingDataFlush(0) - } - } - invalidatePendingPtyDrainPriority = invalidatePendingPtyDrainClassification - invalidatePendingPtyDrainPolicy = invalidatePendingPtyDrainClassification - - function clearDispatcherReadyWatchdog(): void { - if (dispatcherReadyWatchdogTimer) { - clearTimeout(dispatcherReadyWatchdogTimer) - dispatcherReadyWatchdogTimer = null - } - } - - function armDispatcherReadyWatchdog(): void { - clearDispatcherReadyWatchdog() - if (isRendererGone(mainWindow)) { - return - } - // Why: one-shot self-heal — force the gate open if the reloaded page never signals ready, so a dropped handshake can't hold it forever. Unref'd so it can't keep the process alive. - dispatcherReadyWatchdogTimer = setTimeout(() => { - dispatcherReadyWatchdogTimer = null - if (rendererPtyDispatcherReady || isRendererGone(mainWindow)) { - return - } - rendererPtyDispatcherReady = true - rendererDispatcherReadyForcedCount += 1 - pendingData.reactivateBlocked() - schedulePendingDataFlush(0) - }, PTY_DISPATCHER_READY_WATCHDOG_MS) - dispatcherReadyWatchdogTimer.unref?.() - } - - function flushPendingData(): void { - flushTimer = null - if (isRendererGone(mainWindow)) { - // Why release now: bookkeeping is being wiped, so no future drain can resume these producers — local shells would wedge. - producerFlowControl.releaseAll() - clearDeliveryResyncProbe() - clearPendingPtyData() - pendingOverflowMarkedPtys.clear() - rendererDeliveryAccountingByPty.clear() - rendererInFlightTotalChars = 0 - clearDispatcherReadyWatchdog() - return - } - // Ordinary boot-window data is blocked in the queue; hidden-droppable entries still retire before renderer readiness. - const settings = getSettings?.() - let writes = 0 - let sendFailed = false - const round = pendingData.beginRound() - let creditReleasedDuringFlush = false - pendingDataFlushActive = true - pendingDataCreditReleasedDuringFlush = false - try { - while (writes < PTY_BATCH_FLUSH_MAX_WRITES) { - const selection = pendingData.takeNext(round) - if (!selection) { - break - } - const { id, pending } = selection - // Why drop, never re-queue: the model already ingested hidden-gated bytes; reveal restores from the snapshot+seq machinery. - if (shouldDropHiddenRendererPtyData(id, settings)) { - pendingData.remove(selection) - pendingOverflowMarkedPtys.delete(id) - updateProducerFlowControl(id) - const drop = recordHiddenRendererPtyDataDrop(id, pending.data.length) - if (pending.projectionAdmissionIds) { - sshOutputIntake?.transferProjections(pending.projectionAdmissionIds, 'hidden-drop') - } - warnIfDroppingHiddenBytesForVisiblePty(id, pending.data.length) - if (drop.shouldEmitRestoreMarker) { - sendModelRestoreNeededMarker(id, 'hidden-drop', runtime?.getPtyOutputSequence(id)) - } - continue - } - if (!canSendPtyDataToRenderer(id, { interactive: activeRendererPtys.has(id) })) { - pendingData.block(selection) - continue - } - if (pending.droppedOutput === true) { - pendingData.remove(selection) - updateProducerFlowControl(id) - // Why droppedOutput sentinel: pending-cap drop means the pane must repaint from the snapshot, not continue a gapped stream (data = carved query bytes only). - if ( - !sendPtyDataToRenderer( - id, - { - id, - data: pending.data + getDroppedMode2031RendererData(pending), - droppedOutput: true - }, - pending.projectionAdmissionIds - ).sent - ) { - sendFailed = true - break - } - writes++ - continue - } - const { data } = pending - const indivisible = pending.transformed === true - const chunk = indivisible ? data : data.slice(0, PTY_BATCH_FLUSH_CHUNK_CHARS) - const remaining = indivisible ? '' : data.slice(PTY_BATCH_FLUSH_CHUNK_CHARS) - let nextPending: PendingPtyData | undefined - if (remaining) { - nextPending = { data: remaining } - if (typeof pending.startSeq === 'number') { - nextPending.startSeq = pending.startSeq + chunk.length - } - if (pending.containsBackgroundOutput === true) { - nextPending.containsBackgroundOutput = true - } - if (pending.projectionAdmissionIds) { - nextPending.projectionAdmissionIds = pending.projectionAdmissionIds - } - if (pending.projectionAdmissionsTransferred) { - nextPending.projectionAdmissionsTransferred = true - } - pendingData.replaceWithRemainder(selection, nextPending) - } else { - pendingData.remove(selection) - pendingOverflowMarkedPtys.delete(id) - } - updateProducerFlowControl(id) - const delivery = sendPtyDataToRenderer( - id, - makePtyDataPayload( - id, - chunk, - pending.startSeq, - pending.containsBackgroundOutput, - pending.rawLength, - pending.transformed - ), - pending.projectionAdmissionIds - ) - if (nextPending) { - updatePendingProjectionAdmissions( - nextPending, - propagatePendingProjectionRemainder( - nextPending, - delivery, - pendingProjectionAdmissionOptions() - ) - ) - } - if (!delivery.sent) { - sendFailed = true - break - } - writes++ - } - } finally { - pendingDataFlushActive = false - creditReleasedDuringFlush = pendingDataCreditReleasedDuringFlush - pendingDataCreditReleasedDuringFlush = false - pendingData.endRound(round) - } - if (rendererPtyDispatcherReady && pendingData.size > 0 && writes === 0 && !sendFailed) { - ackGatedFlushSkipCount++ - } - if (sendFailed && pendingData.size > 0) { - if (flushTimer) { - clearTimeout(flushTimer) - flushTimer = null - } - schedulePendingDataFlush(PTY_BATCH_DRAIN_CONTINUE_MS) - return - } - if (pendingData.size > 0 && (writes > 0 || creditReleasedDuringFlush)) { - // Why yield between slices: a background terminal can dump megabytes at once, and keystroke writes must not stall behind one flush. - schedulePendingDataFlush(writes > 0 ? PTY_BATCH_DRAIN_CONTINUE_MS : 0) - } - } - - const clearFlushTimerIfIdle = (): void => { - if (pendingData.size > 0 || flushTimer === null) { - return - } - clearTimeout(flushTimer) - flushTimer = null - } - - const syntheticKillExitPtyIds = new Map() - const reversibleStopOwnersByPtyId = new Map() - - function rememberSyntheticKillExit(id: string): void { - const existing = syntheticKillExitPtyIds.get(id) - if (existing) { - clearTimeout(existing) - } - // Why a timed window: providers may report the real exit after kill completes; skip only that late duplicate, not a future reused id forever. - const cleanupTimer = setTimeout(() => { - syntheticKillExitPtyIds.delete(id) - }, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS) - cleanupTimer.unref?.() - syntheticKillExitPtyIds.set(id, cleanupTimer) - } - - // Why: a rejected split retires its PTY while kill's shutdown is still in flight; kill's late - // synthetic exit must not land afterwards, or an SSH pane's code -1 would re-preserve the - // surface the retirement just removed. Timed like the duplicate window so a reused id is free. - const retiredRejectedPtyIds = new Map() - - function rememberRetiredRejectedPty(id: string): void { - const existing = retiredRejectedPtyIds.get(id) - if (existing) { - clearTimeout(existing) - } - const cleanupTimer = setTimeout(() => { - retiredRejectedPtyIds.delete(id) - }, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS) - cleanupTimer.unref?.() - retiredRejectedPtyIds.set(id, cleanupTimer) - } - - function consumeSyntheticKillExit(id: string): boolean { - const cleanupTimer = syntheticKillExitPtyIds.get(id) - if (!cleanupTimer) { - return false - } - clearTimeout(cleanupTimer) - syntheticKillExitPtyIds.delete(id) - return true - } - - function preparePtyExitForRenderer(payload: { id: string; code: number }): (() => void) | null { - if (isRendererGone(mainWindow)) { - sshOutputIntake?.transferPtyProjections(payload.id, 'renderer-destroyed') - return () => {} - } - if (rendererExitingPtyIds.has(payload.id)) { - return null - } - rendererExitingPtyIds.add(payload.id) - let released = false - const release = (): void => { - if (released) { - return - } - released = true - rendererExitingPtyIds.delete(payload.id) - } - try { - if (!rendererCreditBeforeExitByPty.has(payload.id)) { - rendererCreditBeforeExitByPty.set( - payload.id, - getRendererInFlightCharsForPty(payload.id) > 0 - ) - } - // Why flush before exit: the renderer tears down the terminal on pty:exit, so any batched output not yet flushed would be silently lost. - const remaining = pendingData.delete(payload.id) - clearFlushTimerIfIdle() - if (remaining) { - if (remaining.droppedOutput === true) { - // Sentinel entry: only salvaged query bytes remain; keep the flag so the renderer knows the span was dropped. - sendPtyDataToRenderer( - payload.id, - { - id: payload.id, - data: remaining.data, - droppedOutput: true - }, - remaining.projectionAdmissionIds - ) - } else { - sendPtyDataToRenderer( - payload.id, - makePtyDataPayload( - payload.id, - remaining.data, - remaining.startSeq, - remaining.containsBackgroundOutput, - remaining.rawLength, - remaining.transformed - ), - remaining.projectionAdmissionIds - ) - } - } - return release - } catch (error) { - release() - throw error - } - } - - function finalizePtyExitForRenderer(payload: { id: string; code: number }): void { - if (isRendererGone(mainWindow)) { - rendererCreditBeforeExitByPty.delete(payload.id) - return - } - const hadReleasableRendererCredit = - rendererCreditBeforeExitByPty.get(payload.id) ?? - getRendererInFlightCharsForPty(payload.id) > 0 - rendererCreditBeforeExitByPty.delete(payload.id) - // Why resume a dead PTY (no-op): avoid leaving a stale paused mark behind for a reused id. - producerFlowControl.release(payload.id) - sourceCreditPendingPtys.delete(payload.id) - pendingOverflowMarkedPtys.delete(payload.id) - rendererDeliveryRestoreNeededPtys.delete(payload.id) - lastInputAtByPty.delete(payload.id) - interactiveOutputCharsByPty.delete(payload.id) - const releasedRendererCredit = getRendererInFlightCharsForPty(payload.id) - rendererInFlightTotalChars = Math.max(0, rendererInFlightTotalChars - releasedRendererCredit) - // Why: the renderer also drops its cumulative total on pty:exit, so a reused id restarts aligned at zero on both sides. - rendererDeliveryAccountingByPty.delete(payload.id) - if (hadReleasableRendererCredit) { - if (pendingDataFlushActive) { - // Why: let the open round coalesce this wake into its one post-round continuation. - const reactivatedBlocked = pendingData.reactivateBlocked() - pendingDataCreditReleasedDuringFlush ||= reactivatedBlocked - } else { - schedulePendingDataAfterCreditReport(true) - } - } - sendToRenderer(mainWindow, 'pty:exit', { - ...payload, - ...(reversibleStopOwnersByPtyId.has(payload.id) ? { preserveRendererBinding: true } : {}) - }) - } - - function sendPtyExitToRenderer(payload: { id: string; code: number }): void { - options?.onPtyExit?.(payload.id, ++ptyLifecycleSequence) - const release = preparePtyExitForRenderer(payload) - if (!release) { - return - } - try { - sshOutputIntake?.transferPtyProjections(payload.id, 'legacy-pty-exit') - finalizePtyExitForRenderer(payload) - } finally { - release() - } - } - - function sendPtySpawnedToRenderer(id: string): void { - if (!isRendererGone(mainWindow)) { - sendToRenderer(mainWindow, 'pty:spawned', { id }) - } - } - - function acceptPtyDataForRenderer( - payload: { - id: string - data: string - sequenceChars?: number - transformed?: boolean - }, - outputSeq: number | undefined, - projection?: LegacySshProjectionSemantics - ): void { - const rawLength = payload.sequenceChars ?? payload.data.length - const preservesSeq = !payload.transformed && rawLength === payload.data.length - const startSeq = typeof outputSeq === 'number' ? Math.max(0, outputSeq - rawLength) : undefined - const projectionId = projection?.identity.projectionSemanticsId - if (isRendererGone(mainWindow)) { - if (projectionId) { - sshOutputIntake?.transferProjections([projectionId], 'renderer-destroyed') - } - if (flushTimer) { - clearTimeout(flushTimer) - flushTimer = null - } - producerFlowControl.releaseAll() - clearDeliveryResyncProbe() - clearPendingPtyData() - pendingOverflowMarkedPtys.clear() - rendererDeliveryAccountingByPty.clear() - rendererInFlightTotalChars = 0 - clearDispatcherReadyWatchdog() - return - } - if (rendererExitingPtyIds.has(payload.id)) { - if (projectionId) { - sshOutputIntake?.transferProjections([projectionId], 'pty-exiting') - } - return - } - if (shouldDropHiddenRendererPtyData(payload.id, getSettings?.())) { - if (projectionId) { - sshOutputIntake?.transferProjections([projectionId], 'hidden-drop') - } - const droppedChars = projection ? rawLength : payload.data.length - const drop = recordHiddenRendererPtyDataDrop(payload.id, droppedChars) - warnIfDroppingHiddenBytesForVisiblePty(payload.id, droppedChars) - if (drop.shouldEmitRestoreMarker) { - sendModelRestoreNeededMarker(payload.id, 'hidden-drop', outputSeq) - } - return - } - if (payload.data.length === 0 && !payload.transformed) { - if (projectionId) { - sshOutputIntake?.transferProjections([projectionId], 'empty-projection') - } - return - } - const containsBackgroundOutput = - rendererPtyIsKnownHidden(payload.id) || ptyHasHiddenRendererResizeOutput(payload.id) - if (containsBackgroundOutput) { - markHiddenRendererResizeOutputDelivered(payload.id) - } - const overflowMarkedBeforeAppend = pendingOverflowMarkedPtys.has(payload.id) - if (projection?.desktopSpan) { - sourceCreditPendingPtys.add(payload.id) - } - const pending = appendPendingPtyData( - payload.id, - pendingData.get(payload.id), - payload.data, - startSeq, - preservesSeq, - containsBackgroundOutput, - rawLength, - payload.transformed === true, - projectionId - ) - const shouldEmitPendingCapRestoreMarker = - pending.droppedOutput === true && - !overflowMarkedBeforeAppend && - pendingOverflowMarkedPtys.has(payload.id) - const nextData = pending.data + getDroppedMode2031RendererData(pending) - const isInteractiveOutput = shouldSendInteractiveOutputNow( - payload.id, - nextData, - performance.now() - ) - if (isInteractiveOutput && rendererPtyDispatcherReady) { - if (!canSendPtyDataToRenderer(payload.id, { interactive: true })) { - setPendingPtyData(payload.id, pending) - if (shouldEmitPendingCapRestoreMarker) { - sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq) - } - updateProducerFlowControl(payload.id) - requestDeliveryResyncForGatedPty() - return - } - deletePendingPtyData(payload.id) - clearFlushTimerIfIdle() - if (shouldEmitPendingCapRestoreMarker) { - sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq) - } - pendingOverflowMarkedPtys.delete(payload.id) - try { - sendPtyDataToRenderer( - payload.id, - { - id: payload.id, - data: nextData, - ...(typeof pending.startSeq === 'number' - ? { - seq: pending.startSeq + (pending.rawLength ?? nextData.length), - rawLength: pending.rawLength ?? nextData.length - } - : {}), - ...(pending.transformed ? { transformed: true } : {}), - ...(pending.containsBackgroundOutput === true ? { background: true } : {}), - ...(pending.droppedOutput === true ? { droppedOutput: true } : {}) - }, - pending.projectionAdmissionIds - ) - } finally { - updateProducerFlowControl(payload.id) - } - return - } - setPendingPtyData(payload.id, pending) - if (shouldEmitPendingCapRestoreMarker) { - sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq) - } - updateProducerFlowControl(payload.id) - if ( - !canSendPtyDataToRenderer(payload.id, { interactive: activeRendererPtys.has(payload.id) }) - ) { - requestDeliveryResyncForGatedPty() - } - if (!flushTimer) { - schedulePendingDataFlush(PTY_BATCH_INTERVAL_MS) - } - } - - sshOutputIntakeCleanup?.() - sshOutputIntake = new SshPtyOutputIntake({ - getModelSequence: (id) => runtime?.getPtyOutputSequence(id) ?? 0, - acceptModel: (event, projection) => { - if (!runtime) { - throw new Error('SSH PTY output requires the main terminal model') - } - return runtime.acceptPtyDataBounded( - event.id, - event.data, - Date.now(), - event.rawLength, - event.transformed, - projection.desktopSpan ? [projection.desktopSpan] : undefined - ) - }, - project: (event, projection) => - acceptPtyDataForRenderer( - { - id: event.id, - data: event.data, - sequenceChars: event.rawLength, - transformed: event.transformed - }, - projection.identity.sequenceEnd, - projection - ), - prepareExit: (event) => { - const release = preparePtyExitForRenderer(event) - if (!release) { - throw new Error('pty_renderer_exit_in_progress') - } - return release - }, - finalizeExit: (event) => { - runtime?.onPtyExit(event.id, event.code, event.ptyIncarnation, { - hostExitConfirmed: true - }) - finalizePtyExitForRenderer(event) - }, - pauseProvider: (generation, id) => { - const provider = sshProvidersByGeneration.get(generation) as - | (IPtyProvider & { hasPtyDeliveryPauseAdapter?: () => boolean }) - | undefined - if (!provider?.hasPtyDeliveryPauseAdapter?.()) { - return false - } - provider.pauseProducer?.(id) - return true - }, - resumeProvider: (generation, id) => - sshProvidersByGeneration.get(generation)?.resumeProducer?.(id), - closeProvider: (generation, reason) => { - const provider = sshProvidersByGeneration.get(generation) - ;( - provider as (IPtyProvider & { closeOutputIntake?: (reason: string) => void }) | undefined - )?.closeOutputIntake?.(reason) - }, - resetModelForMigration: (_generation, id) => runtime?.resetPtyModelAfterMigrationFailure(id), - onGenerationClosed: (providerGeneration) => { - for (const id of pendingData.keys()) { - const pending = pendingData.get(id) - if ( - pending?.projectionAdmissionIds && - sshOutputIntake?.hasProjectionFromGeneration( - pending.projectionAdmissionIds, - providerGeneration - ) - ) { - pendingData.delete(id) - updateProducerFlowControl(id) - pendingOverflowMarkedPtys.delete(id) - } - } - sshProvidersByGeneration.delete(providerGeneration) - }, - publishSourceAck: publishSshPtySourceAck, - cancelSourceDelivery: cancelSshPtySourceDelivery - }) - runtime?.setRemoteTerminalSourceRangeConsumerHooks?.( - sshOutputIntake.getRemoteSourceRangeConsumerHooks() - ) - const cleanupSshOutputIntakeRegistry = installSshPtyOutputIntake(sshOutputIntake) - sshOutputIntakeCleanup = () => { - runtime?.setRemoteTerminalSourceRangeConsumerHooks?.(null) - cleanupSshOutputIntakeRegistry() - } - - async function shutdownProviderAndDetectExit( - provider: IPtyProvider, - id: string, - opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } - ): Promise { - let providerExitObserved = false - const expectedIncarnationId = ptyIncarnationById.get(id) - const unsubscribe = provider.onExit((payload) => { - if ( - payload.id === id && - (!expectedIncarnationId || payload.incarnationId === expectedIncarnationId) - ) { - providerExitObserved = true - } - }) - try { - await provider.shutdown(id, opts) - } finally { - unsubscribe() - } - return providerExitObserved - } - - // Why extracted: the "Restart daemon" flow rebinds against the fresh adapter after replaceDaemonProvider, sharing this code path with startup registration. - const bindProviderListeners = (): void => { - localDataUnsub?.() - localExitUnsub?.() - localBackgroundStreamUnsub?.() - localWriteUnavailableUnsub?.() - - // Why: a daemon death takes down every session at once. The provider signals - // each affected pane here so background panes remount + re-attach too, not - // just the pane whose write happened to detect the dead endpoint (STA-2373). - localWriteUnavailableUnsub = - localProvider.onWriteUnavailable?.((payload) => { - if (isRendererGone(mainWindow)) { - return - } - sendToRenderer(mainWindow, 'pty:writeUnavailable', { id: payload.id }) - }) ?? null - - // Daemon keep-tail thinning facts, in byte order with onData: markers flip transient-fact scan authority; a gap forces renderer restore from the snapshot. - localBackgroundStreamUnsub = - localProvider.onBackgroundStreamEvent?.((payload) => { - if (payload.kind === 'backgroundMarker') { - runtime?.setPtyTransientFactDelegation( - payload.id, - payload.background, - payload.scanSeedAnsi, - payload.mode2031PendingSubscribe - ) - return - } - if (payload.kind === 'dataGap') { - providerSnapshotRequiredPtys.add(payload.id) - runtime?.notePtyDataGap(payload.id, payload.sequenceChars ?? payload.droppedChars) - sendModelRestoreNeededMarker( - payload.id, - 'hidden-drop', - runtime?.getPtyOutputSequence(payload.id) - ) - return - } - runtime?.emitDaemonPtyTransientFact(payload.id, payload.fact) - }) ?? null - - // Why: daemon providers lack configure().onData, so feed the runtime here or their tail buffer (terminal.read, agent-detection, mobile stream) stays empty. - const isLocalProvider = localProvider instanceof LocalPtyProvider - - localDataUnsub = localProvider.onData((payload) => { - const rawLength = payload.sequenceChars ?? payload.data.length - const outputSeq = isLocalProvider - ? runtime?.getPtyOutputSequence(payload.id) - : runtime?.onPtyData(payload.id, payload.data, Date.now(), rawLength, payload.transformed) - acceptPtyDataForRenderer(payload, outputSeq) - }) - localExitUnsub = localProvider.onExit((payload) => { - if (!isCurrentPtyExit(payload)) { - return - } - if (consumeSyntheticKillExit(payload.id)) { - return - } - if (!isLocalProvider) { - clearProviderPtyState(payload.id) - ptyOwnership.delete(payload.id) - markClaudePtyExited(payload.id) - runtime?.onPtyExit( - payload.id, - payload.code, - payload.incarnationId, - payload.cause ? { cause: payload.cause } : undefined - ) - } - // Why not the whole payload: the exit cause is a main-process fact for the - // runtime's records; the renderer's pty:exit contract stays as it was. - sendPtyExitToRenderer({ - id: payload.id, - code: payload.code, - ...(payload.incarnationId ? { incarnationId: payload.incarnationId } : {}) - }) - }) - } - - bindProviderListeners() - rebindProviderListeners = bindProviderListeners - - // Why: one persistent listener with a request-ID dispatch table instead of one per call, so concurrent serialize requests don't trip Node's MaxListeners=10 warning. - type SerializeResult = { - data: string - cols: number - rows: number - seq?: number - lastTitle?: string - kittyKeyboardFlags?: number - } | null - const pendingSerializeRequests = new Map< - string, - { resolve: (result: SerializeResult) => void; timeout: NodeJS.Timeout } - >() - - function settleSerializeRequest(requestId: string, result: SerializeResult): void { - const pending = pendingSerializeRequests.get(requestId) - if (!pending) { - return - } - clearTimeout(pending.timeout) - pendingSerializeRequests.delete(requestId) - pending.resolve(result) - } - - ipc.on( - 'pty:serializeBuffer:response', - ( - _event, - args: { - requestId?: string - snapshot?: { - data?: unknown - cols?: unknown - rows?: unknown - seq?: unknown - lastTitle?: unknown - kittyKeyboardFlags?: unknown - } | null - } - ) => { - if (typeof args?.requestId !== 'string') { - return - } - const snapshot = args.snapshot - if ( - snapshot && - typeof snapshot.data === 'string' && - typeof snapshot.cols === 'number' && - typeof snapshot.rows === 'number' - ) { - const result: { - data: string - cols: number - rows: number - seq?: number - lastTitle?: string - kittyKeyboardFlags?: number - } = { - data: snapshot.data, - cols: snapshot.cols, - rows: snapshot.rows - } - if (typeof snapshot.seq === 'number' && Number.isFinite(snapshot.seq)) { - result.seq = snapshot.seq - } - if (typeof snapshot.lastTitle === 'string' && snapshot.lastTitle.length > 0) { - result.lastTitle = snapshot.lastTitle - } - // Why gated on seq: without a boundary the flags cannot be reconciled - // against live bytes, so they prove nothing. - const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags(snapshot.kittyKeyboardFlags) - if (result.seq !== undefined && kittyKeyboardFlags !== undefined) { - result.kittyKeyboardFlags = kittyKeyboardFlags - } - settleSerializeRequest(args.requestId, result) - } else { - settleSerializeRequest(args.requestId, null) - } - } - ) - - function requestSerializedBuffer( - ptyId: string, - opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } - ): Promise { - if (isRendererGone(mainWindow)) { - return Promise.resolve(null) - } - - const requestId = randomUUID() - return new Promise((resolve) => { - const timeout = setTimeout(() => { - settleSerializeRequest(requestId, null) - }, 750) - pendingSerializeRequests.set(requestId, { resolve, timeout }) - const payload: { - requestId: string - ptyId: string - opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } - } = { requestId, ptyId } - if (opts) { - payload.opts = opts - } - sendToRenderer(mainWindow, 'pty:serializeBuffer:request', payload) - }) - } - - // Why: reload/crash orphans delivery-interest holds and hidden marks; reset so surviving PTYs aren't stuck force-fed or gated — each pane's first sync re-marks. - clearRendererGateResetHandlers() - const resetRendererPtyDeliveryGateState = (): void => { - const gateDebug = getHiddenRendererPtyDeliveryDebug() - resetRendererScopedHiddenPtyDeliveryState() - if (gateDebug.hiddenDeliveryGatedPtyCount > 0 || gateDebug.deliveryInterestPtyCount > 0) { - invalidatePendingPtyDrainPolicy() - } - // Why: the daemon pacer must not keep throttling ptys whose hidden marks died with the renderer; the fresh renderer's sync re-marks the still-hidden ones. - resyncBackgroundedDeliveriesAfterGateReset() - } - rendererGateResetLoadHandler = resetRendererPtyDeliveryGateState - rendererGateResetGoneHandler = resetRendererPtyDeliveryGateState - // Why guarded: with no renderer there is nothing to reset a gate for, and nothing - // that will ever emit these. Registering against a fake window was the old workaround. - if (rendererContents) { - rendererGateResetWebContents = rendererContents - rendererContents.on('did-finish-load', rendererGateResetLoadHandler) - rendererContents.on('render-process-gone', rendererGateResetGoneHandler) - } - - // Why: only LocalPtyProvider PTYs (main-process) can be orphaned on reload; daemon sessions survive by design and cleanup would kill them. - clearDidFinishLoadHandler() - if (localProvider instanceof LocalPtyProvider) { - const lp = localProvider - didFinishLoadHandler = () => { - // Why: always advance to keep the generation monotonic, but skip the sweep on crash/freeze-recovery reload — it would kill live local PTYs before session restore (#5787). - const generation = lp.advanceGeneration() - if (rendererContents && options?.isRecoveryReloadInFlight?.(rendererContents.id)) { - return - } - // Why: the retained provider onExit callback is the only physical-exit proof; it clears ownership after the OS reaps it. - lp.killOrphanedPtys(generation - 1) - } - if (rendererContents) { - didFinishLoadWebContents = rendererContents - rendererContents.on('did-finish-load', didFinishLoadHandler) - } - } - - const assertFolderWorkspacePtyPathUsable = async ( - worktreeId: string | undefined - ): Promise => { - const workspaceScope = typeof worktreeId === 'string' ? parseWorkspaceKey(worktreeId) : null - if (!store || workspaceScope?.type !== 'folder') { - return - } - const status = await getFolderWorkspacePathStatus( - store, - { scope: 'folder-workspace', folderWorkspaceId: workspaceScope.folderWorkspaceId }, - { getSshFilesystemProvider } - ) - assertFolderWorkspacePathUsable(status) - } - - const resolvePtySpawnStartupCwd = ( - worktreeId: string | undefined, - cwd: string | undefined, - missingDirFallback?: TerminalStartupCwdMissingDirFallback - ): string | undefined => - resolveTerminalStartupCwdForWorkspace({ - workspaceId: worktreeId, - requestedCwd: cwd, - missingDirFallback, - resolveFolderWorkspacePath: (folderWorkspaceId) => - store?.getFolderWorkspace(folderWorkspaceId)?.folderPath - }) - - const localStartupCwdDirectoryExists = (path: string): boolean => { - // Why: Win32 statSync on \\wsl.localhost 9P shares can falsely report ENOENT; defer to the provider's WSL-aware validation. - if (isWslUncPath(path)) { - return true - } - try { - return statSync(path).isDirectory() - } catch { - return false - } - } - - const prepareCodexResumeHome = (args: { - connectionId?: string | null - launchAgent?: TuiAgent - providerSession?: AgentProviderSessionMetadata - target: CodexAccountSelectionTarget - launchEnv?: NodeJS.ProcessEnv - workspacePath?: string - }): { - providerSession: AgentProviderSessionMetadata - preparation: Promise - } | null => { - if (args.connectionId || args.launchAgent !== 'codex' || !options?.prepareCodexSessionResume) { - return null - } - const providerSession = normalizeAgentProviderSession(args.providerSession) - if (!providerSession) { - return null - } - return { - providerSession, - preparation: options.prepareCodexSessionResume({ - providerSession, - target: args.target, - launchEnv: args.launchEnv, - workspacePath: args.workspacePath - }) - } - } - - type CodexResumeLaunch = { - codexResumeHome: Extract | null - command: string | undefined - notifyResumeUnavailable: boolean - droppedResumeArgv: boolean - providerSession: AgentProviderSessionMetadata | null - } - - /** Kept separate from resolveCodexResumeLaunch so non-Codex spawns never await: - * an extra tick reorders the pane-spawn reservation races this handler arbitrates. */ - const noCodexResumeLaunch = (command: string | undefined): CodexResumeLaunch => ({ - codexResumeHome: null, - command, - notifyResumeUnavailable: false, - droppedResumeArgv: false, - providerSession: null - }) - - /** The command a Codex launch actually runs: unchanged when provenance is verified, - * stripped of `resume ` when it is not. */ - const resolveCodexResumeLaunch = ( - command: string | undefined, - preparation: NonNullable> - ): Promise => - preparation.preparation.then((prepared) => { - const providerSession = preparation.providerSession - if (prepared?.outcome !== 'fresh') { - return { - codexResumeHome: prepared ?? null, - command, - notifyResumeUnavailable: false, - droppedResumeArgv: false, - providerSession - } - } - const dropped = dropUnverifiedCodexResumeArgv({ - command, - providerSession, - claimedCodexProvenance: prepared.claimedCodexProvenance - }) - return { - codexResumeHome: null, - command: dropped.command, - // Why: staying silent only makes sense for metadata that positively belongs to - // another agent; a resume with no transcript path at all still owes the user a notice. - notifyResumeUnavailable: - dropped.droppedResumeArgv && - (prepared.claimedCodexProvenance || !providerSession.transcriptPath), - droppedResumeArgv: dropped.droppedResumeArgv, - providerSession - } - }) - - const reconcileSharedRuntimeResumeHome = ( - resumeHome: Extract, - resolveCurrentHome: () => string | null - ): string => { - if (!resumeHome.reconcileSharedRuntimeAuth) { - return resumeHome.codexHomePath - } - const currentHome = resolveCurrentHome() - if (!codexHomePathsEqual(currentHome, resumeHome.codexHomePath)) { - throw new Error(CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE) - } - return resumeHome.codexHomePath - } - - /** Why: buildPtyHostEnv prefers ORCA_SEQUENCED_STARTUP_COMMAND over the launch command - * and the sequenced wrapper `eval`s it, so a dropped resume argv has to go there too. */ - const stripSequencedStartupResumeArgv = | undefined>( - env: T, - launch: CodexResumeLaunch - ): T => { - const sequenced = env?.[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV] - if (!env || !sequenced || !launch.droppedResumeArgv || !launch.providerSession) { - return env - } - const drop = dropAgentResumeArgvFromCommand({ - command: sequenced, - agent: 'codex', - providerSession: launch.providerSession - }) - return drop.status === 'dropped' - ? { ...env, [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: drop.command } - : env - } - - const adoptStablePane = async (args: { - cols: number - rows: number - cwd?: string - connectionId?: string | null - worktreeId: string - preAllocatedHandle?: string - tabId: string - leafId: string - ownsPaneSpawnReservation?: true - }) => { - const paneKey = makePaneKey(args.tabId, args.leafId) - const ownerKey = makePaneSpawnReservationKey(args.worktreeId, args.connectionId, paneKey) - const pendingAdoption = ownerKey ? stablePaneAdoptionsByOwnerKey.get(ownerKey) : undefined - if (pendingAdoption) { - return await pendingAdoption - } - const activePaneSpawn = - ownerKey && !args.ownsPaneSpawnReservation - ? paneSpawnReservationsByOwnerKey.get(ownerKey) - : undefined - if (activePaneSpawn) { - const result = await activePaneSpawn.promise - const owner = resolveStablePaneOwner( - runtime, - store, - paneKey, - args.worktreeId, - args.connectionId - ) - if ( - !owner || - owner.ptyId !== result.id || - (owner.incarnationId !== undefined && - result.incarnationId !== undefined && - owner.incarnationId !== result.incarnationId) - ) { - throw new Error('terminal_pane_owner_changed') - } - return { - result: { - ...result, - isReattach: true, - ...(owner.incarnationId ? { incarnationId: owner.incarnationId } : {}) - }, - owner, - materialized: true as const - } - } - const owner = resolveStablePaneOwner( - runtime, - store, - paneKey, - args.worktreeId, - args.connectionId - ) - if (!owner) { - return null - } - const adoption = attachStablePaneOwner({ - runtime, - store, - provider: getProvider(args.connectionId), - spawnOptions: { - cols: args.cols, - rows: args.rows, - cwd: args.cwd - }, - owner, - worktreeId: args.worktreeId, - connectionId: args.connectionId, - resolveOwner: () => - resolveStablePaneOwner(runtime, store, paneKey, args.worktreeId, args.connectionId) - }) - if (!ownerKey) { - return await adoption - } - stablePaneAdoptionsByOwnerKey.set(ownerKey, adoption) - try { - return await adoption - } finally { - if (stablePaneAdoptionsByOwnerKey.get(ownerKey) === adoption) { - stablePaneAdoptionsByOwnerKey.delete(ownerKey) - } - } - } - - const markSshInventoryUnverifiable = (connectionId: string, error: unknown): void => { - const reason = error instanceof Error ? error.message : String(error) - for (const [ptyId, ownerConnectionId] of ptyOwnership) { - if (ownerConnectionId === connectionId) { - runtime?.markPtyLivenessUnverifiable?.(ptyId, reason) - } - } - } - - // Why: route through getProviderForPty() so CLI commands work for remote PTYs too; localProvider would silently fail for them. - runtime?.setPtyController({ - claimStablePaneCreate: (args) => { - const paneKey = makePaneKey(args.tabId, args.leafId) - const ownerKey = makePaneSpawnReservationKey(args.worktreeId, args.connectionId, paneKey) - return ownerKey ? claimRuntimePaneCreate(ownerKey) : () => {} - }, - adoptStablePane, - spawn: async (args) => { - const codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined - const codexHomeLaunchStartedSequence = !args.connectionId ? ++ptyLifecycleSequence : undefined - const preAdoptedStablePane = args.adoptedStablePane ?? null - const reattachedCodexHomeRoutes = !args.connectionId - ? snapshotCodexPaneHomeRoutes([ - preAdoptedStablePane?.result.id, - args.sessionId ? getAppPtyId(args.connectionId, args.sessionId) : undefined - ]) - : new Map() - const startupPromise = getLocalPtyStartupPromise(args.connectionId) - if (startupPromise) { - await startupPromise - } - if (preAdoptedStablePane?.materialized) { - const handle = preAdoptedStablePane.owner.handle ?? args.preAllocatedHandle - if (!handle) { - throw new Error('terminal_pane_owner_unknown') - } - const result = { - id: preAdoptedStablePane.result.id, - ...(preAdoptedStablePane.result.incarnationId - ? { incarnationId: preAdoptedStablePane.result.incarnationId } - : {}), - ...(typeof preAdoptedStablePane.result.wslDistro === 'string' - ? { wslDistro: preAdoptedStablePane.result.wslDistro } - : {}), - stablePaneOwner: { - handle, - tabId: preAdoptedStablePane.owner.tabId, - leafId: preAdoptedStablePane.owner.leafId - } - } - ensureWslHookRelayForReattach( - { isReattach: true, wslDistro: preAdoptedStablePane.result.wslDistro }, - args.connectionId - ) - if (!args.connectionId) { - options?.onCodexHomePtySpawned?.({ - id: result.id, - codexHomePath: null, - reattached: true, - startedAt: codexHomeLaunchStartedAt, - startedSequence: codexHomeLaunchStartedSequence, - ...codexReattachedHomeRouteField(reattachedCodexHomeRoutes, result.id, true) - }) - } - return result - } - if (!preAdoptedStablePane) { - await assertFolderWorkspacePtyPathUsable(args.worktreeId) - } - const cwd = resolvePtySpawnStartupCwd(args.worktreeId, args.cwd) - const provider = getProvider(args.connectionId) - const freshSpawnRecovery = preAdoptedStablePane - ? undefined - : recoverFreshSpawnProviderRouting( - provider, - args.connectionId, - args.sessionId, - args.isNewSession - ) - if (freshSpawnRecovery) { - await freshSpawnRecovery - } - const isClaudeLaunch = - !preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) - if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error('A Claude account switch is in progress. Try again after it finishes.') - } - // Why: runtime-created terminals carry no renderer-computed projectRuntime; resolve from worktreeId to honor the project's Windows runtime. - const terminalRuntimeOptions = - process.platform === 'win32' && !args.connectionId - ? resolveLocalWindowsTerminalRuntimeOptions({ - requestedShellOverride: undefined, - settings: getSettings?.(), - projectRuntime: resolveLocalProjectRuntimeForWorktreeId(store, args.worktreeId), - fallbackHostShell: process.env.COMSPEC || 'powershell.exe' - }) - : { shellOverride: undefined, terminalWindowsWslDistro: null } - const daemonShellOverride = terminalRuntimeOptions.shellOverride - const isDaemonHostSpawn = - !args.connectionId && - !(provider instanceof LocalPtyProvider) && - !routesFreshSpawnsToLocalProvider(provider) - const callerRequestedSessionId = args.sessionId?.trim() - const requestedSessionId = - callerRequestedSessionId ?? - (isDaemonHostSpawn && args.agentSessionCreateOperationId - ? ptySessionIdForAgentCreateOperation(args.worktreeId, args.agentSessionCreateOperationId) - : undefined) - const sessionId = - requestedSessionId ?? (isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined) - const effectiveSessionRelayId = - sessionId !== undefined ? getRelayPtyId(args.connectionId, sessionId) : undefined - const effectiveSessionAppId = - sessionId !== undefined ? getAppPtyId(args.connectionId, sessionId) : undefined - const isNewDaemonSession = - !preAdoptedStablePane && - isDaemonHostSpawn && - (callerRequestedSessionId === undefined || args.isNewSession === true) - const expectedWslDistro = !args.connectionId - ? (resolveWslSessionContext({ - cwd, - sessionId, - shellOverride: terminalRuntimeOptions.shellOverride, - terminalWindowsWslDistro: terminalRuntimeOptions.terminalWindowsWslDistro - })?.distro ?? null) - : null - const codexSelectionTarget = getCodexSelectionTargetForPty( - daemonShellOverride, - cwd, - expectedWslDistro - ) - const codexResumePreparation = preAdoptedStablePane - ? null - : prepareCodexResumeHome({ - connectionId: args.connectionId, - launchAgent: args.launchAgent, - providerSession: args.resumeProviderSession, - target: codexSelectionTarget, - launchEnv: args.env, - workspacePath: cwd - }) - const codexResumeLaunch = codexResumePreparation - ? await resolveCodexResumeLaunch(args.command, codexResumePreparation) - : noCodexResumeLaunch(preAdoptedStablePane ? undefined : args.command) - const codexResumeHome = codexResumeLaunch.codexResumeHome - // Why: the drop still applies here, but this controller's result has no field for - // notifyResumeUnavailable — runtime/relay panes start fresh without the notice. - const launchCommand = codexResumeLaunch.command - const claudeAuth = - isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth(codexSelectionTarget) : null - if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error('A Claude account switch is in progress. Try again after it finishes.') - } - if (claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) { - throw new Error( - 'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.' - ) - } - - const shouldPersistHostSessionBinding = args.persistHostSessionBinding === true - let hostSessionBinding: { - store: NonNullable - worktreeId: string - tabId: string - leafId: string - expectedSourceBinding?: PtyBindingSourceExpectation - } | null = null - if (shouldPersistHostSessionBinding) { - if ( - !store || - typeof args.worktreeId !== 'string' || - typeof args.tabId !== 'string' || - !isValidTerminalTabId(args.tabId) || - typeof args.leafId !== 'string' || - !isTerminalLeafId(args.leafId) - ) { - throw new Error( - 'Cannot persist runtime PTY binding without worktreeId, tabId, and leafId' - ) - } - hostSessionBinding = { - store, - worktreeId: args.worktreeId, - tabId: args.tabId, - leafId: args.leafId, - ...(args.expectedSourceBinding - ? { expectedSourceBinding: args.expectedSourceBinding } - : {}) - } - } - const sshScopedEnv = stripRemotePaneEnvWhenHooksDisabled(args.connectionId, args.env) - let env: Record | undefined = claudeAuth - ? { ...sshScopedEnv, ...claudeAuth.envPatch } - : sshScopedEnv - const requestedAgentTeamsPath = env?.ORCA_AGENT_TEAMS_TEAM_ID - ? env[resolvePathEnvKey(env, process.platform)] - : undefined - env = stripSequencedStartupResumeArgv(env, codexResumeLaunch) - if (args.preAllocatedHandle) { - env = { ...env, ORCA_TERMINAL_HANDLE: args.preAllocatedHandle } - } - let selectedCodexHomePath = resolveSelectedCodexHomeOrRefuseSpawn(() => - !preAdoptedStablePane && !args.connectionId - ? getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - codexResumeHome - ? reconcileSharedRuntimeResumeHome(codexResumeHome, () => - getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, env, { - workspacePath: cwd, - launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined - }) ?? null - ) - ) - : (getSelectedCodexHomePath?.(codexSelectionTarget, env, { - workspacePath: cwd, - launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined - }) ?? null) - ) - : null - ) - if ( - !preAdoptedStablePane && - args.launchAgent === 'codex' && - callerRequestedSessionId === undefined - ) { - const resolution = resolveCodexHomeAfterManagedAuthReadiness({ - selectedCodexHomePath, - getSettings: () => getSettings?.(), - requiredCodexHomePath: codexResumeHome?.codexHomePath, - target: codexSelectionTarget, - resolveCurrent: () => - resolveSelectedCodexHomeOrRefuseSpawn(() => - getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, env, { - workspacePath: cwd, - launchAgent: 'codex' - }) ?? null - ) - ), - resolveAfterUnavailable: (unavailableManagedHomePath) => - resolveSelectedCodexHomeOrRefuseSpawn(() => - getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, env, { - workspacePath: cwd, - launchAgent: 'codex', - unavailableManagedHomePath - }) ?? null - ) - ) - }) - selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution - } - if (args.launchAgent === 'codex' && selectedCodexHomePath) { - await ensureCodexStateDbBackfillRecoveryStarted(selectedCodexHomePath) - } - const codexResumeHomeSelected = Boolean( - codexResumeHome && codexHomePathsEqual(selectedCodexHomePath, codexResumeHome.codexHomePath) - ) - const skipCodexHomeEnv = - isDaemonHostSpawn && - shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd) && - !selectedCodexHomePath - const ptySettings = isDaemonHostSpawn ? getSettings?.() : undefined - const stripInheritedOrcaCodexHome = - isDaemonHostSpawn && - shouldStripInheritedOrcaCodexHome({ - target: codexSelectionTarget, - selectedCodexHomePath, - skipCodexHomeEnv, - settings: ptySettings - }) - if (isDaemonHostSpawn && sessionId && !preAdoptedStablePane) { - if (!isSafePtySessionId(sessionId, getAppEnvironment().getPath('userData'))) { - throw new Error('Invalid PTY session id') - } - env = buildPtyHostEnv(sessionId, env ?? {}, { - isPackaged: getAppEnvironment().isPackaged(), - resourcesPath: process.resourcesPath, - userDataPath: getAppEnvironment().getPath('userData'), - selectedCodexHomePath, - skipCodexHomeEnv, - stripInheritedOrcaCodexHome, - launchCommand, - launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, - isWsl: shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd), - wslDistro: codexSelectionTarget.runtime === 'wsl' ? expectedWslDistro : null, - agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings), - codexStatusHooksEnabled: isCodexStatusHooksEnabled(ptySettings), - networkProxySettings: ptySettings, - deferGitConfigGuardToDaemon: provider.supportsGitCredentialGuardHost?.(sessionId) === true - }) - stampWslOrchestrationCompatibilityHost( - env, - runtime?.getOrchestrationCompatibilityHostId?.(), - codexSelectionTarget.runtime === 'wsl' ? expectedWslDistro : null - ) - promoteAgentTeamsShimPath(env, requestedAgentTeamsPath) - } - - const authEnvToDelete = claudeAuth?.stripAuthEnv - ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] - : undefined - const spawnOptions: PtySpawnOptions = { - cols: args.cols, - rows: args.rows, - cwd, - env, - ...(isNewDaemonSession ? { isNewSession: true } : {}), - historyIsolationEnabled: getSettings?.()?.terminalScopeHistoryByWorktree ?? true - } - if (!args.connectionId && !isDaemonHostSpawn) { - spawnOptions.codexHomePathOverride = { value: selectedCodexHomePath } - } - const startupTerminalColorQueryReplyColors = getStartupTerminalColorQueryReplyColors(args) - if (startupTerminalColorQueryReplyColors) { - spawnOptions.startupIngress = { - colors: startupTerminalColorQueryReplyColors, - deadlineMs: 5_000 - } - } - let ptySpawnCommitReported = false - const reportPtySpawnCommitted = (): void => { - if (ptySpawnCommitReported) { - return - } - ptySpawnCommitReported = true - args.onPtySpawnCommitted?.() - } - spawnOptions.envToDelete = mergePtyEnvDeletions( - authEnvToDelete, - args.envToDelete ?? [], - // Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH. - isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [], - isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(env) : [], - // Why: ungated, unlike the agent-hook keys — the local provider and the relay host also spread their own process.env into every spawn. - getInheritedClaudeSessionStampEnvKeysToDelete(env) - ) - if (skipCodexHomeEnv) { - spawnOptions.envToDelete = mergePtyEnvDeletions( - spawnOptions.envToDelete, - CODEX_HOME_ENV_KEYS - ) - } else if (stripInheritedOrcaCodexHome) { - // Why: the daemon owns a persistent inherited environment that may - // differ from main. ORCA_CODEX_HOME asks it to compare/delete the pair. - spawnOptions.envToDelete = mergePtyEnvDeletions(spawnOptions.envToDelete, [ - 'ORCA_CODEX_HOME' - ]) - } - if (codexResumeHomeSelected) { - spawnOptions.envToDelete = removeCodexHomeDeletionRequests(spawnOptions.envToDelete) - } - deleteRequestedEnvKeys(env, spawnOptions.envToDelete) - promoteAgentTeamsShimPath(env, requestedAgentTeamsPath) - if (launchCommand !== undefined) { - spawnOptions.command = launchCommand - } - if (args.commandDelivery !== undefined) { - spawnOptions.commandDelivery = args.commandDelivery - } - if (args.startupCommandDelivery !== undefined) { - spawnOptions.startupCommandDelivery = args.startupCommandDelivery - } - if (isTuiAgent(args.launchAgent)) { - spawnOptions.launchAgent = args.launchAgent - } - if (args.worktreeId !== undefined) { - spawnOptions.worktreeId = args.worktreeId - } - const hadSessionSizeBeforeAttach = - effectiveSessionAppId !== undefined ? ptySizes.has(effectiveSessionAppId) : false - const sessionSizeBeforeAttach = - effectiveSessionAppId !== undefined ? ptySizes.get(effectiveSessionAppId) : undefined - if (sessionId !== undefined) { - spawnOptions.sessionId = sessionId - ptySizes.set(effectiveSessionAppId ?? sessionId, { cols: args.cols, rows: args.rows }) - } - const materializedPaneKey = hostSessionBinding - ? makePaneKey(hostSessionBinding.tabId, hostSessionBinding.leafId) - : null - const metadataLeafId = - typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null - const metadataPaneKey = - typeof args.tabId === 'string' && - isValidTerminalTabId(args.tabId) && - args.tabId.length <= 512 && - metadataLeafId - ? makePaneKey(args.tabId, metadataLeafId) - : null - const spawnIdentityPaneKey = materializedPaneKey ?? metadataPaneKey - if (spawnIdentityPaneKey) { - spawnOptions.paneKey = spawnIdentityPaneKey - } - if (typeof args.tabId === 'string' && args.tabId.length > 0 && args.tabId.length <= 512) { - spawnOptions.tabId = args.tabId - } - if (process.platform === 'win32' && !args.connectionId) { - spawnOptions.shellOverride = terminalRuntimeOptions.shellOverride - spawnOptions.terminalWindowsWslDistro = expectedWslDistro - spawnOptions.terminalWindowsPowerShellImplementation = getSettings - ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') - : undefined - } - if ( - !preAdoptedStablePane && - args.agentSessionEnsure && - (await (provider as IPtyProvider).supportsAgentSessionClaims?.()) === false - ) { - // Why: runtime routing must select legacy before dispatch; never downgrade here after it began. - throw new Error('agent_session_claim_unavailable') - } - if ( - !preAdoptedStablePane && - args.agentSessionCreateOperationId && - (await (provider as IPtyProvider).supportsAgentSessionCreateOperations?.()) === false - ) { - throw new Error('execution_owner_unavailable') - } - if (!preAdoptedStablePane && args.agentSessionEnsure) { - spawnOptions.agentSessionEnsure = args.agentSessionEnsure - } - if (!preAdoptedStablePane && args.agentSessionCreateOperationId) { - spawnOptions.agentSessionCreateOperationId = args.agentSessionCreateOperationId - } - if (args.signal) { - spawnOptions.signal = args.signal - } - if ( - args.onPtySpawnCommitted && - (provider instanceof LocalPtyProvider || routesFreshSpawnsToLocalProvider(provider)) - ) { - // Why: local fallback has no lower operation ledger, so commit must be reported at native spawn. - spawnOptions.onPtySpawnCommitted = reportPtySpawnCommitted - } - - const paneSpawnReservationKey = makePaneSpawnReservationKey( - args.worktreeId, - args.connectionId, - spawnIdentityPaneKey - ) - const existingPaneSpawn = paneSpawnReservationKey - ? paneSpawnReservationsByOwnerKey.get(paneSpawnReservationKey) - : undefined - if (existingPaneSpawn) { - const concurrentResult = await existingPaneSpawn.promise - const concurrentOwner = resolveStablePaneOwner( - runtime, - store, - spawnIdentityPaneKey, - args.worktreeId, - args.connectionId - ) - if ( - !concurrentOwner?.handle || - concurrentOwner.ptyId !== concurrentResult.id || - (concurrentOwner.incarnationId !== undefined && - concurrentResult.incarnationId !== undefined && - concurrentOwner.incarnationId !== concurrentResult.incarnationId) - ) { - throw new Error('terminal_pane_owner_unknown') - } - return { - id: concurrentOwner.ptyId, - ...(concurrentOwner.incarnationId - ? { incarnationId: concurrentOwner.incarnationId } - : {}), - stablePaneOwner: { - handle: concurrentOwner.handle, - tabId: concurrentOwner.tabId, - leafId: concurrentOwner.leafId - } - } - } - const finishTerminalInstall = beginPtySpawnForWorktree( - args.worktreeId, - cwd, - args.connectionId - ) - const paneSpawnReservation = paneSpawnReservationKey - ? reservePaneSpawn(paneSpawnReservationKey) - : null - let result: PtySpawnResult - let stablePaneOwner: StablePaneOwner | null = null - let stablePaneBindingPersisted = false - let rejectedRegistrationCandidate: PtySpawnResult | null = null - let pendingRegistrationPtyId: string | null = null - // Why hoisted to the reply scope: main reconciles the provider sequence - // deep inside the spawn path, but the pane needs that renderer-domain - // boundary beside the daemon snapshot's kitty flags. - let reconciledSnapshotSeq: number | null = null - // False when bytes crossed the data socket during the spawn RPC: the - // reconciled boundary covers them, but the daemon proved its kitty flags - // before they existed, so the claim must not erase what the pane may - // have scanned from those bytes live. - let snapshotKittyFlagsCoverReconciledSeq = true - let preparedProvisionalExecutionContext = false - let releaseWorktreeSpawn: (() => void) | undefined - try { - releaseWorktreeSpawn = await runtime?.acquireWorktreeTerminalSpawn?.(args.worktreeId) - try { - if (args.preAllocatedHandle) { - trustedTerminalHandleEnv.add(args.preAllocatedHandle) - } - const stablePaneOwnerCandidate = preAdoptedStablePane - ? preAdoptedStablePane.owner - : args.agentSessionEnsure - ? null - : resolveStablePaneOwner( - runtime, - store, - spawnIdentityPaneKey, - args.worktreeId, - args.connectionId - ) - const expectedPtyId = - stablePaneOwnerCandidate?.ptyId ?? effectiveSessionAppId ?? sessionId - if (expectedPtyId) { - runtime?.beginPtyRegistration?.(expectedPtyId) - pendingRegistrationPtyId = expectedPtyId - } - if (isDaemonHostSpawn && expectedPtyId) { - preparedProvisionalExecutionContext = - runtime?.preparePtyExecutionContext?.(expectedPtyId, expectedWslDistro, { - resetIncarnation: isNewDaemonSession && !stablePaneOwnerCandidate, - preserveExisting: !isNewDaemonSession || Boolean(stablePaneOwnerCandidate) - }) ?? false - } - const sequenceBeforeProviderSpawn = expectedPtyId - ? (runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0) - : 0 - const assertClientStillConnected = (): void => { - if (args.signal?.aborted) { - throw new Error('client_disconnected') - } - } - if (args.agentSessionEnsure && !preAdoptedStablePane) { - // Why: daemon-backed claims can outlive this controller; import all - // proven owners before deciding that an identity is absent. - await reconcileAgentSessionOwnerListings() - const recoveredOwner = agentSessionOwners.find(args.agentSessionEnsure.claim) - if (recoveredOwner && pendingRegistrationPtyId !== recoveredOwner.ptyId) { - if (pendingRegistrationPtyId) { - runtime?.cancelPendingPtyRegistration?.(pendingRegistrationPtyId) - } - runtime?.beginPtyRegistration?.( - recoveredOwner.ptyId, - ptyIncarnationById.get(recoveredOwner.ptyId) - ) - pendingRegistrationPtyId = recoveredOwner.ptyId - } - let providerResult: PtySpawnResult | null = null - const ensured = await agentSessionOwners.ensure({ - claim: args.agentSessionEnsure.claim, - surface: args.agentSessionEnsure.surface, - spawn: async () => { - assertClientStillConnected() - providerResult = await provider.spawn(spawnOptions) - rejectedRegistrationCandidate = providerResult - // Why: a successful lower-owner return proves physical work committed even if admission sees an early exit. - reportPtySpawnCommitted() - assertSpawnReplyWasLive(providerResult) - runtime?.assertPtyRegistrationAllowed?.( - providerResult.id, - providerResult.incarnationId - ) - if (providerResult.incarnationId) { - // Why: local providers cannot serialize controller claims, so liveness proof - // needs the exact incarnation before the registry promotes the new owner. - ptyIncarnationById.set(providerResult.id, providerResult.incarnationId) - } - const providerEnsure = providerResult.agentSessionEnsure - return { - ptyId: providerResult.id, - ...(providerEnsure - ? { - owner: providerEnsure.owner, - disposition: providerEnsure.disposition - } - : {}) - } - }, - isLive: async (owner) => { - const ownerProvider = tryGetProviderForAgentSessionOwner(owner.ptyId) - if (!ownerProvider) { - // Why: a disconnected relay may keep its PTY alive during the - // grace window; missing transport is unknown, never absence. - throw new Error('execution_owner_unavailable') - } - return await isProviderAgentSessionOwnerLive(ownerProvider, owner) - } - }) - result = providerResult ?? { - id: ensured.owner.ptyId, - isReattach: true, - // Why: adoption from an authoritative listing must preserve the - // incarnation proof used to reject a delayed exit from an older process. - incarnationId: ptyIncarnationById.get(ensured.owner.ptyId) - } - result.agentSessionEnsure = ensured - } else { - assertClientStillConnected() - const stablePaneSpawn = preAdoptedStablePane - ? preAdoptedStablePane - : await spawnForStablePane({ - runtime, - store, - provider, - spawnOptions, - owner: stablePaneOwnerCandidate, - worktreeId: args.worktreeId, - connectionId: args.connectionId, - resolveOwner: () => - resolveStablePaneOwner( - runtime, - store, - spawnIdentityPaneKey, - args.worktreeId, - args.connectionId - ), - onFreshSpawn: reportPtySpawnCommitted - }) - result = stablePaneSpawn.result - stablePaneOwner = stablePaneSpawn.owner - if ( - stablePaneOwner && - isNewDaemonSession && - effectiveSessionAppId && - effectiveSessionAppId !== result.id - ) { - clearProviderPtyState(effectiveSessionAppId) - } - rejectedRegistrationCandidate = result - assertSpawnReplyWasLive(result) - } - rejectedRegistrationCandidate ??= result - if (pendingRegistrationPtyId !== result.id) { - if (pendingRegistrationPtyId) { - runtime?.cancelPendingPtyRegistration?.(pendingRegistrationPtyId) - } - runtime?.beginPtyRegistration?.(result.id, result.incarnationId) - pendingRegistrationPtyId = result.id - } - // Why: admission precedes sequence/context state and every durable publication below. - runtime?.assertPtyRegistrationAllowed?.(result.id, result.incarnationId) - if (result.providerSequence) { - const runtimeSequenceBeforeReconcile = runtime?.getPtyOutputSequence?.(result.id) ?? 0 - // Why kept: this is the reattach boundary in the RENDERER's sequence - // domain, and the daemon snapshot's kitty flags mean nothing without - // the boundary they were proven at. - reconciledSnapshotSeq = - runtime?.synchronizePtyOutputSequenceFromProvider?.( - result.id, - result.providerSequence, - sequenceBeforeProviderSpawn - ) ?? null - if (runtimeSequenceBeforeReconcile > sequenceBeforeProviderSpawn) { - snapshotKittyFlagsCoverReconciledSeq = false - } - } - ensureWslHookRelayForReattach(result, args.connectionId) - runtime?.preparePtyExecutionContext?.( - result.id, - args.connectionId - ? null - : result.wslDistro === undefined - ? expectedWslDistro - : result.wslDistro - ) - } catch (err) { - if ( - (isNewDaemonSession || preparedProvisionalExecutionContext) && - effectiveSessionAppId - ) { - runtime?.preparePtyExecutionContext?.(effectiveSessionAppId, null, { - resetIncarnation: true - }) - } - const rawMessage = err instanceof Error ? err.message : String(err) - if (rawMessage === 'agent_session_exited_during_start' && rejectedRegistrationCandidate) { - runtime?.releaseRejectedPtyRegistrationFence?.( - rejectedRegistrationCandidate.id, - rejectedRegistrationCandidate.incarnationId - ) - } - if (pendingRegistrationPtyId) { - runtime?.cancelPendingPtyRegistration?.( - pendingRegistrationPtyId, - rejectedRegistrationCandidate?.incarnationId - ) - pendingRegistrationPtyId = null - } - const spawnError = normalizeNodePtySpawnError(err) - const isIdentityMismatch = - isSshPtyIdentityMismatchError(spawnError) || isSshPtyIdentityMismatchError(rawMessage) - if (effectiveSessionAppId !== undefined) { - if (isIdentityMismatch && hadSessionSizeBeforeAttach && sessionSizeBeforeAttach) { - ptySizes.set(effectiveSessionAppId, sessionSizeBeforeAttach) - } else { - ptySizes.delete(effectiveSessionAppId) - } - } - if ( - args.connectionId && - effectiveSessionRelayId !== undefined && - (spawnError.message.includes(SSH_SESSION_EXPIRED_ERROR) || - rawMessage.includes(SSH_SESSION_EXPIRED_ERROR)) - ) { - if (effectiveSessionAppId !== undefined && !isIdentityMismatch) { - clearProviderPtyState(effectiveSessionAppId) - deletePtyOwnership(effectiveSessionAppId) - } - if (!isIdentityMismatch) { - store?.markSshRemotePtyLease(args.connectionId, effectiveSessionRelayId, 'expired') - } - } - if (isNewDaemonSession && sessionId !== undefined) { - clearProviderPtyState(sessionId) - } - throw spawnError - } finally { - if (args.preAllocatedHandle) { - trustedTerminalHandleEnv.delete(args.preAllocatedHandle) - } - } - try { - stablePaneBindingPersisted = persistAdmittedStablePaneBinding({ - store: hostSessionBinding?.store, - owner: stablePaneOwner, - result, - worktreeId: hostSessionBinding?.worktreeId, - startupCwd: cwd, - connectionId: args.connectionId - }) - } catch (error) { - if (error instanceof Error && error.message === 'terminal_pane_owner_changed') { - throw error - } - console.error('[pty] failed to persist runtime PTY binding after attach:', error) - throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { - agentSessionOperationOutcome: 'unknown' as const - }) - } - if (result.agentSessionEnsure?.disposition === 'adopted') { - const owner = result.agentSessionEnsure.owner - ptyOwnership.set(result.id, args.connectionId ?? ptyOwnership.get(result.id) ?? null) - runtime?.registerPreAllocatedHandleForPty(result.id, owner.surface.terminalHandle) - if (result.incarnationId) { - ptyIncarnationById.set(result.id, result.incarnationId) - } - runtime?.registerPty(result.id, owner.surface.worktreeId, args.connectionId ?? null, { - tabId: owner.surface.tabId, - leafId: owner.surface.leafId, - ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}) - }) - if (!args.connectionId) { - options?.onCodexHomePtySpawned?.({ - id: result.id, - codexHomePath: selectedCodexHomePath, - reattached: true, - startedAt: codexHomeLaunchStartedAt, - startedSequence: codexHomeLaunchStartedSequence, - ...codexReattachedHomeRouteField(reattachedCodexHomeRoutes, result.id, true), - ...(env ? { launchEnv: env } : {}) - }) - } - return { - id: result.id, - ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), - agentSessionEnsure: result.agentSessionEnsure - } - } - ptyOwnership.set(result.id, args.connectionId ?? null) - if (result.incarnationId) { - ptyIncarnationById.set(result.id, result.incarnationId) - } - // Why: record the native-Windows-local-PTY determination before any byte reaches the emulator, so its ConPTY DA1 override exists from byte zero. - if ( - isNativeWindowsLocalPtySpawn({ - connectionId: args.connectionId, - cwd: args.cwd, - shellOverride: daemonShellOverride - }) - ) { - markNativeWindowsConptyPty(result.id) - } - const relayResultId = getRelayPtyId(args.connectionId, result.id) - const persistSshLease = (): void => { - if (!store || !args.connectionId) { - return - } - // Why: SSH leases keep relay ids for remote reconciliation, while session bindings keep app-facing ids for hydration. - store.upsertSshRemotePtyLease({ - targetId: args.connectionId, - ptyId: relayResultId, - ...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}), - ...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}), - ...(typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) - ? { leafId: args.leafId } - : {}), - state: 'attached', - lastAttachedAt: Date.now() - }) - } - if (!hostSessionBinding) { - persistSshLease() - } - ptySizes.set(result.id, { cols: args.cols, rows: args.rows }) - if (effectiveSessionAppId !== undefined && effectiveSessionAppId !== result.id) { - ptySizes.delete(effectiveSessionAppId) - } - recordCodexPaneAccountForSpawn({ - ptyId: result.id, - isDaemonHostSpawn, - isReattach: result.isReattach === true, - pinnedByResume: codexResumeHomeSelected, - launchCodexHomePath: selectedCodexHomePath, - launchEnv: args.env, - target: codexSelectionTarget, - settings: getSettings?.() - }) - if (hostSessionBinding && !stablePaneBindingPersisted) { - try { - const binding = { - worktreeId: hostSessionBinding.worktreeId, - tabId: hostSessionBinding.tabId, - leafId: hostSessionBinding.leafId, - ptyId: result.id, - hostAdmittedMembership: true, - ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), - ...(cwd ? { startupCwd: cwd } : {}), - ...(hostSessionBinding.expectedSourceBinding - ? { expectedSourceBinding: hostSessionBinding.expectedSourceBinding } - : {}) - } - const persisted = args.connectionId - ? hostSessionBinding.store.persistPtyBinding( - binding, - toSshExecutionHostId(args.connectionId) - ) - : hostSessionBinding.store.persistPtyBinding(binding) - if (persisted === false) { - throw new Error('terminal_split_source_not_found') - } - } catch (err) { - console.error('[pty] failed to persist runtime PTY binding after spawn:', err) - if (!result.isReattach) { - deletePtyOwnership(result.id) - try { - await provider.shutdown(result.id, { immediate: true }) - } catch (shutdownErr) { - console.warn('[pty] failed to clean up PTY after persistence failure:', shutdownErr) - } - clearProviderPtyState(result.id) - } - if (err instanceof Error && err.message === 'terminal_split_source_not_found') { - throw err - } - throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { - agentSessionOperationOutcome: 'unknown' as const - }) - } - persistSshLease() - } - if (args.preAllocatedHandle && !stablePaneOwner?.handle) { - runtime?.registerPreAllocatedHandleForPty(result.id, args.preAllocatedHandle) - } - if (args.worktreeId) { - runtime?.registerPty( - result.id, - args.worktreeId, - args.connectionId ?? null, - // Why: thread validated pane identity so main can back a pending mobile create even if graph-sync stalls (#7587). - typeof args.tabId === 'string' && - isValidTerminalTabId(args.tabId) && - args.tabId.length <= 512 && - metadataLeafId !== null - ? { - tabId: args.tabId, - leafId: metadataLeafId, - ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}) - } - : undefined, - !args.connectionId - ? shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd) - : undefined - ) - } else { - // Why: non-worktree PTYs have no later surface-registration phase to clear admission intent. - runtime?.cancelPendingPtyRegistration?.(result.id, result.incarnationId) - } - // Why: runtime-controller creates (headless serve, CLI, splits) adopt surviving daemon sessions too; without this seed their records stay blank. - seedTerminalRestoreRecordsFromSpawnResult(runtime, result) - // Why: arms main's per-PTY Command Code output detector from the launch command (renderer startupCommand parity). - if (!stablePaneOwner) { - runtime?.noteTerminalSpawnCommand?.(result.id, launchCommand ?? null) - } - if (isClaudeLaunch && !stablePaneOwner) { - markClaudePtySpawned(result.id) - } - if (args.telemetry && !stablePaneOwner) { - const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind) - const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source) - const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind) - if (agentKindParse.success && launchSourceParse.success && requestKindParse.success) { - track('agent_started', { - agent_kind: agentKindParse.data, - launch_source: launchSourceParse.data, - request_kind: requestKindParse.data, - ...getCohortAtEmit() - }) - } - } - // Why: runtime-owned CLI PTYs bypass the renderer pty:spawn handler; record paneKey here too since hook titles and cache cleanup need this reverse lookup. - const paneKey = rememberPaneKeyForPty(result.id, env?.ORCA_PANE_KEY) - const pendingSerializer = paneKey ? pendingByPaneKey.get(paneKey) : undefined - const inheritRendererReadiness = - result.isReattach === true && - !pendingSerializer && - rendererSerializerReadiness.has(result.id) - rendererSerializerReadiness.beginIncarnation(result.id, inheritRendererReadiness) - if (paneKey && pendingSerializer) { - pendingPtyIdBySerializerGeneration.set(pendingSerializer.gen, result.id) - } - if (!args.connectionId) { - registerPty({ - ptyId: result.id, - worktreeId: args.worktreeId ?? null, - sessionId: sessionId ?? null, - paneKey, - pid: - typeof result.pid === 'number' && Number.isFinite(result.pid) && result.pid > 0 - ? result.pid - : null - }) - } - // Why: runtime-owned/background spawns bypass mounted-pane state, so inventory consumers need an explicit signal. - sendPtySpawnedToRenderer(result.id) - if (!args.connectionId) { - options?.onCodexHomePtySpawned?.({ - id: result.id, - codexHomePath: selectedCodexHomePath, - startedAt: codexHomeLaunchStartedAt, - startedSequence: codexHomeLaunchStartedSequence, - ...codexReattachedHomeRouteField( - reattachedCodexHomeRoutes, - result.id, - result.isReattach === true - ), - ...(result.isReattach === true ? { reattached: true } : env ? { launchEnv: env } : {}) - }) - } - const response = { - id: result.id, - ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), - ...(stablePaneOwner && (stablePaneOwner.handle || args.preAllocatedHandle) - ? { - stablePaneOwner: { - handle: stablePaneOwner.handle ?? args.preAllocatedHandle!, - tabId: stablePaneOwner.tabId, - leafId: stablePaneOwner.leafId - } - } - : {}), - ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}) - } - resolvePaneSpawnReservation(paneSpawnReservationKey, paneSpawnReservation, { - ...result, - ...(typeof result.snapshotKittyKeyboardFlags === 'number' && - reconciledSnapshotSeq !== null && - snapshotKittyFlagsCoverReconciledSeq - ? { snapshotSeq: reconciledSnapshotSeq } - : { snapshotKittyKeyboardFlags: undefined }), - isReattach: true - }) - return response - } catch (err) { - if (pendingRegistrationPtyId) { - runtime?.cancelPendingPtyRegistration?.( - pendingRegistrationPtyId, - rejectedRegistrationCandidate?.incarnationId - ) - pendingRegistrationPtyId = null - } - // Why: once the reservation is created, any later throw — spawn - // failure, persist failure, or a post-spawn helper such as - // registerPty/rememberPaneKeyForPty/track — must settle it. Otherwise - // it lingers in paneSpawnReservationsByOwnerKey and every future spawn - // for this pane awaits a promise that never resolves. reject is a - // no-op once the reservation has already resolved. - rejectPaneSpawnReservation(paneSpawnReservationKey, paneSpawnReservation, err) - throw err - } finally { - releaseWorktreeSpawn?.() - finishTerminalInstall() - } - }, - write: (ptyId, data) => { - try { - return getProviderForPty(ptyId).write(ptyId, data) !== false - } catch { - return false - } - }, - writeWithSettlement: async (ptyId, data) => { - try { - const provider = getProviderForPty(ptyId) - return provider.writeWithSettlement - ? await provider.writeWithSettlement(ptyId, data) - : provider.write(ptyId, data) !== false - } catch { - return false - } - }, - probePtyLiveness: async (ptyId) => { - try { - // Why: no locally routed provider can authoritatively answer for a - // remote host's PTY, so remote-scoped ids stay unknown, never absent. - if (ptyId.startsWith('remote:')) { - return null - } - const connectionId = ptyOwnership.get(ptyId) ?? parseAppSshPtyId(ptyId)?.connectionId - // Why: during cold start the daemon swap is in flight; the pre-swap - // fallback would answer absent for every daemon-owned id. - const startupPromise = getLocalPtyProviderStartupPromise(connectionId) - if (startupPromise) { - await startupPromise - } - const provider = getProviderForPty(ptyId) - if (provider.probePtyLiveness) { - return await provider.probePtyLiveness(ptyId) - } - // Why: the in-process provider is its own sole owner (#12393), so its - // refusal is authoritative; every other probe-less provider is doubt. - if (provider instanceof LocalPtyProvider) { - return provider.hasPty(ptyId) - } - return null - } catch { - return null - } - }, - // Why: subscriber-driven ingestion for daemon sessions no renderer pane - // ever attached. Local daemon sessions only — SSH panes have their own - // lease machinery, and the in-process local provider streams without - // attach. Attach-only and false-on-doubt: never creates or resizes. - attach: async (ptyId) => { - if (ptyOwnership.get(ptyId) != null || parseAppSshPtyId(ptyId)) { - return false - } - let provider: IPtyProvider - try { - provider = getProviderForPty(ptyId) - } catch { - return false - } - if (provider !== localProvider || provider instanceof LocalPtyProvider) { - return false - } - try { - const sequenceBeforeProviderAttach = runtime?.getPtyOutputSequence?.(ptyId) ?? 0 - const attachResult = await provider.attach(ptyId) - if (attachResult?.providerSequence) { - runtime?.synchronizePtyOutputSequenceFromProvider?.( - ptyId, - attachResult.providerSequence, - sequenceBeforeProviderAttach - ) - } - return true - } catch { - return false - } - }, - kill: (ptyId) => { - runtime?.markPtyStopRequested?.(ptyId) - let connectionId: string | null | undefined = ptyOwnership.get(ptyId) - const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null - connectionId ??= parsedSshId?.connectionId - const killWithCurrentProvider = (): boolean => { - let provider: IPtyProvider - try { - provider = connectionId ? getProvider(connectionId) : getProviderForPty(ptyId) - } catch { - if (connectionId) { - // Why: runtime/CLI close can target a detached SSH PTY after its - // provider was unregistered. Tombstone the lease so reconnect does - // not revive a terminal the user explicitly closed — but a detached - // relay PTY outlives its provider, so report an unconfirmed stop - // rather than a kill nobody performed. - const incarnationId = finishPtyShutdown(ptyId, connectionId, store) - runtime?.onPtyExit(ptyId, -1, incarnationId) - rememberSyntheticKillExit(ptyId) - sendPtyExitToRenderer({ id: ptyId, code: -1 }) - runtime?.markPtyLivenessUnverifiable?.(ptyId, SSH_PROVIDER_UNREGISTERED_REASON) - return false - } - return false - } - // Why: controller is synchronous, but keep ownership until async shutdown proves whether the provider emitted an exit. - void shutdownProviderAndDetectExit(provider, ptyId, { immediate: false }) - .then((providerExitObserved) => { - const retired = retiredRejectedPtyIds.has(ptyId) - const incarnationId = finishPtyShutdown(ptyId, connectionId, store) - if (!providerExitObserved && !retired) { - runtime?.onPtyExit(ptyId, -1, incarnationId) - rememberSyntheticKillExit(ptyId) - sendPtyExitToRenderer({ id: ptyId, code: -1 }) - } - }) - .catch((err) => { - const retired = retiredRejectedPtyIds.has(ptyId) - if (isPtyAlreadyGoneError(err)) { - const incarnationId = finishPtyShutdown(ptyId, connectionId, store) - if (!retired) { - runtime?.onPtyExit(ptyId, -1, incarnationId) - rememberSyntheticKillExit(ptyId) - sendPtyExitToRenderer({ id: ptyId, code: -1 }) - } - return - } - console.warn( - `[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}` - ) - // Why: close runtime tails without clearing provider ownership, so - // a retry can still target a PTY that survived the failed shutdown. - if (!retired) { - if (connectionId) { - runtime?.markPtyLivenessUnverifiable?.( - ptyId, - err instanceof Error ? err.message : String(err) - ) - } - runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId)) - } - }) - return true - } - const startupPromise = getLocalPtyProviderStartupPromise(connectionId) - if (startupPromise) { - // Why: select the provider after the daemon swap; the fallback first can report success while orphaning a daemon PTY. - void startupPromise.then(killWithCurrentProvider).catch((err) => { - console.warn( - `[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}` - ) - if (!retiredRejectedPtyIds.has(ptyId)) { - if (connectionId) { - runtime?.markPtyLivenessUnverifiable?.( - ptyId, - err instanceof Error ? err.message : String(err) - ) - } - runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId)) - } - }) - return true - } - return killWithCurrentProvider() - }, - retireRejectedPty: (ptyId, stopConfirmed) => { - rememberRetiredRejectedPty(ptyId) - if (!stopConfirmed) { - runtime?.markPtyLivenessUnverifiable?.( - ptyId, - 'a follow-up stop was issued but its outcome could not be verified' - ) - if (!ptyOwnership.has(ptyId)) { - return - } - runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId)) - rememberSyntheticKillExit(ptyId) - sendPtyExitToRenderer({ id: ptyId, code: -1 }) - return - } - // Why: a completed stop already cleared provider state, tombstoned the lease and told the - // renderer; repeating that double-fires the exit IPC. - if (!ptyOwnership.has(ptyId)) { - runtime?.onPtyExit(ptyId, 0, ptyIncarnationById.get(ptyId)) - return - } - let connectionId: string | null | undefined = ptyOwnership.get(ptyId) - const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null - connectionId ??= parsedSshId?.connectionId - const incarnationId = finishPtyShutdown(ptyId, connectionId, store) - runtime?.onPtyExit(ptyId, 0, incarnationId) - rememberSyntheticKillExit(ptyId) - sendPtyExitToRenderer({ id: ptyId, code: 0 }) - }, - markReversibleStops: (ptyIds) => { - for (const ptyId of ptyIds) { - reversibleStopOwnersByPtyId.set(ptyId, (reversibleStopOwnersByPtyId.get(ptyId) ?? 0) + 1) - } - let released = false - return () => { - if (released) { - return - } - released = true - for (const ptyId of ptyIds) { - const owners = (reversibleStopOwnersByPtyId.get(ptyId) ?? 0) - 1 - if (owners > 0) { - reversibleStopOwnersByPtyId.set(ptyId, owners) - } else { - reversibleStopOwnersByPtyId.delete(ptyId) - } - } - } - }, - stopAndWait: async (ptyId, opts) => { - runtime?.markPtyStopRequested?.(ptyId) - let connectionId: string | null | undefined = ptyOwnership.get(ptyId) - const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null - connectionId ??= parsedSshId?.connectionId - // Why: destructive teardown threads one absolute deadline through every await - // below; each RPC leaf converts it to the remaining time when it issues, so - // sequential RPCs share the budget and cannot overrun the sweep deadline. - const deadlineMs = opts?.deadlineMs - const startupPromise = getLocalPtyProviderStartupPromise(connectionId) - if (startupPromise) { - // Why: exact-stop must resolve the provider after daemon startup just - // like renderer kills, or the fallback can falsely confirm teardown. - if (deadlineMs !== undefined) { - // Why: bound the cold-start await by the teardown deadline instead of the - // 60s startup fail-open cap; fail closed so the sweep records the miss. - const won = await Promise.race([ - // Why: () => false on rejection both fails closed on a startup error and - // keeps the losing branch's rejection from surfacing as unhandled. - startupPromise.then( - () => true, - () => false - ), - delay(Math.max(1, deadlineMs - Date.now())).then(() => false) - ]) - if (!won) { - return false - } - } else { - await startupPromise - } - } - let provider: IPtyProvider - try { - provider = connectionId ? getProvider(connectionId) : getProviderForPty(ptyId) - } catch { - if (connectionId) { - // Why: the relay lease must still be tombstoned, but an absent SSH - // provider is lost contact — the remote PTY is designed to survive it, - // so nothing here observed an exit to report as a confirmed stop. - const incarnationId = finishPtyShutdown(ptyId, connectionId, store) - runtime?.onPtyExit(ptyId, -1, incarnationId) - rememberSyntheticKillExit(ptyId) - sendPtyExitToRenderer({ id: ptyId, code: -1 }) - runtime?.markPtyLivenessUnverifiable?.(ptyId, SSH_PROVIDER_UNREGISTERED_REASON) - } - return false - } - let providerExitObserved = false - try { - providerExitObserved = await shutdownProviderAndDetectExit(provider, ptyId, { - immediate: true, - keepHistory: opts?.keepHistory ?? false, - deadlineMs - }) - } catch (err) { - if (!isPtyAlreadyGoneError(err)) { - if (connectionId) { - runtime?.markPtyLivenessUnverifiable?.( - ptyId, - err instanceof Error ? err.message : String(err) - ) - } - console.warn( - `[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}` - ) - return false - } - } - try { - if (!(await verifyPtyStopped(provider, ptyId, opts))) { - runtime?.markPtyLivenessLive?.(ptyId) - return false - } - } catch (err) { - if (connectionId) { - runtime?.markPtyLivenessUnverifiable?.( - ptyId, - err instanceof Error ? err.message : String(err) - ) - } - console.warn( - `[pty] Failed to verify PTY ${ptyId} stopped: ${ - err instanceof Error ? err.message : String(err) - }` - ) - return false - } - const incarnationId = finishPtyShutdown(ptyId, connectionId, store) - if (!providerExitObserved) { - // The owning provider's fresh inventory observed absence, so this is a - // death certificate even when its exit event was missed. - runtime?.onPtyExit(ptyId, 0, incarnationId) - rememberSyntheticKillExit(ptyId) - sendPtyExitToRenderer({ id: ptyId, code: 0 }) - } - return true - }, - getForegroundProcess: async (ptyId) => { - try { - return await getProviderForPty(ptyId).getForegroundProcess(ptyId) - } catch { - return null - } - }, - inspectProcess: async (ptyId) => inspectPtyProviderProcess(getProviderForPty(ptyId), ptyId), - confirmForegroundProcess: async (ptyId) => { - try { - const provider = getProviderForPty(ptyId) - // Why: cached foreground evidence cannot resolve a fresh shell conflict. - return (await provider.confirmForegroundProcess?.(ptyId)) ?? null - } catch { - return null - } - }, - getCwd: async (ptyId) => { - try { - const cwd = await getProviderForPty(ptyId).getCwd(ptyId) - return cwd || null - } catch { - return null - } - }, - hasChildProcesses: async (ptyId) => { - try { - return await getProviderForPty(ptyId).hasChildProcesses(ptyId) - } catch { - return false - } - }, - clearBuffer: async (ptyId) => { - // Why: desktop xterm and daemon/SSH providers hold separate buffers; clear both so mobile resubscribe can't resurrect cleared history. - sendToRenderer(mainWindow, 'pty:clearBuffer:request', { ptyId }) - try { - await getProviderForPty(ptyId).clearBuffer(ptyId) - } catch { - /* best effort: renderer clear still handles local PTYs */ - } - }, - hasPty: (ptyId) => { - try { - return getProviderForPty(ptyId).hasPty?.(ptyId) ?? null - } catch { - return null - } - }, - listProcesses: async (connectionId, opts) => { - if (connectionId === null) { - return localProvider.listProcesses() - } - if (connectionId !== undefined) { - try { - return await getProvider(connectionId).listProcesses(opts) - } catch (error) { - markSshInventoryUnverifiable(connectionId, error) - throw error - } - } - return (await listRegisteredPtyProcessesWithHostScope(markSshInventoryUnverifiable, opts)) - .processes - }, - listProcessesWithHostScope: (opts) => - listRegisteredPtyProcessesWithHostScope(markSshInventoryUnverifiable, opts), - serializeBuffer: (ptyId, opts) => { - // Why: mobile xterm must start from the desktop's exact screen state/dimensions before live TUI chunks render correctly. - return requestSerializedBuffer(ptyId, opts) - }, - serializeProviderBuffer: async (ptyId, opts) => { - try { - // Why: restored daemon PTYs can be live while their desktop pane is unmounted; query the provider model so phone-local navigation works. - return (await getProviderForPty(ptyId).getBufferSnapshot?.(ptyId, opts)) ?? null - } catch { - return null - } - }, - hasRendererSerializer: (ptyId) => { - // Why: a synchronous probe lets the runtime decide whether to skip the daemon-snapshot seed (renderer will hydrate) or run it (no renderer authoritative). - return rendererSerializerReadiness.has(ptyId) - }, - getRendererSerializerGeneration: (ptyId) => { - return rendererSerializerReadiness.generation(ptyId) - }, - waitForRendererSerializer: (ptyId, afterGeneration, timeoutMs, signal) => { - return rendererSerializerReadiness.wait(ptyId, afterGeneration, timeoutMs, signal) - }, - getSize: (ptyId) => ptySizes.get(ptyId) ?? null, - resize: (ptyId, cols, rows) => { - try { - getProviderForPty(ptyId).resize(ptyId, cols, rows) - ptySizes.set(ptyId, { cols, rows }) - return true - } catch { - return false - } - } - }) - - // ─── IPC Handlers (thin dispatch layer) ───────────────────────── - - function normalizeSnapshotScrollbackRows(value: unknown): number | undefined { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return undefined - } - return Math.max(0, Math.min(50_000, Math.floor(value))) - } - - ipc.handle( - 'pty:getMainBufferSnapshot', - async ( - _event, - args: { id?: unknown; opts?: { scrollbackRows?: unknown } } - ): Promise<{ - data: string - frameRestoreAnsi?: string - cols: number - rows: number - cwd?: string | null - lastTitle?: string - seq?: number - pendingDeliveryStartSeq?: number - source?: 'headless' | 'renderer' - alternateScreen?: boolean - scrollbackAnsi?: string - pendingEscapeTailAnsi?: string - kittyKeyboardFlags?: number - } | null> => { - if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) { - return null - } - const scrollbackRows = normalizeSnapshotScrollbackRows(args.opts?.scrollbackRows) - try { - const runtimeSeqBeforeSnapshot = runtime.getPtyOutputSequence(args.id) - const providerSnapshotRequired = providerSnapshotRequiredPtys.has(args.id) - const providerSnapshot = providerSnapshotRequired - ? await tryGetProviderForPty(args.id)?.getBufferSnapshot?.(args.id, { - scrollbackRows - }) - : null - // Why: after a data gap main holds only the retained tail; returning it as a full snapshot would erase older scrollback. - if (providerSnapshotRequired && !providerSnapshot) { - return null - } - const snapshot = - providerSnapshot ?? - (await runtime.serializeHiddenOutputRecoveryBuffer(args.id, { - scrollbackRows - })) - if (!snapshot || typeof snapshot.seq !== 'number') { - return snapshot - } - // Why: the renderer's post-restore dedupe needs this pending-queue bound, or a stale baseline swallows new chunks whose seq sits below the snapshot counter. - const pending = pendingData.get(args.id) - if (pending && typeof pending.startSeq !== 'number') { - // Why: a seq-less backlog cannot be bounded — stay conservative. - return snapshot - } - return { - ...snapshot, - pendingDeliveryStartSeq: Math.min( - pending?.startSeq ?? (providerSnapshot ? runtimeSeqBeforeSnapshot : snapshot.seq), - snapshot.seq - ) - } - } catch { - return null - } - } - ) - - // Why: main owns side effects, so this replay restores title state only — never historical bells/completions (no-attention-replay rule, terminal-side-effect-authority.md). - ipc.handle('pty:sideEffectSnapshot', (_event, args: { id: string }) => { - if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) { - return null - } - return runtime.getTerminalSideEffectSnapshot(args.id) - }) - - installPowerSignalBreadcrumbs(getPtyPower()) - ipc.handle('pty:getRendererDeliveryDebugSnapshot', (): PtyRendererDeliveryDebugSnapshot => { - return getPtyRendererDeliveryDebugSnapshot() - }) - ipc.handle('pty:resetRendererDeliveryDebug', (): void => { - resetPtyRendererDeliveryDebug() - }) - - ipc.handle( - 'pty:spawn', - async ( - _event, - args: { - cols: number - rows: number - cwd?: string - // Why: fresh local spawns opt into recovering a saved cwd whose dir was deleted (#7239); reattach/remote need exact cwd, so the flag alone isn't sufficient. - cwdFallback?: 'worktree' - env?: Record - envToDelete?: string[] - command?: string - commandDelivery?: 'renderer' | 'provider' - launchConfig?: SleepingAgentLaunchConfig - resumeProviderSession?: AgentProviderSessionMetadata - launchToken?: unknown - launchAgent?: TuiAgent - startupCommandDelivery?: StartupCommandDelivery - connectionId?: string | null - worktreeId?: string - sessionId?: string - shellOverride?: string - projectRuntime?: ProjectExecutionRuntimeResolution - terminalColorQueryReplies?: { - foreground?: unknown - background?: unknown - } - // Why: hidden-at-spawn declaration (terminal-query-authority.md §races) — main marks hidden before byte zero so the gate owns spawn-time queries. - initiallyHidden?: boolean - // Why: closes the SIGKILL race (INVESTIGATION.md) by letting main sync-flush the binding before pty:spawn returns; only the Ctrl+T daemon-host path threads these. - tabId?: string - leafId?: string - // Why: renderer-threaded launch telemetry (telemetry-plan.md§Agent launch semantics); loosely typed because the main-side schema validator is the single enforcement point. - telemetry?: { - agent_kind?: unknown - launch_source?: unknown - request_kind?: unknown - } - } - ) => { - const codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined - const codexHomeLaunchStartedSequence = !args.connectionId ? ++ptyLifecycleSequence : undefined - const initialLeafId = - typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null - const initialPaneKey = - typeof args.worktreeId === 'string' && - typeof args.tabId === 'string' && - isValidTerminalTabId(args.tabId) && - args.tabId.length <= 512 && - initialLeafId - ? makePaneKey(args.tabId, initialLeafId) - : null - const initialStablePanePtyId = (() => { - try { - return !args.connectionId && initialPaneKey - ? resolveStablePaneOwner( - runtime, - store, - initialPaneKey, - args.worktreeId, - args.connectionId - )?.ptyId - : undefined - } catch { - return undefined - } - })() - const reattachedCodexHomeRoutes = !args.connectionId - ? snapshotCodexPaneHomeRoutes([ - initialStablePanePtyId, - args.sessionId ? getAppPtyId(args.connectionId, args.sessionId) : undefined - ]) - : new Map() - const spawnTiming = createPtySpawnTiming() - const startupPromise = getLocalPtyStartupPromise(args.connectionId) - if (startupPromise) { - await startupPromise - } - let cwd = resolvePtySpawnStartupCwd(args.worktreeId, args.cwd) - let prevalidatedCwd: string | undefined - let startupCwdFallback: { kind: 'worktree'; cwd: string } | undefined - const earlyLeafId = - typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null - const earlyPaneKey = - typeof args.worktreeId === 'string' && - typeof args.tabId === 'string' && - isValidTerminalTabId(args.tabId) && - args.tabId.length <= 512 && - earlyLeafId - ? makePaneKey(args.tabId, earlyLeafId) - : null - const earlyReservationKey = makePaneSpawnReservationKey( - args.worktreeId, - args.connectionId, - earlyPaneKey - ) - const pendingRuntimeCreate = earlyReservationKey - ? pendingRuntimePaneCreatesByOwnerKey.get(earlyReservationKey) - : undefined - if (pendingRuntimeCreate) { - await pendingRuntimeCreate.promise - } - const existingPaneSpawn = earlyReservationKey - ? paneSpawnReservationsByOwnerKey.get(earlyReservationKey) - : undefined - if (existingPaneSpawn) { - return { ...(await existingPaneSpawn.promise), isReattach: true } - } - const earlyStablePaneOwner = - earlyPaneKey && args.worktreeId - ? resolveStablePaneOwner(runtime, store, earlyPaneKey, args.worktreeId, args.connectionId) - : null - const earlyWorktreeId = args.worktreeId - let paneSpawnReservationKey = - earlyStablePaneOwner && earlyReservationKey ? earlyReservationKey : null - let paneSpawnReservation = paneSpawnReservationKey - ? reservePaneSpawn(paneSpawnReservationKey) - : null - let finishTerminalInstall = (): void => {} - let result: PtySpawnResult - let stablePaneOwner: StablePaneOwner | null = null - let stablePaneBindingPersisted = false - let rejectedRegistrationCandidate: PtySpawnResult | null = null - let pendingRegistrationPtyId: string | null = null - // Why hoisted to the reply scope: main reconciles the provider sequence - // deep inside the spawn path, but the pane needs that renderer-domain - // boundary beside the daemon snapshot's kitty flags. - let reconciledSnapshotSeq: number | null = null - // False when bytes crossed the data socket during the spawn RPC: the - // reconciled boundary covers them, but the daemon proved its kitty flags - // before they existed, so the claim must not erase what the pane may - // have scanned from those bytes live. - let snapshotKittyFlagsCoverReconciledSeq = true - let preparedProvisionalExecutionContext = false - let releaseWorktreeSpawn: (() => void) | undefined - try { - if (!earlyStablePaneOwner) { - await assertFolderWorkspacePtyPathUsable(args.worktreeId) - } - const provider = getProvider(args.connectionId) - spawnTiming.mark('stable_adoption_setup') - const preAdoptedStablePane = - earlyStablePaneOwner && earlyWorktreeId - ? await adoptStablePane({ - cols: args.cols, - rows: args.rows, - cwd, - connectionId: args.connectionId, - worktreeId: earlyWorktreeId, - tabId: earlyStablePaneOwner.tabId, - leafId: earlyStablePaneOwner.leafId, - ownsPaneSpawnReservation: true - }) - : null - spawnTiming.mark('stable_adoption') - if (earlyStablePaneOwner && !preAdoptedStablePane) { - await assertFolderWorkspacePtyPathUsable(args.worktreeId) - } - if (!preAdoptedStablePane) { - // Why: reattach needs exact cwd, SSH cannot probe locally, and successful stable-pane adoption needs no launch preflight. - const requestedMissingCwdFallback = - !args.connectionId && !args.sessionId && args.cwdFallback === 'worktree' - const isPosixStartupCwd = args.cwd?.startsWith('/') === true - const startupWorkspaceCwd = - requestedMissingCwdFallback && isPosixStartupCwd - ? resolvePtySpawnStartupCwd(args.worktreeId, '.') - : undefined - const initiallyResolvedStartupCwd = - requestedMissingCwdFallback && isPosixStartupCwd ? cwd : undefined - const startupTerminalRuntimeOptions = - requestedMissingCwdFallback && process.platform === 'win32' - ? resolveLocalWindowsTerminalRuntimeOptions({ - requestedShellOverride: args.shellOverride, - settings: getSettings?.(), - projectRuntime: args.projectRuntime, - fallbackHostShell: process.env.COMSPEC || 'powershell.exe' - }) - : undefined - const wslRuntimeOwnsStartupCwd = - requestedMissingCwdFallback && - isPosixStartupCwd && - (isWslShellName(startupTerminalRuntimeOptions?.shellOverride) || - isWslUncPath(startupWorkspaceCwd ?? '')) - const startupWslContext = wslRuntimeOwnsStartupCwd - ? resolveWslSessionContext({ - cwd: startupWorkspaceCwd, - shellOverride: startupTerminalRuntimeOptions?.shellOverride, - terminalWindowsWslDistro: startupTerminalRuntimeOptions?.terminalWindowsWslDistro - }) - : undefined - let wslStartupCwdExists: boolean | null = null - let wslWorkspaceCwdExists: boolean | null = null - if (startupWslContext && initiallyResolvedStartupCwd) { - const validationCwd = toWindowsWslPath( - initiallyResolvedStartupCwd, - startupWslContext.distro - ) - wslStartupCwdExists = isWslUncPath(validationCwd) - ? await wslUncDirectoryExistsAsync(validationCwd) - : localStartupCwdDirectoryExists(validationCwd) - if (wslStartupCwdExists === true) { - prevalidatedCwd = validationCwd - } - if (wslStartupCwdExists === false && startupWorkspaceCwd) { - wslWorkspaceCwdExists = isWslUncPath(startupWorkspaceCwd) - ? await wslUncDirectoryExistsAsync(startupWorkspaceCwd) - : localStartupCwdDirectoryExists(startupWorkspaceCwd) - if (wslWorkspaceCwdExists === true) { - prevalidatedCwd = startupWorkspaceCwd - } - } - } - const allowMissingCwdFallback = - requestedMissingCwdFallback && - (!wslRuntimeOwnsStartupCwd || - (wslStartupCwdExists === false && wslWorkspaceCwdExists === true)) - let didFallbackToWorkspaceRootCwd = false - cwd = resolvePtySpawnStartupCwd( - args.worktreeId, - args.cwd, - allowMissingCwdFallback - ? { - directoryExists: (path) => - startupWslContext && - wslStartupCwdExists === false && - path === initiallyResolvedStartupCwd - ? false - : startupWslContext && path === startupWorkspaceCwd - ? wslWorkspaceCwdExists === true - : localStartupCwdDirectoryExists( - process.platform === 'win32' ? normalizeWindowsTerminalCwd(path) : path - ), - onFallbackToWorkspaceRoot: () => { - didFallbackToWorkspaceRootCwd = true - } - } - : undefined - ) - if (didFallbackToWorkspaceRootCwd && wslWorkspaceCwdExists === true && cwd) { - prevalidatedCwd = cwd - } - startupCwdFallback = - didFallbackToWorkspaceRootCwd && cwd ? { kind: 'worktree', cwd } : undefined - } - spawnTiming.mark('preflight') - const freshSpawnRecovery = preAdoptedStablePane - ? undefined - : recoverFreshSpawnProviderRouting(provider, args.connectionId, args.sessionId) - if (freshSpawnRecovery) { - await freshSpawnRecovery - } - const isClaudeLaunch = - !preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) - if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error('A Claude account switch is in progress. Try again after it finishes.') - } - const terminalRuntimeOptions = - process.platform === 'win32' && !args.connectionId - ? resolveLocalWindowsTerminalRuntimeOptions({ - requestedShellOverride: args.shellOverride, - settings: getSettings?.(), - projectRuntime: args.projectRuntime, - fallbackHostShell: process.env.COMSPEC || 'powershell.exe' - }) - : { shellOverride: args.shellOverride, terminalWindowsWslDistro: null } - const initialShellOverride = terminalRuntimeOptions.shellOverride - const isDaemonHostSpawn = - !args.connectionId && - !(provider instanceof LocalPtyProvider) && - !routesFreshSpawnsToLocalProvider(provider) - // Why: daemon host-env setup needs a stable id BEFORE provider.spawn so buildPtyHostEnv hooks/Pi cleanup can run; daemon still honors opts.sessionId ?? mint(). - // Note: sessionId is STABLE across daemon restarts by design — do NOT simplify to a fresh UUID per spawn; that orphans reconnectable state. - // Why: only clear ids minted in THIS request on failure — a caller-supplied args.sessionId may name an existing PTY we must not clobber. - const isMintedSessionId = args.sessionId === undefined && isDaemonHostSpawn - const effectiveSessionId = - args.sessionId ?? (isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined) - const effectiveSessionAppId = - effectiveSessionId !== undefined - ? getAppPtyId(args.connectionId, effectiveSessionId) - : undefined - const effectiveSessionRelayId = - effectiveSessionId !== undefined - ? getRelayPtyId(args.connectionId, effectiveSessionId) - : undefined - const expectedWslDistro = !args.connectionId - ? (resolveWslSessionContext({ - cwd, - sessionId: effectiveSessionId, - shellOverride: terminalRuntimeOptions.shellOverride, - terminalWindowsWslDistro: terminalRuntimeOptions.terminalWindowsWslDistro - })?.distro ?? null) - : null - const initialSelectionTarget = getCodexSelectionTargetForPty( - initialShellOverride, - cwd, - expectedWslDistro - ) - const claudeAuth = - isClaudeLaunch && prepareClaudeAuth - ? await prepareClaudeAuth(initialSelectionTarget) - : null - spawnTiming.mark('auth') - if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error('A Claude account switch is in progress. Try again after it finishes.') - } - if (claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) { - throw new Error( - 'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.' - ) - } - // Why: the daemon-backed provider skips LocalPtyProvider's buildSpawnEnv, so assemble the same host-local env here for parity. - // Safety: skip entirely for SSH — every injection is a loopback secret or a local path that leaks or misleads on the remote host. - const startupTerminalColorQueryReplyColors = getStartupTerminalColorQueryReplyColors(args) - // Why: forward pane env to SSH only when the relay hook path is enabled, or a newer relay could emit statuses this build can't route. - const sshSourceEnv = stripRemotePaneEnvWhenHooksDisabled(args.connectionId, args.env) - const baseEnvWithAuth = claudeAuth - ? { ...sshSourceEnv, ...claudeAuth.envPatch } - : sshSourceEnv - const spawnPaneKey = baseEnvWithAuth?.ORCA_PANE_KEY - const parsedSpawnPaneKey = parseValidPaneKey(spawnPaneKey) - const verifiedPaneKey = - parsedSpawnPaneKey && - typeof args.tabId === 'string' && - args.tabId === parsedSpawnPaneKey.tabId && - args.leafId === parsedSpawnPaneKey.leafId - ? makePaneKey(parsedSpawnPaneKey.tabId, parsedSpawnPaneKey.leafId) - : null - const verifiedLeafId = - verifiedPaneKey && parsedSpawnPaneKey ? parsedSpawnPaneKey.leafId : null - const metadataLeafId = - typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null - const metadataPaneKey = - typeof args.tabId === 'string' && - isValidTerminalTabId(args.tabId) && - args.tabId.length <= 512 && - metadataLeafId - ? makePaneKey(args.tabId, metadataLeafId) - : null - const legacySpawnPaneKey = verifiedPaneKey ? null : parseLegacyNumericPaneKey(spawnPaneKey) - const migrationUnsupportedPaneKey = - legacySpawnPaneKey && - typeof args.tabId === 'string' && - args.tabId === legacySpawnPaneKey.tabId && - typeof args.leafId === 'string' && - isTerminalLeafId(args.leafId) - ? makePaneKey(args.tabId, args.leafId) - : null - const stablePaneKey = verifiedPaneKey ?? migrationUnsupportedPaneKey - let baseEnv = baseEnvWithAuth ? { ...baseEnvWithAuth } : undefined - const shouldRefreshAgentTeamsEnv = - !preAdoptedStablePane && - !args.connectionId && - runtime !== undefined && - stablePaneKey !== null && - shouldRefreshNativeClaudeAgentTeamsEnv({ - command: args.command, - launchConfig: args.launchConfig - }) - let effectiveLaunchConfig = args.launchConfig - const shouldPreAllocateTerminalHandle = - runtime !== undefined && - ((!(provider instanceof LocalPtyProvider) && - !routesFreshSpawnsToLocalProvider(provider)) || - shouldRefreshAgentTeamsEnv) - const preAllocatedHandle = shouldPreAllocateTerminalHandle - ? (preAdoptedStablePane?.owner.handle ?? runtime.createPreAllocatedTerminalHandle()) - : null - if (shouldRefreshAgentTeamsEnv && preAllocatedHandle) { - // Why: Agent Teams ids/tokens are process-local, so the team env must be regenerated for the new leader PTY. - const prepared = await runtime.prepareClaudeAgentTeamsLeaderForHandle({ - handle: preAllocatedHandle, - baseEnv: baseEnv ?? {} - }) - baseEnv = { - ...baseEnv, - ...prepared.env - } - if (args.launchConfig) { - effectiveLaunchConfig = { - ...args.launchConfig, - agentEnv: { - ...args.launchConfig.agentEnv, - ...prepared.env - } - } - } - } - const requestedAgentTeamsPath = baseEnv?.ORCA_AGENT_TEAMS_TEAM_ID - ? baseEnv[resolvePathEnvKey(baseEnv, process.platform)] - : undefined - const agentTeamsEnvToDelete = shouldRefreshAgentTeamsEnv ? ['TERM_PROGRAM'] : undefined - if (baseEnv && stablePaneKey) { - baseEnv.ORCA_PANE_KEY = stablePaneKey - if (typeof args.tabId === 'string') { - baseEnv.ORCA_TAB_ID = args.tabId - } else if (!args.connectionId) { - delete baseEnv.ORCA_TAB_ID - } - if (typeof args.worktreeId === 'string') { - baseEnv.ORCA_WORKTREE_ID = args.worktreeId - } else if (!args.connectionId) { - delete baseEnv.ORCA_WORKTREE_ID - } - } else if (baseEnv) { - // Why: ORCA_PANE_KEY crosses into shells/hook registries; only a key proven to match this spawn's tab+leaf may cross the IPC boundary. - delete baseEnv.ORCA_PANE_KEY - delete baseEnv.ORCA_TAB_ID - delete baseEnv.ORCA_WORKTREE_ID - delete baseEnv.ORCA_AGENT_LAUNCH_TOKEN - } - const validatedPaneKey = stablePaneKey - // Why: SSH can strip ORCA_PANE_KEY when remote hooks are off; IPC tab/leaf metadata still names the pane. - const reservationPaneKey = metadataPaneKey ?? validatedPaneKey - const validatedLeafId = verifiedLeafId ?? metadataLeafId - const effectiveShellOverride = terminalRuntimeOptions.shellOverride - const nativeWindowsConptySpawn = isNativeWindowsLocalPtySpawn({ - connectionId: args.connectionId, - cwd: args.cwd, - shellOverride: effectiveShellOverride - }) - const codexSelectionTarget = getCodexSelectionTargetForPty( - effectiveShellOverride, - cwd, - expectedWslDistro - ) - const codexResumePreparation = preAdoptedStablePane - ? null - : prepareCodexResumeHome({ - connectionId: args.connectionId, - launchAgent: args.launchAgent, - providerSession: args.resumeProviderSession, - target: codexSelectionTarget, - launchEnv: baseEnv, - workspacePath: cwd - }) - const codexResumeLaunch = codexResumePreparation - ? await resolveCodexResumeLaunch(args.command, codexResumePreparation) - : noCodexResumeLaunch(preAdoptedStablePane ? undefined : args.command) - const codexResumeHome = codexResumeLaunch.codexResumeHome - const launchCommand = codexResumeLaunch.command - baseEnv = stripSequencedStartupResumeArgv(baseEnv, codexResumeLaunch) - // Why: declared after the strip so a local-provider spawn cannot capture the - // pre-strip env — only the daemon branch below re-derives this from baseEnv. - let env: Record | undefined = baseEnv - let selectedCodexHomePath = resolveSelectedCodexHomeOrRefuseSpawn(() => - !preAdoptedStablePane && !args.connectionId - ? getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - codexResumeHome - ? reconcileSharedRuntimeResumeHome(codexResumeHome, () => - getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { - workspacePath: cwd, - launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined - }) ?? null - ) - ) - : (getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { - workspacePath: cwd, - launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined - }) ?? null) - ) - : null - ) - if (!preAdoptedStablePane && args.launchAgent === 'codex' && args.sessionId === undefined) { - const resolution = resolveCodexHomeAfterManagedAuthReadiness({ - selectedCodexHomePath, - getSettings: () => getSettings?.(), - requiredCodexHomePath: codexResumeHome?.codexHomePath, - target: codexSelectionTarget, - resolveCurrent: () => - resolveSelectedCodexHomeOrRefuseSpawn(() => - getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { - workspacePath: cwd, - launchAgent: 'codex' - }) ?? null - ) - ), - resolveAfterUnavailable: (unavailableManagedHomePath) => - resolveSelectedCodexHomeOrRefuseSpawn(() => - getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { - workspacePath: cwd, - launchAgent: 'codex', - unavailableManagedHomePath - }) ?? null - ) - ) - }) - selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution - } - if (args.launchAgent === 'codex' && selectedCodexHomePath) { - await ensureCodexStateDbBackfillRecoveryStarted(selectedCodexHomePath) - } - const codexResumeHomeSelected = Boolean( - codexResumeHome && - codexHomePathsEqual(selectedCodexHomePath, codexResumeHome.codexHomePath) - ) - const skipCodexHomeEnv = - isDaemonHostSpawn && - shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd) && - !selectedCodexHomePath - const ptySettings = isDaemonHostSpawn ? getSettings?.() : undefined - const stripInheritedOrcaCodexHome = - isDaemonHostSpawn && - shouldStripInheritedOrcaCodexHome({ - target: codexSelectionTarget, - selectedCodexHomePath, - skipCodexHomeEnv, - settings: ptySettings - }) - if (isDaemonHostSpawn && !preAdoptedStablePane) { - if (effectiveSessionId === undefined) { - // Should be unreachable: effectiveSessionId is a string when isDaemonHostSpawn; defense-in-depth. - throw new Error('Invariant violation: daemon spawn without sessionId') - } - const sessionIdForEnv = effectiveSessionId - // Why: this id reaches filesystem paths; reject traversal/separators so a crafted IPC payload can't escape the expected roots. - if (!isSafePtySessionId(sessionIdForEnv, getAppEnvironment().getPath('userData'))) { - throw new Error('Invalid PTY session id') - } - // Why: clone before mutating so injections don't leak back into args.env (renderer may reuse it). - env = { ...baseEnv } - try { - buildPtyHostEnv(sessionIdForEnv, env, { - isPackaged: getAppEnvironment().isPackaged(), - resourcesPath: process.resourcesPath, - userDataPath: getAppEnvironment().getPath('userData'), - selectedCodexHomePath, - skipCodexHomeEnv, - stripInheritedOrcaCodexHome, - launchCommand, - launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, - isWsl: shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd), - wslDistro: codexSelectionTarget.runtime === 'wsl' ? expectedWslDistro : null, - agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings), - codexStatusHooksEnabled: isCodexStatusHooksEnabled(ptySettings), - networkProxySettings: ptySettings, - deferGitConfigGuardToDaemon: - provider.supportsGitCredentialGuardHost?.(effectiveSessionId) === true - }) - stampWslOrchestrationCompatibilityHost( - env, - runtime?.getOrchestrationCompatibilityHostId?.(), - codexSelectionTarget.runtime === 'wsl' ? expectedWslDistro : null - ) - promoteAgentTeamsShimPath(env, requestedAgentTeamsPath) - } catch (err) { - // Why: buildPtyHostEnv has fs side-effects (Pi/OMP install); clear per-PTY state on throw, but only minted ids — caller ids may name existing PTYs. - if (isMintedSessionId) { - clearProviderPtyState(sessionIdForEnv) - } - throw err - } - } - spawnTiming.mark('host_env') - const spawnEnv = preAllocatedHandle - ? { ...env, ORCA_TERMINAL_HANDLE: preAllocatedHandle } - : env - const envToDelete = claudeAuth?.stripAuthEnv - ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] - : undefined - let combinedEnvToDelete = mergePtyEnvDeletions( - envToDelete, - args.envToDelete ?? [], - agentTeamsEnvToDelete ?? [], - // Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH. - isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [], - isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(spawnEnv) : [], - getInheritedClaudeSessionStampEnvKeysToDelete(spawnEnv), - skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [], - // Why: the persistent daemon compares its own merged CODEX_HOME pair; - // main cannot safely decide ownership for a process it may not parent. - stripInheritedOrcaCodexHome ? ['ORCA_CODEX_HOME'] : [] - ) - if (codexResumeHomeSelected) { - combinedEnvToDelete = removeCodexHomeDeletionRequests(combinedEnvToDelete) - } - deleteRequestedEnvKeys(spawnEnv, combinedEnvToDelete) - promoteAgentTeamsShimPath(spawnEnv, requestedAgentTeamsPath) - const spawnOptions: PtySpawnOptions = { - cols: args.cols, - rows: args.rows, - cwd, - ...(prevalidatedCwd && !isDaemonHostSpawn ? { prevalidatedCwd } : {}), - env: spawnEnv, - ...(isMintedSessionId ? { isNewSession: true } : {}), - historyIsolationEnabled: getSettings?.()?.terminalScopeHistoryByWorktree ?? true - } - if (!args.connectionId && !isDaemonHostSpawn) { - spawnOptions.codexHomePathOverride = { value: selectedCodexHomePath } - } - if (combinedEnvToDelete) { - spawnOptions.envToDelete = combinedEnvToDelete - } - if (launchCommand !== undefined) { - spawnOptions.command = launchCommand - } - if (args.commandDelivery !== undefined) { - spawnOptions.commandDelivery = args.commandDelivery - } - if (args.startupCommandDelivery !== undefined) { - spawnOptions.startupCommandDelivery = args.startupCommandDelivery - } - if (isTuiAgent(args.launchAgent)) { - spawnOptions.launchAgent = args.launchAgent - } - if (args.worktreeId !== undefined) { - spawnOptions.worktreeId = args.worktreeId - } - if (reservationPaneKey) { - spawnOptions.paneKey = reservationPaneKey - } - if (typeof args.tabId === 'string' && args.tabId.length > 0 && args.tabId.length <= 512) { - spawnOptions.tabId = args.tabId - } - if (effectiveSessionId !== undefined) { - spawnOptions.sessionId = effectiveSessionId - } - // Why: without this, the Windows daemon path ignores the user's Default Shell preference (LocalPtyProvider already honors it via getWindowsShell()). - if (effectiveShellOverride !== undefined) { - spawnOptions.shellOverride = effectiveShellOverride - } - const hadSessionSizeBeforeAttach = - effectiveSessionAppId !== undefined ? ptySizes.has(effectiveSessionAppId) : false - const sessionSizeBeforeAttach = - effectiveSessionAppId !== undefined ? ptySizes.get(effectiveSessionAppId) : undefined - if (effectiveSessionId !== undefined) { - // Why: daemon PTYs can emit before spawn() resolves; set real geometry now or early bytes default to 80x24 and wrap TUIs. - ptySizes.set(effectiveSessionAppId ?? effectiveSessionId, { - cols: args.cols, - rows: args.rows - }) - } - if (process.platform === 'win32' && !args.connectionId) { - // Why: the renderer models PowerShell as one shell family; thread the implementation choice so both PTY paths resolve the same executable. - spawnOptions.terminalWindowsWslDistro = expectedWslDistro - spawnOptions.terminalWindowsPowerShellImplementation = getSettings - ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') - : undefined - } - if (startupTerminalColorQueryReplyColors) { - spawnOptions.startupIngress = { - colors: startupTerminalColorQueryReplyColors, - deadlineMs: 5_000 - } - } - const resolvedPaneSpawnReservationKey = makePaneSpawnReservationKey( - args.worktreeId, - args.connectionId, - reservationPaneKey - ) - if ( - paneSpawnReservationKey && - resolvedPaneSpawnReservationKey !== paneSpawnReservationKey - ) { - throw new Error('terminal_pane_identity_changed') - } - if (!paneSpawnReservationKey) { - paneSpawnReservationKey = resolvedPaneSpawnReservationKey - const pendingRuntimeCreateAfterPreflight = paneSpawnReservationKey - ? pendingRuntimePaneCreatesByOwnerKey.get(paneSpawnReservationKey) - : undefined - if (pendingRuntimeCreateAfterPreflight) { - await pendingRuntimeCreateAfterPreflight.promise - } - const existingPaneSpawnAfterPreflight = paneSpawnReservationKey - ? paneSpawnReservationsByOwnerKey.get(paneSpawnReservationKey) - : undefined - if (existingPaneSpawnAfterPreflight) { - return { ...(await existingPaneSpawnAfterPreflight.promise), isReattach: true } - } - paneSpawnReservation = paneSpawnReservationKey - ? reservePaneSpawn(paneSpawnReservationKey) - : null - } - finishTerminalInstall = beginPtySpawnForWorktree(args.worktreeId, cwd, args.connectionId) - const initiallyHidden = args.initiallyHidden === true - // Why: daemon PTYs can emit before spawn() resolves, so the hidden mark must beat byte zero (terminal-query-authority.md §races); other providers are safe with the post-spawn mark below. - const preSpawnHiddenMarkId = - initiallyHidden && isDaemonHostSpawn && effectiveSessionAppId !== undefined - ? effectiveSessionAppId - : null - if (preSpawnHiddenMarkId !== null) { - transitionSpawnHiddenRendererPtyDeliveryState(preSpawnHiddenMarkId, true) - } - releaseWorktreeSpawn = await runtime?.acquireWorktreeTerminalSpawn?.(args.worktreeId) - try { - if (preAllocatedHandle) { - trustedTerminalHandleEnv.add(preAllocatedHandle) - } - spawnTiming.mark('options') - const stablePaneOwnerCandidate = resolveStablePaneOwner( - runtime, - store, - reservationPaneKey, - args.worktreeId, - args.connectionId - ) - const expectedPtyId = - stablePaneOwnerCandidate?.ptyId ?? effectiveSessionAppId ?? effectiveSessionId - if (expectedPtyId) { - runtime?.beginPtyRegistration?.(expectedPtyId) - pendingRegistrationPtyId = expectedPtyId - } - if (isDaemonHostSpawn && expectedPtyId) { - preparedProvisionalExecutionContext = - runtime?.preparePtyExecutionContext?.(expectedPtyId, expectedWslDistro, { - resetIncarnation: isMintedSessionId && !stablePaneOwnerCandidate, - preserveExisting: !isMintedSessionId || Boolean(stablePaneOwnerCandidate) - }) ?? false - } - const sequenceBeforeProviderSpawn = expectedPtyId - ? (runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0) - : 0 - const stablePaneSpawn = preAdoptedStablePane - ? preAdoptedStablePane - : await spawnForStablePane({ - runtime, - store, - provider, - spawnOptions, - owner: stablePaneOwnerCandidate, - worktreeId: args.worktreeId, - connectionId: args.connectionId, - resolveOwner: () => - resolveStablePaneOwner( - runtime, - store, - reservationPaneKey, - args.worktreeId, - args.connectionId - ) - }) - result = stablePaneSpawn.result - stablePaneOwner = stablePaneSpawn.owner - if ( - stablePaneOwner && - isMintedSessionId && - effectiveSessionAppId && - effectiveSessionAppId !== result.id - ) { - clearProviderPtyState(effectiveSessionAppId) - } - rejectedRegistrationCandidate = result - if (pendingRegistrationPtyId !== result.id) { - if (pendingRegistrationPtyId) { - runtime?.cancelPendingPtyRegistration?.(pendingRegistrationPtyId) - } - runtime?.beginPtyRegistration?.(result.id, result.incarnationId) - pendingRegistrationPtyId = result.id - } - assertSpawnReplyWasLive(result) - runtime?.assertPtyRegistrationAllowed?.(result.id, result.incarnationId) - if (result.providerSequence) { - const runtimeSequenceBeforeReconcile = runtime?.getPtyOutputSequence?.(result.id) ?? 0 - // Why kept: this is the reattach boundary in the RENDERER's sequence - // domain, and the daemon snapshot's kitty flags mean nothing without - // the boundary they were proven at. - reconciledSnapshotSeq = - runtime?.synchronizePtyOutputSequenceFromProvider?.( - result.id, - result.providerSequence, - sequenceBeforeProviderSpawn - ) ?? null - if (runtimeSequenceBeforeReconcile > sequenceBeforeProviderSpawn) { - snapshotKittyFlagsCoverReconciledSeq = false - } - } - ensureWslHookRelayForReattach(result, args.connectionId) - runtime?.preparePtyExecutionContext?.( - result.id, - args.connectionId - ? null - : result.wslDistro === undefined - ? expectedWslDistro - : result.wslDistro - ) - spawnTiming.mark('provider_spawn') - } catch (err) { - if ((isMintedSessionId || preparedProvisionalExecutionContext) && effectiveSessionAppId) { - runtime?.preparePtyExecutionContext?.(effectiveSessionAppId, null, { - resetIncarnation: true - }) - } - // Why: a stale hidden mark on this session id would gate a later visible attach that reuses it. - if (preSpawnHiddenMarkId !== null) { - transitionSpawnHiddenRendererPtyDeliveryState(preSpawnHiddenMarkId, false) - } - const rawMessage = err instanceof Error ? err.message : String(err) - if (rawMessage === 'agent_session_exited_during_start' && rejectedRegistrationCandidate) { - runtime?.releaseRejectedPtyRegistrationFence?.( - rejectedRegistrationCandidate.id, - rejectedRegistrationCandidate.incarnationId - ) - } - if (pendingRegistrationPtyId) { - runtime?.cancelPendingPtyRegistration?.( - pendingRegistrationPtyId, - rejectedRegistrationCandidate?.incarnationId - ) - pendingRegistrationPtyId = null - } - const spawnError = normalizeNodePtySpawnError(err) - const isIdentityMismatch = - isSshPtyIdentityMismatchError(spawnError) || isSshPtyIdentityMismatchError(rawMessage) - if (effectiveSessionAppId !== undefined) { - if (isIdentityMismatch && hadSessionSizeBeforeAttach && sessionSizeBeforeAttach) { - ptySizes.set(effectiveSessionAppId, sessionSizeBeforeAttach) - } else { - ptySizes.delete(effectiveSessionAppId) - } - } - if ( - args.connectionId && - effectiveSessionRelayId !== undefined && - (spawnError.message.includes(SSH_SESSION_EXPIRED_ERROR) || - rawMessage.includes(SSH_SESSION_EXPIRED_ERROR)) - ) { - // Why: expired remote reattach = relay already dropped the PTY; clear the lease so writes can't restore the stale binding. - if (effectiveSessionAppId !== undefined && !isIdentityMismatch) { - clearProviderPtyState(effectiveSessionAppId) - deletePtyOwnership(effectiveSessionAppId) - } - if (!isIdentityMismatch) { - store?.markSshRemotePtyLease(args.connectionId, effectiveSessionRelayId, 'expired') - } - } - // Why: provider state buildPtyHostEnv materialized for this minted id leaks if spawn failed. - if (isMintedSessionId && effectiveSessionId !== undefined) { - clearProviderPtyState(effectiveSessionId) - } - // Why: telemetry-plan.md§agent_error — attribute the error to the renderer-threaded agent_kind, else sniff the command for `claude`; raw messages are dropped at the validator boundary. - const rendererAgentKindParse = - args.telemetry?.agent_kind !== undefined - ? agentKindSchema.safeParse(args.telemetry.agent_kind) - : null - const errorAgentKind = rendererAgentKindParse?.success - ? rendererAgentKindParse.data - : isClaudeLaunch - ? ('claude-code' as const) - : null - if (errorAgentKind) { - const classified = classifyError(spawnError) - track('agent_error', { - agent_kind: errorAgentKind, - error_class: classified.error_class, - ...getCohortAtEmit() - }) - } - throw spawnError - } finally { - if (preAllocatedHandle) { - trustedTerminalHandleEnv.delete(preAllocatedHandle) - } - } - try { - stablePaneBindingPersisted = persistAdmittedStablePaneBinding({ - store, - owner: stablePaneOwner, - result, - worktreeId: args.worktreeId, - startupCwd: cwd, - connectionId: args.connectionId - }) - } catch (error) { - if (error instanceof Error && error.message === 'terminal_pane_owner_changed') { - throw error - } - console.error('[pty] failed to persist PTY binding after attach:', error) - throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { - agentSessionOperationOutcome: 'unknown' as const - }) - } - spawnTiming.log(result.id, { - daemon: isDaemonHostSpawn, - reattach: result.isReattach ?? false - }) - recordCodexPaneAccountForSpawn({ - ptyId: result.id, - isDaemonHostSpawn, - isReattach: result.isReattach === true, - pinnedByResume: codexResumeHomeSelected, - launchCodexHomePath: selectedCodexHomePath, - launchEnv: baseEnv, - target: codexSelectionTarget, - settings: getSettings?.() - }) - ptyOwnership.set(result.id, args.connectionId ?? null) - if (result.incarnationId) { - ptyIncarnationById.set(result.id, result.incarnationId) - } - if (initiallyHidden) { - // Why marked synchronously here: provider data events dispatch on later tasks, so this still lands ahead of the first byte's delivery decision (idempotent if already marked pre-spawn). - transitionSpawnHiddenRendererPtyDeliveryState(result.id, true) - if (preSpawnHiddenMarkId !== null && preSpawnHiddenMarkId !== result.id) { - // Defense: never strand a mark on an id the provider renamed. - transitionSpawnHiddenRendererPtyDeliveryState(preSpawnHiddenMarkId, false) - } - // Why after ptyOwnership.set: provider lookup routes by ownership, and a hidden-spawned agent should be paceable from its first flood. - syncPtyBackgroundedDelivery(result.id, 'spawn') - closeStartupQueryAuthorityForPty(result.id) - } - // Why: record the native-Windows-ConPTY determination before the headless seed so the emulator's DA1 override exists from byte zero. - if (nativeWindowsConptySpawn) { - markNativeWindowsConptyPty(result.id) - } - const relayResultId = getRelayPtyId(args.connectionId, result.id) - if (store && args.connectionId) { - // Why: remote PTYs live in the SSH relay grace window after Orca detaches; persist IDs immediately so reconnect reattaches instead of spawning a fresh shell. - store.upsertSshRemotePtyLease({ - targetId: args.connectionId, - ptyId: relayResultId, - ...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}), - ...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}), - ...(validatedLeafId ? { leafId: validatedLeafId } : {}), - state: 'attached', - lastAttachedAt: Date.now() - }) - } - if (preAllocatedHandle && !stablePaneOwner?.handle) { - runtime?.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) - } - ptySizes.set(result.id, { cols: args.cols, rows: args.rows }) - if (effectiveSessionAppId !== undefined && effectiveSessionAppId !== result.id) { - ptySizes.delete(effectiveSessionAppId) - } - // Why: patch the load-bearing ptyId binding synchronously so a force-quit in the renderer's ~450 ms debounce window can't orphan daemon history or an SSH relay lease (Issue #217). - if ( - store && - typeof args.worktreeId === 'string' && - typeof args.tabId === 'string' && - validatedLeafId !== null && - !stablePaneBindingPersisted - ) { - try { - const binding = { - worktreeId: args.worktreeId, - tabId: args.tabId, - leafId: validatedLeafId, - ptyId: result.id, - ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), - ...(cwd ? { startupCwd: cwd } : {}) - } - if (args.connectionId) { - store.persistPtyBinding(binding, toSshExecutionHostId(args.connectionId)) - } else { - store.persistPtyBinding(binding) - } - } catch (err) { - console.error('[pty] failed to persist PTY binding after spawn:', err) - if (!result.isReattach) { - try { - await provider.shutdown(result.id, { immediate: true }) - } catch (shutdownErr) { - console.warn('[pty] failed to clean up PTY after persistence failure:', shutdownErr) - } - clearProviderPtyState(result.id) - deletePtyOwnership(result.id) - } - if (!result.isReattach && args.connectionId && store) { - store.removeSshRemotePtyLease(args.connectionId, relayResultId) - } - throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { - agentSessionOperationOutcome: 'unknown' as const - }) - } - } - // Why: when the renderer has declared it will own the serializer for this paneKey, suppress the daemon-snapshot seed so its hydration path is sole authority (keyed on paneKey since the ptyId isn't known yet). See docs/mobile-prefer-renderer-scrollback.md. - const rendererPreSignaled = validatedPaneKey - ? pendingByPaneKey.has(validatedPaneKey) - : false - const rendererAlreadyRegistered = - result.isReattach === true && - !rendererPreSignaled && - rendererSerializerReadiness.has(result.id) - rendererSerializerReadiness.beginIncarnation(result.id, rendererAlreadyRegistered) - // Why: capture the pending gen at spawn time so this PTY's teardown only settles its own generation, not a remount that replaced the entry. - if (validatedPaneKey && rendererPreSignaled) { - const pending = pendingByPaneKey.get(validatedPaneKey) - if (pending) { - pendingPtyIdBySerializerGeneration.set(pending.gen, result.id) - } - } - - // Why: seed the headless emulator before registerPty so concurrent live PTY data lands on top of the seed, not replacing it (mobile keeps the daemon-restored scrollback). - // Skip when the renderer will be authoritative — its xterm buffer is richer than the daemon snapshot. - if (runtime && !rendererPreSignaled && !rendererAlreadyRegistered) { - const snapshotSeedSize = - typeof result.snapshotCols === 'number' && typeof result.snapshotRows === 'number' - ? { cols: result.snapshotCols, rows: result.snapshotRows } - : undefined - if (typeof result.snapshot === 'string' && result.snapshot.length > 0) { - // Why kitty flags ride seed metadata: the snapshot omits them, but the re-seeded emulator must answer hidden `CSI ? u` with the running app's flags (terminal-query-authority.md). - runtime.seedHeadlessTerminal( - result.id, - result.snapshot, - snapshotSeedSize, - typeof result.snapshotKittyKeyboardFlags === 'number' - ? { kittyKeyboardFlags: result.snapshotKittyKeyboardFlags } - : {} - ) - } else if ( - result.coldRestore && - typeof result.coldRestore.scrollback === 'string' && - result.coldRestore.scrollback.length > 0 - ) { - const coldRestoreSeedSize = - typeof result.coldRestore.cols === 'number' && - typeof result.coldRestore.rows === 'number' - ? { cols: result.coldRestore.cols, rows: result.coldRestore.rows } - : undefined - runtime.seedHeadlessTerminal( - result.id, - result.coldRestore.scrollback, - coldRestoreSeedSize, - { - cwd: result.coldRestore.cwd, - oscLinks: result.coldRestore.oscLinks, - preferProviderIfExisting: true - } - ) - } else if (typeof result.replay === 'string' && result.replay.length > 0) { - // Why: the relay reattach replay is the one restore main never ingests, so without - // this seed its model is a mere suffix of what the renderer painted — and a later - // park-reveal would outrank the fuller relay replay with that fragment. - runtime.seedHeadlessTerminal(result.id, result.replay) - } - } - if ( - typeof args.worktreeId === 'string' && - args.worktreeId.length > 0 && - args.worktreeId.length <= 512 - ) { - const agentLaunchAuthority = admitRendererAgentLaunchAuthority({ - launchToken: args.launchToken, - spawnEnv, - launchAgent: args.launchAgent, - launchConfig: effectiveLaunchConfig, - isReattach: result.isReattach === true, - hasStablePaneOwner: stablePaneOwner !== null, - incarnationId: result.incarnationId - }) - runtime?.registerPty( - result.id, - args.worktreeId, - args.connectionId ?? null, - // Why: pass validated pane identity so a throttled mobile create publishes its surface main-side instead of destroying the live PTY (#7587); bound the untrusted tabId. - typeof args.tabId === 'string' && - isValidTerminalTabId(args.tabId) && - args.tabId.length <= 512 && - metadataLeafId !== null - ? { - tabId: args.tabId, - leafId: metadataLeafId, - ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), - ...(agentLaunchAuthority ? { agentLaunchAuthority } : {}) - } - : undefined, - !args.connectionId - ? shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd) - : undefined - ) - pendingRegistrationPtyId = null - } else if (pendingRegistrationPtyId) { - runtime?.cancelPendingPtyRegistration?.(pendingRegistrationPtyId, result.incarnationId) - pendingRegistrationPtyId = null - } - // Why: seed after registerPty binds the worktree — including on - // desktop, where the renderer-authority gate above skips the emulator - // seed but the list/read records still live main-side. - seedTerminalRestoreRecordsFromSpawnResult(runtime, result) - // Why: arm main's per-PTY Command Code output detector from the launch command (startupCommand parity); banner detection covers PTYs without one. - if (!stablePaneOwner) { - runtime?.noteTerminalSpawnCommand?.( - result.id, - typeof launchCommand === 'string' ? launchCommand : null - ) - } - if (isClaudeLaunch && !stablePaneOwner) { - markClaudePtySpawned(result.id) - } - // Why: record the paneKey mapping so clearProviderPtyState can clear the agent-hooks server's per-paneKey caches on exit. - // Why: args.env is untrusted IPC JSON (type unenforced); bound the paneKey so malformed/oversized values can't pollute ptyPaneKey or clearPaneState. - const rememberedPaneKey = validatedPaneKey - ? rememberPaneKeyForPty(result.id, validatedPaneKey) - : null - if (legacySpawnPaneKey && migrationUnsupportedPaneKey) { - agentHookServer.registerPaneKeyAlias( - legacySpawnPaneKey.paneKey, - migrationUnsupportedPaneKey, - result.id, - Date.now(), - { authorityVerified: true } - ) - clearMigrationUnsupportedPtysForPaneKey(migrationUnsupportedPaneKey) - } else if (validatedPaneKey) { - if (!result.isReattach) { - clearMigrationUnsupportedPtysForPaneKey(validatedPaneKey) - } - } - // Why: register only local PTYs with the memory collector — SSH PTYs run remotely and their process tree is invisible to our local `ps`. - if (!args.connectionId) { - // Why: record the spawn-result pid once here so the memory module needn't reach back into ipc/pty on a hot path (works for in-process and daemon-hosted PTYs). - const spawnedPid = result.pid ?? null - // Why: args.worktreeId/sessionId arrive as untrusted IPC strings (type unenforced at the boundary); bound them so malformed/oversized values can't pollute registerPty's maps. - registerPty({ - ptyId: result.id, - worktreeId: - typeof args.worktreeId === 'string' && - args.worktreeId.length > 0 && - args.worktreeId.length <= 512 - ? args.worktreeId - : null, - sessionId: - typeof args.sessionId === 'string' && - args.sessionId.length > 0 && - args.sessionId.length <= 256 - ? args.sessionId - : null, - paneKey: rememberedPaneKey, - pid: - typeof spawnedPid === 'number' && Number.isFinite(spawnedPid) && spawnedPid > 0 - ? spawnedPid - : null - }) - } - // Why: telemetry-plan.md§Agent launch semantics — fire agent_started only after spawn resolved; safeParse each field so a spoofed IPC payload can't poison the event (missing required field skips it). - if (args.telemetry && !stablePaneOwner) { - const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind) - const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source) - const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind) - if (agentKindParse.success && launchSourceParse.success && requestKindParse.success) { - track('agent_started', { - agent_kind: agentKindParse.data, - launch_source: launchSourceParse.data, - request_kind: requestKindParse.data, - ...getCohortAtEmit() - }) - } - } - const response = { - ...result, - // Why both or neither: a pane can only adopt proven kitty flags together - // with the sequence boundary they describe. - ...(typeof result.snapshotKittyKeyboardFlags === 'number' && - reconciledSnapshotSeq !== null && - snapshotKittyFlagsCoverReconciledSeq - ? { snapshotSeq: reconciledSnapshotSeq } - : { snapshotKittyKeyboardFlags: undefined }), - ...(!result.isReattach && effectiveLaunchConfig - ? { launchConfig: effectiveLaunchConfig } - : {}), - // Why: a daemon-retry race can surface isReattach even for a minted session id, and a reattach must never claim its cwd was remapped. - ...(startupCwdFallback && !result.isReattach ? { startupCwdFallback } : {}), - // Why: the pane asked to resume and got a fresh session instead; only the - // renderer can say so, and a reattach never ran this launch command. - ...(codexResumeLaunch.notifyResumeUnavailable && !result.isReattach - ? { agentResumeUnavailable: true as const } - : {}) - } - // Why: renderer tab state cannot reliably infer background and reattached PTYs in the daemon inventory. - sendPtySpawnedToRenderer(result.id) - if (!args.connectionId) { - options?.onCodexHomePtySpawned?.({ - id: result.id, - codexHomePath: selectedCodexHomePath, - startedAt: codexHomeLaunchStartedAt, - startedSequence: codexHomeLaunchStartedSequence, - ...codexReattachedHomeRouteField( - reattachedCodexHomeRoutes, - result.id, - result.isReattach === true - ), - ...(result.isReattach === true - ? { reattached: true } - : baseEnv - ? { launchEnv: baseEnv } - : {}) - }) - } - return resolvePaneSpawnReservation(paneSpawnReservationKey, paneSpawnReservation, response) - } catch (err) { - if (pendingRegistrationPtyId) { - runtime?.cancelPendingPtyRegistration?.( - pendingRegistrationPtyId, - rejectedRegistrationCandidate?.incarnationId - ) - pendingRegistrationPtyId = null - } - // Why: once the reservation is created, any later throw — - // spawn failure, persist failure, or a post-spawn helper such as - // seedHeadlessTerminal/registerPty/track — must settle it. Otherwise - // it lingers in paneSpawnReservationsByOwnerKey and every future spawn - // for this pane awaits a promise that never resolves. reject is a - // no-op once the reservation has already resolved. - rejectPaneSpawnReservation(paneSpawnReservationKey, paneSpawnReservation, err) - throw err - } finally { - releaseWorktreeSpawn?.() - finishTerminalInstall() - } - } - ) - - const reportUnavailablePtyWrite = (id: string, error: unknown): void => { - if (!isPtyWriteUnavailableError(error) || isRendererGone(mainWindow)) { - return - } - sendToRenderer(mainWindow, 'pty:writeUnavailable', { id }) - } - - const writePtyProviderInputWithinLimit = ( - provider: IPtyProvider, - id: string, - data: string - ): boolean | Promise => { - const chunks = iterateTerminalInputChunks(data) - const first = chunks.next() - if (first.done) { - provider.write(id, data) - return true - } - const second = chunks.next() - if (second.done) { - provider.write(id, first.value) - return true - } - return writePtyProviderInputChunks(provider, id, chunks, first.value, second.value) - } - - const writePtyProviderInput = ( - provider: IPtyProvider, - id: string, - data: string - ): boolean | Promise => { - try { - const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data) - if (typeof tooLarge === 'boolean') { - return tooLarge ? false : writePtyProviderInputWithinLimit(provider, id, data) - } - return tooLarge - .then((result) => (result ? false : writePtyProviderInputWithinLimit(provider, id, data))) - .catch((error) => { - reportUnavailablePtyWrite(id, error) - return false - }) - } catch (error) { - reportUnavailablePtyWrite(id, error) - return false - } - } - - const writePtyProviderInputChunks = async ( - provider: IPtyProvider, - id: string, - chunks: Iterator, - firstChunk: string, - secondChunk: string - ): Promise => { - try { - let chunk: IteratorResult = { done: false, value: firstChunk } - let nextChunk: IteratorResult = { done: false, value: secondChunk } - while (!chunk.done) { - provider.write(id, chunk.value) - if (!nextChunk.done) { - await new Promise((resolve) => setTimeout(resolve, 0)) - } - chunk = nextChunk - nextChunk = chunks.next() - } - return true - } catch (error) { - reportUnavailablePtyWrite(id, error) - return false - } - } - - type PtyWritePayload = { id: string; data: string } - type PtyViewportClaimPayload = { id: string; cols: number; rows: number } - - const isPtyWritePayload = (value: unknown): value is PtyWritePayload => - typeof value === 'object' && - value !== null && - typeof (value as { id?: unknown }).id === 'string' && - (value as { id: string }).id.length > 0 && - typeof (value as { data?: unknown }).data === 'string' - - const isPtyViewportClaimPayload = (value: unknown): value is PtyViewportClaimPayload => - typeof value === 'object' && - value !== null && - typeof (value as { id?: unknown }).id === 'string' && - (value as { id: string }).id.length > 0 && - typeof (value as { cols?: unknown }).cols === 'number' && - Number.isFinite((value as { cols: number }).cols) && - typeof (value as { rows?: unknown }).rows === 'number' && - Number.isFinite((value as { rows: number }).rows) && - (value as { cols: number }).cols > 0 && - (value as { rows: number }).rows > 0 - - // Why null-tolerant: with no renderer there is no sender that can legitimately match, - // so every write is rejected. These handlers cannot fire headless anyway — ipcMain has - // nothing to deliver from — but failing closed is the right answer if that ever changes. - const isPtyWriteEventFromMainWindow = ( - event: IpcMainEvent | IpcMainInvokeEvent, - mainWebContents: WebContents | null - ): boolean => - mainWebContents !== null && - event.sender === mainWebContents && - !isRendererGone(mainWindow) && - !(typeof mainWebContents.isDestroyed === 'function' && mainWebContents.isDestroyed()) - - const writePtyInput = (args: PtyWritePayload): boolean | Promise => { - // Why: mobile-presence-lock defense-in-depth — the renderer's onData guard can let one keystroke slip during the state-flip lag, so catch it server-side. See docs/mobile-presence-lock.md. - if (runtime?.getDriver(args.id).kind === 'mobile') { - return false - } - const provider = ptyOwnership.has(args.id) ? tryGetProviderForPty(args.id) : undefined - if (!provider) { - return false - } - try { - const now = performance.now() - lastInputAtByPty.set(args.id, now) - interactiveOutputCharsByPty.set(args.id, 0) - if (visibleRendererPtys.has(args.id)) { - clearHiddenRendererResizeOutput(args.id) - } - return writePtyProviderInput(provider, args.id, args.data) - } catch { - return false - } - } - - const writePtyInputAccepted = (args: PtyWritePayload): boolean | Promise => { - if (runtime?.getDriver(args.id).kind === 'mobile') { - return false - } - // Why: the ack infers Ctrl+C/Escape reached the local PTY; SSH providers are fire-and-forget relay notifications and can't truthfully acknowledge yet. - if (ptyOwnership.get(args.id) !== null) { - return false - } - const provider = tryGetProviderForPty(args.id) - if (!provider?.hasPty?.(args.id)) { - return false - } - try { - const now = performance.now() - lastInputAtByPty.set(args.id, now) - interactiveOutputCharsByPty.set(args.id, 0) - if (visibleRendererPtys.has(args.id)) { - clearHiddenRendererResizeOutput(args.id) - } - return writePtyProviderInput(provider, args.id, args.data) - } catch { - return false - } - } - - const hostViewportClaimTails = new Map>() - - ipc.on('pty:write', (event, args: unknown) => { - if ( - !isPtyWriteEventFromMainWindow(event, rendererWebContents(mainWindow)) || - !isPtyWritePayload(args) - ) { - return - } - const claimTail = hostViewportClaimTails.get(args.id) - if (claimTail) { - void claimTail.then((claimed) => (claimed ? writePtyInput(args) : false)) - return - } - writePtyInput(args) - }) - ipc.handle('pty:writeAccepted', (event, args: unknown): boolean | Promise => { - if ( - !isPtyWriteEventFromMainWindow(event, rendererWebContents(mainWindow)) || - !isPtyWritePayload(args) - ) { - return false - } - const claimTail = hostViewportClaimTails.get(args.id) - return claimTail - ? claimTail.then((claimed) => (claimed ? writePtyInputAccepted(args) : false)) - : writePtyInputAccepted(args) - }) - - ipc.removeAllListeners('pty:claimViewport') - ipc.on('pty:claimViewport', (event, args: unknown) => { - if ( - !isPtyWriteEventFromMainWindow(event, rendererWebContents(mainWindow)) || - !runtime || - !isPtyViewportClaimPayload(args) - ) { - return - } - const prior = hostViewportClaimTails.get(args.id) - // Why: two panes can mirror one PTY — never let a later no-op claim replace the in-flight resize that the following host input must await. - const claim = ( - prior - ? prior.then( - () => runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows), - () => runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows) - ) - : runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows) - ).catch(() => false) - hostViewportClaimTails.set(args.id, claim) - void claim.then(() => { - if (hostViewportClaimTails.get(args.id) === claim) { - hostViewportClaimTails.delete(args.id) - } - }) - }) - - // Why: resize is fire-and-forget — ipc.on (not .handle) halves IPC traffic by skipping the empty acknowledgement reply. - ipc.removeAllListeners('pty:resize') - ipc.on('pty:resize', (_event, args: { id: string; cols: number; rows: number }) => { - // Why: after a desktop-fit override change the renderer's safeFit cascade re-measures ALL panes (background ones at full width), so suppress every pty:resize in this window to avoid corrupting PTY dimensions. - if (runtime?.isResizeSuppressed()) { - return - } - // Why: presence-lock defense-in-depth — while a phone or remote-desktop viewer drives the width, host-side resizes must not reach the PTY or its alt-screen grid garbles; load-bearing because the renderer mirror lags one IPC hop. See docs/mobile-presence-lock.md. - const mobileOwnsResize = runtime?.getDriver(args.id).kind === 'mobile' - const remoteDesktopOwnsResize = runtime?.isRemoteDesktopResizeDriven?.(args.id) === true - if (mobileOwnsResize || remoteDesktopOwnsResize) { - if (remoteDesktopOwnsResize) { - runtime?.recordRemoteDesktopHostReclaimTarget(args.id, args.cols, args.rows) - } - return - } - const provider = tryGetProviderForPty(args.id) - if (!provider) { - return - } - const markedHiddenResizeOutput = rendererPtyIsKnownHidden(args.id) - if (markedHiddenResizeOutput) { - // Why: alt-screen TUIs repaint on SIGWINCH; a hidden repaint read after switch-back must not masquerade as live output and overwrite the correctly-sized screen. - pendingHiddenRendererResizeOutputPtys.add(args.id) - deliveredHiddenRendererResizeOutputPtys.delete(args.id) - } else if (visibleRendererPtys.has(args.id)) { - // Why: after the stale hidden-resize repaint is observed, the renderer's visible resize pulse owns the next repaint. - clearDeliveredHiddenRendererResizeOutput(args.id) - } - try { - provider.resize(args.id, args.cols, args.rows) - } catch { - if (markedHiddenResizeOutput) { - pendingHiddenRendererResizeOutputPtys.delete(args.id) - } - return - } - ptySizes.set(args.id, { cols: args.cols, rows: args.rows }) - runtime?.onExternalPtyResize(args.id, args.cols, args.rows) - }) - - // Why: pty:reportGeometry is a measurement-only sibling of pty:resize — it refreshes the restore-target cache (never resizes) so mobile-fit hold learns real desktop dims even while resize is blocked. See docs/mobile-fit-hold.md. - ipc.removeAllListeners('pty:reportGeometry') - ipc.on('pty:reportGeometry', (_event, args: { id: string; cols: number; rows: number }) => { - runtime?.recordRendererGeometry(args.id, args.cols, args.rows) - }) - - // Why: fire-and-forget — clears the DaemonPtyAdapter's sticky cold-restore cache after the renderer consumed it; no-op for non-daemon providers. - ipc.on('pty:ackColdRestore', (_event, args: { id: string }) => { - const provider = tryGetProviderForPty(args.id) - if (provider && 'ackColdRestore' in provider && typeof provider.ackColdRestore === 'function') { - provider.ackColdRestore(args.id) - } - }) - - // Why: renderer ACKs bound main→renderer delivery without stopping PTY ingestion — agent/status consumers still see every chunk via the provider/runtime path. - ipc.on( - 'pty:ackData', - (_event, args: { id: string; charCount?: number; processedChars?: number }) => { - lastAckReceivedAtMs = Date.now() - // Why: a live ACK channel means a future unanswered probe is a fresh diagnostic event, not a continuation of the last silent streak. - deliveryResyncUnansweredWarnLogged = false - let acknowledged = 0 - if (typeof args.processedChars === 'number' && Number.isFinite(args.processedChars)) { - acknowledged = applyCumulativeAck(args.id, Math.max(0, args.processedChars)) - } else { - // Why: tolerate legacy per-chunk delta payloads — dev hot-reload can pair an old renderer with a new main. - const accounting = rendererDeliveryAccountingByPty.get(args.id) - const delta = Number.isFinite(args.charCount) ? Math.max(0, args.charCount ?? 0) : 0 - acknowledged = accounting ? applyCumulativeAck(args.id, accounting.ackedChars + delta) : 0 - } - tryGetProviderForPty(args.id)?.acknowledgeDataEvent(args.id, acknowledged) - schedulePendingDataAfterCreditReport(acknowledged > 0) - } - ) - - ipc.on( - 'pty:deliveryResyncResponse', - (_event, args: { requestId: number; processedCharsByPty: Record }) => { - if ( - deliveryResyncOutstandingRequestId === null || - args?.requestId !== deliveryResyncOutstandingRequestId - ) { - return - } - clearDeliveryResyncProbe() - deliveryResyncUnansweredWarnLogged = false - // Why max-merge: the renderer's cumulative totals are authoritative for what it processed, draining exactly the in-flight debt from lost ACKs. - let creditedAny = false - for (const [id, processedChars] of Object.entries(args.processedCharsByPty ?? {})) { - if (typeof processedChars !== 'number' || !Number.isFinite(processedChars)) { - continue - } - const acknowledged = applyCumulativeAck(id, Math.max(0, processedChars)) - if (acknowledged > 0) { - creditedAny = true - tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged) - } - } - schedulePendingDataAfterCreditReport(creditedAny) - } - ) - - // Why invoke + renderer-initiated: the field wedge (v1.4.121-rc.0) kills every main→renderer push channel while invoke survives, so the resync rides here plus a write-off lane. - ipc.handle( - 'pty:reportRendererDeliveryState', - (_event, args: PtyRendererDeliveryStateReport): PtyRendererDeliveryHealthReply => { - // Extra repair lane for the lost-ACK variant: identical max-merge to the resync response, so a heal is only reached when merging cannot drain. - let creditedAny = false - for (const [id, processedChars] of Object.entries(args?.processedCharsByPty ?? {})) { - if (typeof processedChars !== 'number' || !Number.isFinite(processedChars)) { - continue - } - const acknowledged = applyCumulativeAck(id, Math.max(0, processedChars)) - if (acknowledged > 0) { - creditedAny = true - tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged) - } - } - let writtenOff: PtyDeliveryWriteOff[] = [] - // Why the main-side ACK-silence check: requiring main to have also seen no ACK stops a buggy/foreign caller from writing off live delivery. - if ( - args?.heal === true && - rendererInFlightTotalChars > 0 && - (lastAckReceivedAtMs === null || - Date.now() - lastAckReceivedAtMs >= PTY_DELIVERY_HEAL_MIN_ACK_SILENCE_MS) - ) { - writtenOff = writeOffLostRendererDelivery(args) - creditedAny ||= writtenOff.length > 0 - } - schedulePendingDataAfterCreditReport(creditedAny) - let inFlightPtyCount = 0 - for (const accounting of rendererDeliveryAccountingByPty.values()) { - if (accounting.sentChars - accounting.ackedChars > 0) { - inFlightPtyCount++ - } - } - return { - inFlightTotalChars: rendererInFlightTotalChars, - inFlightPtyCount, - msSinceLastAck: lastAckReceivedAtMs === null ? null : Date.now() - lastAckReceivedAtMs, - ...(writtenOff.length > 0 ? { writtenOff } : {}) - } - } - ) - - // Why: renderer signals its pty:data listener is live; until then sends are held so boot-window bytes can't drop into a listener-less page and pin the gate. - ipc.removeAllListeners('pty:rendererDispatcherReady') - ipc.on('pty:rendererDispatcherReady', (event) => { - // Why: the reconcile below destructively clears delivery accounting, so a straggler handshake from a dying window must not reset the new window. - if (!isPtyWriteEventFromMainWindow(event, rendererWebContents(mainWindow))) { - return - } - // Why: a handshake while the gate is already open means a page load whose lifecycle reset was missed; clear the dead page's stale accounting so it can't permanently gate survivors. - if (rendererPtyDispatcherReady) { - resetRendererDeliveryAccountingForLifecycleReset() - } - // Why: real handshake landed — cancel the self-heal watchdog so it can't later force-open the gate. - clearDispatcherReadyWatchdog() - rendererPtyDispatcherReady = true - pendingData.reactivateBlocked() - schedulePendingDataFlush(0) - }) - - ipc.removeAllListeners('pty:setActiveRendererPty') - ipc.on('pty:setActiveRendererPty', (_event, args: { id: string; active: boolean }) => { - if (typeof args.id !== 'string' || !args.id) { - return - } - // Why: renderer scheduling hint only — active panes just get first chance at the bounded output reserve; reads/state/notifications continue for inactive terminals. - if (args.active) { - if (activeRendererPtys.has(args.id)) { - return - } - activeRendererPtys.add(args.id) - } else if (!activeRendererPtys.delete(args.id)) { - return - } - invalidatePendingPtyDrainPriority(args.id) - }) - - ipc.removeAllListeners('pty:setRendererPtyVisible') - ipc.on('pty:setRendererPtyVisible', (_event, args: { id: string; visible: boolean }) => { - if (typeof args.id !== 'string' || !args.id) { - return - } - // Why: data produced while no renderer can see this PTY must keep that origin through batching, even if the user switches back before the flush lands. - rendererVisibilityKnownPtys.add(args.id) - if (args.visible) { - visibleRendererPtys.add(args.id) - closeStartupQueryAuthorityForPty(args.id) - } else { - visibleRendererPtys.delete(args.id) - } - syncPtyBackgroundedDelivery(args.id, 'visibility-report') - }) - - ipc.removeAllListeners('pty:setHiddenRendererPty') - ipc.on('pty:setHiddenRendererPty', (_event, args: { id: string; hidden: boolean }) => { - if (typeof args.id !== 'string' || !args.id) { - return - } - mainDeliveryBreadcrumbs.record(args.hidden === true ? 'gate-mark' : 'gate-unmark', { - id: redactPtyIdForDiagnostics(args.id) - }) - const transition = transitionHiddenRendererPtyDeliveryState(args.id, args.hidden === true) - if (args.hidden === true) { - closeStartupQueryAuthorityForPty(args.id) - // Why: drop bytes queued for a newly hidden PTY instead of holding them under ACK starvation; reveal restores from the snapshot. - const pending = pendingData.get(args.id) - if (pending && transition.droppable) { - pendingData.delete(args.id) - if (pending.projectionAdmissionIds) { - sshOutputIntake?.transferProjections(pending.projectionAdmissionIds, 'hidden-drop') - } - updateProducerFlowControl(args.id) - pendingOverflowMarkedPtys.delete(args.id) - const drop = recordHiddenRendererPtyDataDrop(args.id, pending.data.length) - if (drop.shouldEmitRestoreMarker) { - sendModelRestoreNeededMarker( - args.id, - 'hidden-drop', - runtime?.getPtyOutputSequence(args.id) - ) - } - } - if (transition.policyChanged) { - invalidatePendingPtyDrainPolicy(args.id) - } - syncPtyBackgroundedDelivery(args.id, 'gate-mark') - return - } - if (transition.policyChanged) { - invalidatePendingPtyDrainPolicy(args.id) - } - syncPtyBackgroundedDelivery(args.id, 'gate-unmark') - // Why: a reload/remount may have replaced the view that latched restore-needed, so re-emit on unhide; a redundant replay is cheap/idempotent, a missed restore corrupts the pane. - if (transition.droppedWhileHidden) { - sendModelRestoreNeededMarker(args.id, 'unhide', runtime?.getPtyOutputSequence(args.id)) - } - }) - - ipc.removeAllListeners('pty:terminalViewAttributes') - ipc.on('pty:terminalViewAttributes', (_event, args: unknown) => { - // Why validate-or-drop: a malformed palette gives a wrong color reply that breaks TUI theme detection worse than the silent-until-first-push default. - const attributes = validateTerminalViewAttributes(args) - if (attributes) { - setTerminalViewAttributes(attributes) - } - }) - - ipc.removeAllListeners('pty:setPtyDeliveryInterest') - ipc.on('pty:setPtyDeliveryInterest', (_event, args: { id: string; interested: boolean }) => { - if (typeof args.id !== 'string' || !args.id) { - return - } - // Why: any delivery interest suppresses the hidden-delivery gate (raw-byte consumers keep receiving while hidden); not synced to the daemon pacer so interest churn can't un-pace a flood. - const settings = getSettings?.() - const wasDroppable = shouldDropHiddenRendererPtyData(args.id, settings) - setRendererPtyDeliveryInterest(args.id, args.interested === true) - if (wasDroppable !== shouldDropHiddenRendererPtyData(args.id, settings)) { - invalidatePendingPtyDrainPolicy(args.id) - } - }) - - ipc.removeAllListeners('pty:signal') - ipc.on('pty:signal', (_event, args: { id: string; signal: string }) => { - tryGetProviderForPty(args.id) - ?.sendSignal(args.id, args.signal) - .catch(() => {}) - }) - - ipc.removeAllListeners('pty:clearBuffer') - ipc.on('pty:clearBuffer', (_event, args: { id: string }) => { - // Why: clear PTY-side state (ConPTY/daemon/SSH buffer) so the next prompt repaint doesn't land at a stale cursor row. - tryGetProviderForPty(args.id) - ?.clearBuffer(args.id) - .catch(() => {}) - runtime?.clearHeadlessTerminalBuffer(args.id).catch(() => {}) - }) - - ipc.handle('pty:kill', async (_event, args: { id: string; keepHistory?: boolean }) => { - if (typeof args?.id !== 'string' || !args.id || args.id.startsWith('remote:')) { - // Why: runtime terminal handles belong to terminal.close; unowned PTY routing could target the local provider. - throw new Error('Invalid PTY provider id') - } - runtime?.markPtyStopRequested?.(args.id) - const ownedConnectionId = ptyOwnership.get(args.id) - const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null - const connectionId = ownedConnectionId ?? parsedSshId?.connectionId - // Why: wait for daemon startup before selecting the local provider, else a fallback shutdown falsely succeeds and orphans a restored daemon PTY (#7742). - const startupPromise = getLocalPtyProviderStartupPromise(connectionId) - if (startupPromise) { - await startupPromise - } - const provider = connectionId ? sshProviders.get(connectionId) : tryGetProviderForPty(args.id) - if (!provider && connectionId) { - // Why: detached SSH PTYs intentionally keep ownership after their - // provider is unregistered; hydrated app-scoped ids can also arrive - // before ownership is rebuilt. Tombstone instead of falling back local. - const incarnationId = finishPtyShutdown(args.id, connectionId, store) - runtime?.markPtyLivenessUnverifiable?.(args.id, SSH_PROVIDER_UNREGISTERED_REASON) - runtime?.onPtyExit(args.id, -1, incarnationId) - rememberSyntheticKillExit(args.id) - sendPtyExitToRenderer({ id: args.id, code: -1 }) - return - } - const shutdownProvider = provider ?? getProviderForPty(args.id) - let providerExitObserved = false - try { - providerExitObserved = await shutdownProviderAndDetectExit(shutdownProvider, args.id, { - immediate: true, - keepHistory: args.keepHistory ?? false - }) - } catch (err) { - if (!isPtyAlreadyGoneError(err)) { - // Why: a failed shutdown can leave the process alive (SSH relay grace window / local daemon); keep ownership/lease state so the user can retry. - throw err - } - /* session already dead — cleanup below handles the rest */ - } - // Why: some shutdown paths do not emit onExit through the provider listener. - // Explicit cleanup is idempotent and covers already-dead PTYs. - const incarnationId = finishPtyShutdown(args.id, connectionId, store) - if (!providerExitObserved) { - runtime?.onPtyExit(args.id, -1, incarnationId) - rememberSyntheticKillExit(args.id) - sendPtyExitToRenderer({ id: args.id, code: -1 }) - } - }) - - ipc.handle('pty:listSessions', async (): Promise => { - const deduped = new Map() - const admission = new PtyProcessListAdmission() - await visitPtyProcessListingsInBatches( - registeredPtyProviders(), - ({ provider, connectionId }) => - connectionId === null ? provider.listProcesses() : provider.listProcesses().catch(() => []), - ({ provider, connectionId }, sessions) => { - for (const rawSession of sessions) { - const session = admission.admit(rawSession) - // Why: kill actions only send back the PTY id, so rebuild ownership while listing to keep reconnect-discovered remote sessions routed to their provider. - ptyOwnership.set(session.id, connectionId) - deduped.set(session.id, { - id: session.id, - cwd: session.cwd, - title: session.title, - // Why: the renderer's binding map is empty during restore, so ownership is the only - // liveness evidence it has. Absence is authoritative only from a provider that - // serializes claims — otherwise it is 'unknown', never 'absent' (#8459). - agentOwnership: - (session.agentSessionOwners?.length ?? 0) > 0 - ? 'present' - : provider.providesAgentSessionOwnerListings?.(session.id) === true - ? 'absent' - : 'unknown' - }) - } - } - ) - return Array.from(deduped.values()) - }) - - ipc.handle( - 'pty:getAuthoritativeBufferSnapshotCapabilities', - async (_event, args: { ids?: unknown }) => { - const ids = Array.isArray(args?.ids) ? args.ids.slice(0, 512) : [] - const hasLocalPtyId = ids.some((value) => { - if ( - typeof value !== 'string' || - value.length === 0 || - value.length > 512 || - value.startsWith('remote:') || - parseAppSshPtyId(value) - ) { - return false - } - const ownedConnectionId = ptyOwnership.get(value) - return ownedConnectionId === undefined || ownedConnectionId === null - }) - if (hasLocalPtyId) { - await getLocalPtyProviderStartupPromise() - } - const capabilities: { id: string; authoritative: boolean | null }[] = [] - const seen = new Set() - for (const value of ids) { - if ( - typeof value !== 'string' || - value.length === 0 || - value.length > 512 || - seen.has(value) - ) { - continue - } - seen.add(value) - const provider = tryGetProviderForPty(value) - // Resolved providers without the optional method are definitively non-authoritative; null remains retryable. - capabilities.push({ - id: value, - authoritative: - provider === undefined || provider === null - ? null - : provider.canProvideAuthoritativeBufferSnapshot - ? provider.canProvideAuthoritativeBufferSnapshot(value) - : false - }) - } - return capabilities - } - ) - - ipc.handle('pty:hasPty', async (_event, args: { id: string }): Promise => { - if (typeof args?.id !== 'string' || args.id.startsWith('remote:')) { - // Why: same routing hazard pty:kill guards against — ptyOwnership never holds - // a runtime terminal handle and parseAppSshPtyId ignores it, so the lookup - // falls through to the local provider and its "not in my table" reads as an - // authoritative dead. That is a fabricated answer about another host's PTY. - return null - } - const ownedConnectionId = ptyOwnership.get(args.id) - const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null - const provider = parsedSshId - ? sshProviders.get(parsedSshId.connectionId) - : tryGetProviderForPty(args.id) - if (!provider?.hasPty) { - return null - } - try { - return provider.hasPty(args.id) - } catch { - // Why: liveness is only allowed to close panes on an authoritative false. - return null - } - }) - - ipc.handle('pty:hasChildProcesses', async (_event, args: { id: string }): Promise => { - if (!hasPtyProviderForInspection(args.id)) { - return false - } - return getProviderForPty(args.id).hasChildProcesses(args.id) - }) - - ipc.handle( - 'pty:getForegroundProcess', - async (_event, args: { id: string }): Promise => { - if (!hasPtyProviderForInspection(args.id)) { - return null - } - return getProviderForPty(args.id).getForegroundProcess(args.id) - } - ) - - ipc.handle('pty:inspectProcess', async (_event, args: { id: string }) => - inspectPtyProviderProcessForRenderer(getProviderForPty(args.id), args.id) - ) - - ipc.handle( - 'pty:confirmForegroundProcess', - async (_event, args: { id: string }): Promise => { - if (!hasPtyProviderForInspection(args.id)) { - return null - } - const provider = getProviderForPty(args.id) - // Why: the cached foreground API would turn stale process identity into shell/agent authority at a command boundary. - return provider.confirmForegroundProcess?.(args.id) ?? null - } - ) - - // Why: Cmd+D split needs the live shell cwd so the new pane inherits it (not the worktree root); '' means unknown/unresolvable (Windows) → renderer falls through. - ipc.handle('pty:getCwd', async (_event, args: { id: string }): Promise => { - try { - return await getProviderForPty(args.id).getCwd(args.id) - } catch { - return '' - } - }) - - // Why: prefer the provider's APPLIED size over the requested ptySizes so the renderer's resume drift-check can spot a dropped resize; null means "cannot confirm" → re-forward once. - ipc.handle( - 'pty:getSize', - async (_event, args: { id: string }): Promise<{ cols: number; rows: number } | null> => { - const provider = tryGetProviderForPty(args.id) - try { - if (provider?.getAppliedSize) { - // Why: a provider-owned null means it could not verify the applied - // grid; preserve null so the renderer re-forwards instead of trusting - // the requested-size cache that may describe a dropped resize. - return await provider.getAppliedSize(args.id) - } - } catch { - // Fall through to the requested-size cache so a dead daemon/relay can't throw across the IPC boundary. - } - return ptySizes.get(args.id) ?? null - } - ) - - // Pre-signal handshake handlers (declare→spawn→settle/clear); see docs/mobile-prefer-renderer-scrollback.md and `pendingByPaneKey` above. - ipc.handle( - 'pty:declarePendingPaneSerializer', - async (event, args: { paneKey?: unknown }): Promise => { - if (!isValidPaneKey(args.paneKey)) { - throw new Error('Invalid paneKey') - } - return declarePendingPaneSerializer(args.paneKey, event?.sender) - } - ) - - ipc.handle( - 'pty:settlePaneSerializer', - async (_event, args: { paneKey?: unknown; gen?: unknown }): Promise => { - if (!isValidPaneKey(args.paneKey) || typeof args.gen !== 'number') { - return - } - const ptyId = pendingPtyIdBySerializerGeneration.get(args.gen) - const settledCurrentGeneration = settlePendingPaneSerializer(args.paneKey, args.gen) - // Why: the generation-to-PTY binding survives late teardown of a reused id; paneKey reverse maps may already be gone. - pendingPtyIdBySerializerGeneration.delete(args.gen) - if (settledCurrentGeneration && ptyId) { - rendererSerializerReadiness.markReady(ptyId) - } - } - ) - - ipc.handle( - 'pty:clearPendingPaneSerializer', - async (_event, args: { paneKey?: unknown; gen?: unknown }): Promise => { - if (!isValidPaneKey(args.paneKey) || typeof args.gen !== 'number') { - return - } - settlePendingPaneSerializer(args.paneKey, args.gen) - pendingPtyIdBySerializerGeneration.delete(args.gen) - } - ) - - ipc.handle( - 'pty:reportRendererSerializerReady', - async (_event, args: { ptyId?: unknown }): Promise => { - if ( - typeof args.ptyId !== 'string' || - !args.ptyId.startsWith('remote:') || - args.ptyId.length > 512 - ) { - return - } - // Why: remote-runtime panes skip the local spawn cooperation gate, so their exact PTY id is the only readiness key. - rendererSerializerReadiness.markReady(args.ptyId) - } - ) -} - -export function registerHeadlessPtyRuntime( - runtime: OrcaRuntimeService, - getSelectedCodexHomePath?: GetSelectedCodexHomePath, - getSettings?: () => GlobalSettings, - prepareClaudeAuth?: PrepareClaudeAuth, - store?: Store, - prepareCodexSessionResume?: PrepareCodexSessionResume, - lifecycle?: { - onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void - onPtyExit?: (id: string, exitSequence: number) => void - } -): void { - // Why null and not a stub window: headless `orca serve` has no renderer but needs the - // same PTY handlers so remote clients can drive terminals. This used to pass a fake - // BrowserWindow whose isDestroyed() returned true — a window-shaped object that lied - // about being a window. `null` says the same thing honestly, and keeps `electron` out - // of this path entirely. - registerPtyHandlers( - null, - runtime, - getSelectedCodexHomePath, - getSettings, - prepareClaudeAuth, - store, - { prepareCodexSessionResume, ...lifecycle } - ) -} - -/** - * Kill in-process local PTYs. Daemon-backed PTYs are preserved by daemon disconnect. - */ -export function killAllPty(): void { - if (localProvider instanceof LocalPtyProvider) { - localProvider.killAll() - } -} +export { + isCurrentPtyExit, + deletePtyOwnership, + setPtyOwnership, + restorePtyIncarnation, + getPtyIdsForConnection +} from './pty/provider/ownership-state' +export { getPtyIdForPaneKey, registerPaneKeyTeardownListener } from './pty/pane/key-state' +export { hasPendingRendererSerializerForPaneKey } from './pty/pane/serializer-state' +export type { + BuildPtyHostEnvOptions, + CodexHomeLaunchContext, + GetSelectedCodexHomePath, + PrepareCodexSessionResume, + CodexHomePtySpawnedLifecycleArgs +} from './pty/host-env/types' +export { resolveCodexHomeAfterManagedAuthReadiness } from './pty/host-env/codex-home' +export { buildPtyHostEnv } from './pty/host-env/assembly' +export { + registerSshPtyProvider, + unregisterSshPtyProvider, + getSshPtyProvider, + getLocalPtyProvider, + setLocalPtyProvider +} from './pty/provider/registry' +export { clearProviderPtyState, clearPtyOwnershipForConnection } from './pty/provider/state-cleanup' +export { + rebindLocalProviderListeners, + unbindLocalProviderListeners +} from './pty/provider/listener-lifecycle' +export type { PtyRendererDeliveryDebugSnapshot } from './pty/delivery/debug' +export { + getPtyRendererDeliveryDebugSnapshot, + resetPtyRendererDeliveryDebug +} from './pty/delivery/debug' +export { registerPtyHandlers } from './pty/register-handlers' +export { registerHeadlessPtyRuntime } from './pty/register-headless-runtime' +export { killAllPty } from './pty/kill-all' diff --git a/src/main/ipc/pty/delivery/accept.ts b/src/main/ipc/pty/delivery/accept.ts new file mode 100644 index 00000000000..91117147dde --- /dev/null +++ b/src/main/ipc/pty/delivery/accept.ts @@ -0,0 +1,190 @@ +import type { LegacySshProjectionSemantics } from '../../ssh-pty-legacy-projection' +import { + recordHiddenRendererPtyDataDrop, + shouldDropHiddenRendererPtyData +} from '../../pty-hidden-delivery-gate' +import { + deliveredHiddenRendererResizeOutputPtys, + pendingHiddenRendererResizeOutputPtys, + rendererVisibilityKnownPtys, + visibleRendererPtys, + activeRendererPtys +} from './visibility-state' +import { PTY_BATCH_INTERVAL_MS } from './constants' +import { appendPendingPtyData, getDroppedMode2031RendererData } from './pending' +import { sendModelRestoreNeededMarker, sendPtyDataToRenderer } from './payload' +import { shouldSendInteractiveOutputNow } from './interactive' +import { requestDeliveryResyncForGatedPty } from './accounting' +import { warnIfDroppingHiddenBytesForVisiblePty } from './debug-snapshot' +import { clearFlushTimerIfIdle } from './flush' +import type { PtyIpcSession } from '../session' + +export function rendererPtyIsKnownHidden(id: string): boolean { + return rendererVisibilityKnownPtys.has(id) && !visibleRendererPtys.has(id) +} + +export function ptyHasHiddenRendererResizeOutput(id: string): boolean { + return ( + pendingHiddenRendererResizeOutputPtys.has(id) || deliveredHiddenRendererResizeOutputPtys.has(id) + ) +} + +export function markHiddenRendererResizeOutputDelivered(id: string): void { + if (!pendingHiddenRendererResizeOutputPtys.delete(id)) { + return + } + deliveredHiddenRendererResizeOutputPtys.add(id) +} + +export function clearDeliveredHiddenRendererResizeOutput(id: string): void { + deliveredHiddenRendererResizeOutputPtys.delete(id) +} + +export function clearHiddenRendererResizeOutput(id: string): void { + pendingHiddenRendererResizeOutputPtys.delete(id) + deliveredHiddenRendererResizeOutputPtys.delete(id) +} + +export function acceptPtyDataForRenderer( + session: PtyIpcSession, + payload: { + id: string + data: string + sequenceChars?: number + transformed?: boolean + }, + outputSeq: number | undefined, + projection?: LegacySshProjectionSemantics +): void { + const rawLength = payload.sequenceChars ?? payload.data.length + const preservesSeq = !payload.transformed && rawLength === payload.data.length + const startSeq = typeof outputSeq === 'number' ? Math.max(0, outputSeq - rawLength) : undefined + const projectionId = projection?.identity.projectionSemanticsId + if (session.mainWindow.isDestroyed()) { + if (projectionId) { + session.sshOutputIntake?.transferProjections([projectionId], 'renderer-destroyed') + } + if (session.flushTimer) { + clearTimeout(session.flushTimer) + session.flushTimer = null + } + session.producerFlowControl.releaseAll() + session.clearDeliveryResyncProbe() + session.clearPendingPtyData() + session.pendingOverflowMarkedPtys.clear() + session.rendererDeliveryAccountingByPty.clear() + session.rendererInFlightTotalChars = 0 + session.clearDispatcherReadyWatchdog() + return + } + if (session.rendererExitingPtyIds.has(payload.id)) { + if (projectionId) { + session.sshOutputIntake?.transferProjections([projectionId], 'pty-exiting') + } + return + } + if (shouldDropHiddenRendererPtyData(payload.id, session.getSettings?.())) { + if (projectionId) { + session.sshOutputIntake?.transferProjections([projectionId], 'hidden-drop') + } + const droppedChars = projection ? rawLength : payload.data.length + const drop = recordHiddenRendererPtyDataDrop(payload.id, droppedChars) + warnIfDroppingHiddenBytesForVisiblePty(session, payload.id, droppedChars) + if (drop.shouldEmitRestoreMarker) { + sendModelRestoreNeededMarker(session, payload.id, 'hidden-drop', outputSeq) + } + return + } + if (payload.data.length === 0 && !payload.transformed) { + if (projectionId) { + session.sshOutputIntake?.transferProjections([projectionId], 'empty-projection') + } + return + } + const containsBackgroundOutput = + rendererPtyIsKnownHidden(payload.id) || ptyHasHiddenRendererResizeOutput(payload.id) + if (containsBackgroundOutput) { + markHiddenRendererResizeOutputDelivered(payload.id) + } + const overflowMarkedBeforeAppend = session.pendingOverflowMarkedPtys.has(payload.id) + if (projection?.desktopSpan) { + session.sourceCreditPendingPtys.add(payload.id) + } + const pending = appendPendingPtyData( + session, + payload.id, + session.pendingData.get(payload.id), + payload.data, + startSeq, + preservesSeq, + containsBackgroundOutput, + rawLength, + payload.transformed === true, + projectionId + ) + const shouldEmitPendingCapRestoreMarker = + pending.droppedOutput === true && + !overflowMarkedBeforeAppend && + session.pendingOverflowMarkedPtys.has(payload.id) + const nextData = pending.data + getDroppedMode2031RendererData(pending) + const isInteractiveOutput = shouldSendInteractiveOutputNow( + payload.id, + nextData, + performance.now() + ) + if (isInteractiveOutput && session.rendererPtyDispatcherReady) { + if (!session.canSendPtyDataToRenderer(payload.id, { interactive: true })) { + session.setPendingPtyData(payload.id, pending) + if (shouldEmitPendingCapRestoreMarker) { + sendModelRestoreNeededMarker(session, payload.id, 'pending-cap', outputSeq) + } + session.updateProducerFlowControl(payload.id) + requestDeliveryResyncForGatedPty(session) + return + } + session.deletePendingPtyData(payload.id) + clearFlushTimerIfIdle(session) + if (shouldEmitPendingCapRestoreMarker) { + sendModelRestoreNeededMarker(session, payload.id, 'pending-cap', outputSeq) + } + session.pendingOverflowMarkedPtys.delete(payload.id) + try { + sendPtyDataToRenderer( + session, + payload.id, + { + id: payload.id, + data: nextData, + ...(typeof pending.startSeq === 'number' + ? { + seq: pending.startSeq + (pending.rawLength ?? nextData.length), + rawLength: pending.rawLength ?? nextData.length + } + : {}), + ...(pending.transformed ? { transformed: true } : {}), + ...(pending.containsBackgroundOutput === true ? { background: true } : {}), + ...(pending.droppedOutput === true ? { droppedOutput: true } : {}) + }, + pending.projectionAdmissionIds + ) + } finally { + session.updateProducerFlowControl(payload.id) + } + return + } + session.setPendingPtyData(payload.id, pending) + if (shouldEmitPendingCapRestoreMarker) { + sendModelRestoreNeededMarker(session, payload.id, 'pending-cap', outputSeq) + } + session.updateProducerFlowControl(payload.id) + if ( + !session.canSendPtyDataToRenderer(payload.id, { + interactive: activeRendererPtys.has(payload.id) + }) + ) { + requestDeliveryResyncForGatedPty(session) + } + if (!session.flushTimer) { + session.schedulePendingDataFlush(PTY_BATCH_INTERVAL_MS) + } +} diff --git a/src/main/ipc/pty/delivery/accounting.ts b/src/main/ipc/pty/delivery/accounting.ts new file mode 100644 index 00000000000..eddc49be3f2 --- /dev/null +++ b/src/main/ipc/pty/delivery/accounting.ts @@ -0,0 +1,219 @@ +import type { + PtyDeliveryWriteOff, + PtyRendererDeliveryStateReport +} from '../../../../shared/pty-renderer-delivery-health' +import { tryGetProviderForPty } from '../provider/registry' +import { mainDeliveryBreadcrumbs } from './debug' +import { + PTY_DELIVERY_RESYNC_TIMEOUT_MS, + PTY_RENDERER_ACTIVE_PTY_IN_FLIGHT_RESERVE_CHARS, + PTY_RENDERER_INTERACTIVE_RESERVE_CHARS, + PTY_RENDERER_IN_FLIGHT_HIGH_WATER_CHARS, + PTY_RENDERER_TOTAL_IN_FLIGHT_HIGH_WATER_CHARS +} from './constants' +import type { PtyIpcSession } from '../session' +import type { PendingPtyData } from '../../pty-pending-data-drain-queue' + +export function getRendererInFlightCharsForPty(session: PtyIpcSession, id: string): number { + const accounting = session.rendererDeliveryAccountingByPty.get(id) + return accounting ? accounting.sentChars - accounting.ackedChars : 0 +} + +export function recordPtyRendererDeliveryPressure(session: PtyIpcSession, id: string): void { + session.peakPendingChars = Math.max( + session.peakPendingChars, + session.pendingData.totalPendingChars + ) + session.peakMaxPendingCharsByPty = Math.max( + session.peakMaxPendingCharsByPty, + session.pendingData.get(id)?.data.length ?? 0 + ) + session.peakRendererInFlightChars = Math.max( + session.peakRendererInFlightChars, + session.rendererInFlightTotalChars + ) + session.peakMaxRendererInFlightCharsByPty = Math.max( + session.peakMaxRendererInFlightCharsByPty, + getRendererInFlightCharsForPty(session, id) + ) +} + +export function setPendingPtyData( + session: PtyIpcSession, + id: string, + pending: PendingPtyData +): void { + session.pendingData.set(id, pending) + recordPtyRendererDeliveryPressure(session, id) +} + +export function deletePendingPtyData(session: PtyIpcSession, id: string): void { + session.pendingData.delete(id) +} + +export function clearPendingPtyData(session: PtyIpcSession): void { + for (const pending of session.pendingData.values()) { + if (pending.projectionAdmissionIds) { + session.sshOutputIntake?.transferProjections( + pending.projectionAdmissionIds, + 'renderer-lifecycle-reset' + ) + } + } + session.pendingData.clear() + session.sourceCreditPendingPtys.clear() +} + +export function canSendPtyDataToRenderer( + session: PtyIpcSession, + id: string, + options: { interactive?: boolean } = {} +): boolean { + const totalLimit = + PTY_RENDERER_TOTAL_IN_FLIGHT_HIGH_WATER_CHARS + + (options.interactive === true ? PTY_RENDERER_INTERACTIVE_RESERVE_CHARS : 0) + // Why per-PTY (not global) reserve: keep one active pane responsive without letting every background pane burst past the cap. + const ptyLimit = + PTY_RENDERER_IN_FLIGHT_HIGH_WATER_CHARS + + (options.interactive === true ? PTY_RENDERER_ACTIVE_PTY_IN_FLIGHT_RESERVE_CHARS : 0) + return ( + getRendererInFlightCharsForPty(session, id) < ptyLimit && + session.rendererInFlightTotalChars < totalLimit + ) +} + +export function applyCumulativeAck( + session: PtyIpcSession, + id: string, + processedChars: number +): number { + const accounting = session.rendererDeliveryAccountingByPty.get(id) + if (!accounting) { + return 0 + } + // Clamped to sentChars so a corrupt payload cannot drive in-flight negative. + const nextAckedChars = Math.min( + accounting.sentChars, + Math.max(accounting.ackedChars, processedChars) + ) + const acknowledged = nextAckedChars - accounting.ackedChars + accounting.ackedChars = nextAckedChars + if (acknowledged > 0) { + accounting.lastAckAtMs = Date.now() + } + session.rendererInFlightTotalChars = Math.max( + 0, + session.rendererInFlightTotalChars - acknowledged + ) + if (acknowledged > 0) { + session.sshOutputIntake?.settleProjectionPrefix(id, acknowledged) + } + return acknowledged +} + +export function schedulePendingDataAfterCreditReport( + session: PtyIpcSession, + creditedAny: boolean +): void { + if (creditedAny) { + session.pendingData.reactivateBlocked() + } + if (session.pendingData.size > 0 && !session.flushTimer) { + session.schedulePendingDataFlush(0) + } +} + +export function clearDeliveryResyncProbe(session: PtyIpcSession): void { + session.deliveryResyncOutstandingRequestId = null + if (session.deliveryResyncTimer) { + clearTimeout(session.deliveryResyncTimer) + session.deliveryResyncTimer = null + } +} + +export function requestDeliveryResyncForGatedPty(session: PtyIpcSession): void { + if (session.deliveryResyncOutstandingRequestId !== null || session.mainWindow.isDestroyed()) { + return + } + session.deliveryResyncRequestSerial += 1 + const requestId = session.deliveryResyncRequestSerial + session.deliveryResyncOutstandingRequestId = requestId + session.deliveryResyncTimer = setTimeout(() => { + if (session.deliveryResyncOutstandingRequestId !== requestId) { + return + } + clearDeliveryResyncProbe(session) + // Why no mutation on timeout: unanswered means dead IPC that only a reload cures; log once per silent streak to avoid spamming every probe. + if (session.deliveryResyncUnansweredWarnLogged) { + return + } + session.deliveryResyncUnansweredWarnLogged = true + console.warn('[pty] delivery resync probe unanswered — renderer IPC unresponsive', { + msSinceLastAck: + session.lastAckReceivedAtMs === null ? null : Date.now() - session.lastAckReceivedAtMs, + ...session.readCurrentPtyRendererDeliveryDebugSnapshot() + }) + }, PTY_DELIVERY_RESYNC_TIMEOUT_MS) + session.deliveryResyncTimer.unref?.() + session.mainWindow.webContents.send('pty:requestDeliveryResync', { requestId }) +} + +export function writeOffLostRendererDelivery( + session: PtyIpcSession, + report: PtyRendererDeliveryStateReport +): PtyDeliveryWriteOff[] { + const writtenOff: PtyDeliveryWriteOff[] = [] + for (const [id, accounting] of session.rendererDeliveryAccountingByPty) { + if (accounting.sentChars - accounting.ackedChars <= 0) { + continue + } + const received = report.receivedCharsByPty?.[id] + const receivedChars = + typeof received === 'number' && Number.isFinite(received) ? Math.max(0, received) : 0 + // Why skip: received-but-unparsed bytes are alive in the renderer write queue; their deferred ACK still repays this debt. + if (receivedChars > accounting.ackedChars) { + continue + } + const acknowledged = applyCumulativeAck(session, id, accounting.sentChars) + if (acknowledged <= 0) { + continue + } + tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged) + // Why drop pending: everything at/before markerSeq comes from the snapshot, so flushing pre-marker bytes would double-paint the restore. + const pending = session.pendingData.get(id) + if (pending) { + if (pending.projectionAdmissionIds) { + session.sshOutputIntake?.transferProjections( + pending.projectionAdmissionIds, + 'renderer-delivery-writeoff' + ) + } + session.pendingDroppedChars += pending.data.length + deletePendingPtyData(session, id) + session.pendingOverflowMarkedPtys.delete(id) + session.updateProducerFlowControl(id) + } + const markerSeq = session.runtime?.getPtyOutputSequence(id) + writtenOff.push({ + id, + ...(typeof markerSeq === 'number' ? { markerSeq } : {}), + writtenOffChars: acknowledged + }) + } + if (writtenOff.length > 0) { + clearDeliveryResyncProbe(session) + session.deliveryResyncUnansweredWarnLogged = false + mainDeliveryBreadcrumbs.record('delivery-heal-writeoff', { + writtenOffPtyCount: writtenOff.length, + writtenOffChars: writtenOff.reduce((sum, { writtenOffChars }) => sum + writtenOffChars, 0) + }) + console.warn('[pty] delivery heal: wrote off renderer-bound bytes lost in push channel', { + rendererPtyDataListenerCount: report.rendererPtyDataListenerCount ?? null, + msSinceLastAck: + session.lastAckReceivedAtMs === null ? null : Date.now() - session.lastAckReceivedAtMs, + writtenOffByPty: writtenOff.map(({ id, writtenOffChars }) => ({ id, writtenOffChars })), + ...session.readCurrentPtyRendererDeliveryDebugSnapshot() + }) + } + return writtenOff +} diff --git a/src/main/ipc/pty/delivery/constants.ts b/src/main/ipc/pty/delivery/constants.ts new file mode 100644 index 00000000000..b9b3bb3670c --- /dev/null +++ b/src/main/ipc/pty/delivery/constants.ts @@ -0,0 +1,17 @@ +export const PTY_BATCH_INTERVAL_MS = 2 +export const PTY_BATCH_DRAIN_CONTINUE_MS = 1 +export const PTY_BATCH_FLUSH_CHUNK_CHARS = 16 * 1024 +export const PTY_BATCH_FLUSH_MAX_WRITES = 2 +export const PTY_RENDERER_IN_FLIGHT_HIGH_WATER_CHARS = 512 * 1024 +export const PTY_RENDERER_TOTAL_IN_FLIGHT_HIGH_WATER_CHARS = 8 * 1024 * 1024 +export const PTY_DISPATCHER_READY_WATCHDOG_MS = 10_000 +export const PTY_RENDERER_INTERACTIVE_RESERVE_CHARS = 256 * 1024 +export const PTY_RENDERER_ACTIVE_PTY_IN_FLIGHT_RESERVE_CHARS = 512 * 1024 +export const PTY_DELIVERY_RESYNC_TIMEOUT_MS = 5_000 +export const PTY_DELIVERY_HEAL_MIN_ACK_SILENCE_MS = 10_000 +export const INTERACTIVE_OUTPUT_WINDOW_MS = 100 +export const INTERACTIVE_OUTPUT_MAX_CHARS = 1024 +export const INTERACTIVE_REDRAW_MAX_CHARS = PTY_BATCH_FLUSH_CHUNK_CHARS +export const INTERACTIVE_OUTPUT_BUDGET_CHARS = 32 * 1024 +export const DELIVERY_DIAGNOSTICS_MAX_PTYS = 30 +export const DROPPED_QUERY_SALVAGE_MAX_CHARS = 4096 diff --git a/src/main/ipc/pty/delivery/debug-snapshot.ts b/src/main/ipc/pty/delivery/debug-snapshot.ts new file mode 100644 index 00000000000..facc04e4e74 --- /dev/null +++ b/src/main/ipc/pty/delivery/debug-snapshot.ts @@ -0,0 +1,168 @@ +import { getAppEnvironment } from '../../../../shared/app-environment' +import { + type PtyMainDeliveryDiagnostics, + type PtyPerPtyDeliveryDiagnostics, + redactPtyIdForDiagnostics +} from '../../../../shared/pty-delivery-diagnostics' +import { + getHiddenRendererPtyDeliveryDebug, + getHiddenRendererPtyIds, + isHiddenRendererPty +} from '../../pty-hidden-delivery-gate' +import { + lastPowerResumeAtMs, + lastPowerSuspendAtMs, + mainDeliveryBreadcrumbs, + type PtyRendererDeliveryDebugSnapshot +} from './debug' +import { activeRendererPtys, visibleRendererPtys } from './visibility-state' +import { DELIVERY_DIAGNOSTICS_MAX_PTYS } from './constants' +import type { PtyIpcSession } from '../session' + +export function buildMainDeliveryDiagnostics(session: PtyIpcSession): PtyMainDeliveryDiagnostics { + const now = Date.now() + // Include hidden/visible/active members even without an accounting entry: a pty gated before its first byte is exactly the wedge case to surface. + const ids = new Set([ + ...session.rendererDeliveryAccountingByPty.keys(), + ...session.pendingData.keys(), + ...getHiddenRendererPtyIds(), + ...visibleRendererPtys, + ...activeRendererPtys + ]) + const perPty: PtyPerPtyDeliveryDiagnostics[] = [] + for (const id of ids) { + const accounting = session.rendererDeliveryAccountingByPty.get(id) + perPty.push({ + id: redactPtyIdForDiagnostics(id), + sentChars: accounting?.sentChars ?? 0, + ackedChars: accounting?.ackedChars ?? 0, + inFlightChars: accounting ? accounting.sentChars - accounting.ackedChars : 0, + pendingChars: session.pendingData.get(id)?.data.length ?? 0, + hidden: isHiddenRendererPty(id), + visible: visibleRendererPtys.has(id), + active: activeRendererPtys.has(id), + msSinceLastSend: accounting ? now - accounting.lastSendAtMs : null, + msSinceLastAck: accounting?.lastAckAtMs == null ? null : now - accounting.lastAckAtMs + }) + } + perPty.sort((a, b) => b.inFlightChars + b.pendingChars - (a.inFlightChars + a.pendingChars)) + const windowAlive = !session.mainWindow.isDestroyed() + return { + appVersion: getAppEnvironment().getVersion(), + mainUptimeMs: Math.round(process.uptime() * 1000), + windowFocused: windowAlive ? session.mainWindow.isFocused() : null, + windowVisible: windowAlive ? session.mainWindow.isVisible() : null, + windowMinimized: windowAlive ? session.mainWindow.isMinimized() : null, + msSinceLastPowerSuspend: lastPowerSuspendAtMs === null ? null : now - lastPowerSuspendAtMs, + msSinceLastPowerResume: lastPowerResumeAtMs === null ? null : now - lastPowerResumeAtMs, + perPty: perPty.slice(0, DELIVERY_DIAGNOSTICS_MAX_PTYS), + breadcrumbs: mainDeliveryBreadcrumbs.snapshot() + } +} + +export function readCurrentPtyRendererDeliveryDebugSnapshot( + session: PtyIpcSession +): PtyRendererDeliveryDebugSnapshot { + let pendingChars = 0 + let maxPendingCharsByPty = 0 + for (const pending of session.pendingData.values()) { + const chars = pending.data.length + pendingChars += chars + maxPendingCharsByPty = Math.max(maxPendingCharsByPty, chars) + } + const hiddenDeliveryDebug = getHiddenRendererPtyDeliveryDebug() + let rendererInFlightPtyCount = 0 + let maxRendererInFlightCharsByPty = 0 + for (const accounting of session.rendererDeliveryAccountingByPty.values()) { + const inFlight = accounting.sentChars - accounting.ackedChars + if (inFlight > 0) { + rendererInFlightPtyCount++ + } + maxRendererInFlightCharsByPty = Math.max(maxRendererInFlightCharsByPty, inFlight) + } + // Why: a pty both hidden-gated and reported visible means main is starving a visible pane (v1.4.124-rc.2.perf field lead). + let hiddenDeliveryGatedVisiblePtyCount = 0 + for (const id of visibleRendererPtys) { + if (isHiddenRendererPty(id)) { + hiddenDeliveryGatedVisiblePtyCount++ + } + } + let hiddenDeliveryGatedActivePtyCount = 0 + for (const id of activeRendererPtys) { + if (isHiddenRendererPty(id)) { + hiddenDeliveryGatedActivePtyCount++ + } + } + return { + pendingPtyCount: session.pendingData.size, + pendingChars, + maxPendingCharsByPty, + rendererInFlightPtyCount, + rendererInFlightChars: session.rendererInFlightTotalChars, + maxRendererInFlightCharsByPty, + activeRendererPtyCount: activeRendererPtys.size, + flushScheduled: session.flushTimer !== null, + peakPendingChars: session.peakPendingChars, + peakMaxPendingCharsByPty: session.peakMaxPendingCharsByPty, + peakRendererInFlightChars: session.peakRendererInFlightChars, + peakMaxRendererInFlightCharsByPty: session.peakMaxRendererInFlightCharsByPty, + ackGatedFlushSkipCount: session.ackGatedFlushSkipCount, + ...hiddenDeliveryDebug, + hiddenDeliveryGatedVisiblePtyCount, + hiddenDeliveryGatedActivePtyCount, + pendingDroppedChars: session.pendingDroppedChars, + diagnostics: buildMainDeliveryDiagnostics(session), + rendererLifecycleResetCount: session.rendererLifecycleResetCount, + lastLifecycleResetClearedChars: session.lastLifecycleResetClearedChars, + rendererPtyDispatcherReady: session.rendererPtyDispatcherReady, + rendererDispatcherReadyForcedCount: session.rendererDispatcherReadyForcedCount + } +} + +export function seedPtyRendererDeliveryPeaksFromCurrentState(session: PtyIpcSession): void { + let pendingChars = 0 + let maxPendingCharsByPty = 0 + for (const pending of session.pendingData.values()) { + const chars = pending.data.length + pendingChars += chars + maxPendingCharsByPty = Math.max(maxPendingCharsByPty, chars) + } + session.peakPendingChars = pendingChars + session.peakMaxPendingCharsByPty = maxPendingCharsByPty + session.peakRendererInFlightChars = session.rendererInFlightTotalChars + let maxRendererInFlightCharsByPty = 0 + for (const accounting of session.rendererDeliveryAccountingByPty.values()) { + maxRendererInFlightCharsByPty = Math.max( + maxRendererInFlightCharsByPty, + accounting.sentChars - accounting.ackedChars + ) + } + session.peakMaxRendererInFlightCharsByPty = maxRendererInFlightCharsByPty +} + +export function warnIfDroppingHiddenBytesForVisiblePty( + session: PtyIpcSession, + id: string, + droppedChars: number +): void { + if (!visibleRendererPtys.has(id) && !activeRendererPtys.has(id)) { + return + } + // Recorded before the warn rate limit: the ring coalesces repeats, and the contradiction must appear in the freeze report either way. + mainDeliveryBreadcrumbs.record('hidden-drop-visible', { + id: redactPtyIdForDiagnostics(id), + droppedChars + }) + const now = Date.now() + if (now - session.lastHiddenDropContradictionWarnAtMs < 60_000) { + return + } + session.lastHiddenDropContradictionWarnAtMs = now + console.warn('[pty] hidden-delivery gate is dropping bytes for a visible/active pty', { + id: redactPtyIdForDiagnostics(id), + droppedChars, + visible: visibleRendererPtys.has(id), + active: activeRendererPtys.has(id), + ...readCurrentPtyRendererDeliveryDebugSnapshot(session) + }) +} diff --git a/src/main/ipc/pty/delivery/debug.ts b/src/main/ipc/pty/delivery/debug.ts new file mode 100644 index 00000000000..99dae69488d --- /dev/null +++ b/src/main/ipc/pty/delivery/debug.ts @@ -0,0 +1,124 @@ +import { getPtyPower } from '../../pty-host-bindings' +import { + type PtyMainDeliveryDiagnostics, + EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS, + createPtyDeliveryBreadcrumbRing +} from '../../../../shared/pty-delivery-diagnostics' + +export type PtyRendererDeliveryDebugSnapshot = { + pendingPtyCount: number + pendingChars: number + maxPendingCharsByPty: number + rendererInFlightPtyCount: number + rendererInFlightChars: number + maxRendererInFlightCharsByPty: number + activeRendererPtyCount: number + flushScheduled: boolean + peakPendingChars: number + peakMaxPendingCharsByPty: number + peakRendererInFlightChars: number + peakMaxRendererInFlightCharsByPty: number + ackGatedFlushSkipCount: number + hiddenDeliveryGatedPtyCount: number + /** Hidden-gated ptys the renderer ALSO reports visible/active — a contradiction that should be zero (v1.4.124-rc.2.perf field lead). */ + hiddenDeliveryGatedVisiblePtyCount: number + hiddenDeliveryGatedActivePtyCount: number + deliveryInterestPtyCount: number + hiddenDeliveryDroppedChars: number + hiddenDeliveryDroppedChunks: number + pendingDroppedChars: number + /** One-paste freeze diagnostics: per-pty delivery table + event history. */ + diagnostics: PtyMainDeliveryDiagnostics + // Why: a nonzero lastLifecycleResetClearedChars is the exact signature of the leaked-accounting freeze this reset fixes. + rendererLifecycleResetCount: number + lastLifecycleResetClearedChars: number + // Why: the boot-window hold early-returns before ackGatedFlushSkipCount++, so these expose an otherwise-invisible held gate; forcedCount > 0 flags a watchdog self-heal. + rendererPtyDispatcherReady: boolean + rendererDispatcherReadyForcedCount: number +} + +// Why module scope: breadcrumb writers live both inside registerPtyHandlers and outside it (renderer lifecycle resets). +export const mainDeliveryBreadcrumbs = createPtyDeliveryBreadcrumbRing() +export let lastPowerSuspendAtMs: number | null = null +export let lastPowerResumeAtMs: number | null = null +let powerSignalBreadcrumbsInstalled = false + +// Why: both field freeze variants correlate with display sleep; suspend/resume timestamps let breadcrumbs line up against the wake. +export function installPowerSignalBreadcrumbs(): void { + if (powerSignalBreadcrumbsInstalled) { + return + } + powerSignalBreadcrumbsInstalled = true + const powerMonitor = getPtyPower() + powerMonitor.on('suspend', () => { + lastPowerSuspendAtMs = Date.now() + mainDeliveryBreadcrumbs.record('power-suspend') + }) + powerMonitor.on('resume', () => { + lastPowerResumeAtMs = Date.now() + mainDeliveryBreadcrumbs.record('power-resume') + }) +} + +export const EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT: PtyRendererDeliveryDebugSnapshot = { + pendingPtyCount: 0, + pendingChars: 0, + maxPendingCharsByPty: 0, + rendererInFlightPtyCount: 0, + rendererInFlightChars: 0, + maxRendererInFlightCharsByPty: 0, + activeRendererPtyCount: 0, + flushScheduled: false, + peakPendingChars: 0, + peakMaxPendingCharsByPty: 0, + peakRendererInFlightChars: 0, + peakMaxRendererInFlightCharsByPty: 0, + ackGatedFlushSkipCount: 0, + hiddenDeliveryGatedPtyCount: 0, + hiddenDeliveryGatedVisiblePtyCount: 0, + hiddenDeliveryGatedActivePtyCount: 0, + deliveryInterestPtyCount: 0, + hiddenDeliveryDroppedChars: 0, + hiddenDeliveryDroppedChunks: 0, + pendingDroppedChars: 0, + diagnostics: EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS, + rendererLifecycleResetCount: 0, + lastLifecycleResetClearedChars: 0, + rendererPtyDispatcherReady: false, + rendererDispatcherReadyForcedCount: 0 +} + +export let readPtyRendererDeliveryDebugSnapshot = (): PtyRendererDeliveryDebugSnapshot => ({ + ...EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT +}) +export let resetPtyRendererDeliveryDebugSnapshot = (): void => {} +// Bridged into the registerPtyHandlers closure so the module-scope lifecycle-reset handler can zero closure-owned delivery accounting on renderer reload/crash. +export let resetRendererDeliveryAccountingForLifecycleReset = (): void => {} +// Bridged so a re-registration can cancel the prior closure's dispatcher-ready watchdog before wiring its own. +export let clearRendererDispatcherReadyWatchdog = (): void => {} + +export function setReadPtyRendererDeliveryDebugSnapshot( + fn: () => PtyRendererDeliveryDebugSnapshot +): void { + readPtyRendererDeliveryDebugSnapshot = fn +} + +export function setResetPtyRendererDeliveryDebugSnapshot(fn: () => void): void { + resetPtyRendererDeliveryDebugSnapshot = fn +} + +export function setResetRendererDeliveryAccountingForLifecycleReset(fn: () => void): void { + resetRendererDeliveryAccountingForLifecycleReset = fn +} + +export function setClearRendererDispatcherReadyWatchdog(fn: () => void): void { + clearRendererDispatcherReadyWatchdog = fn +} + +export function getPtyRendererDeliveryDebugSnapshot(): PtyRendererDeliveryDebugSnapshot { + return readPtyRendererDeliveryDebugSnapshot() +} + +export function resetPtyRendererDeliveryDebug(): void { + resetPtyRendererDeliveryDebugSnapshot() +} diff --git a/src/main/ipc/pty/delivery/exit.ts b/src/main/ipc/pty/delivery/exit.ts new file mode 100644 index 00000000000..3825be51192 --- /dev/null +++ b/src/main/ipc/pty/delivery/exit.ts @@ -0,0 +1,177 @@ +import { + interactiveOutputCharsByPty, + lastInputAtByPty, + SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS +} from './visibility-state' +import { allocatePtyLifecycleSequence } from '../host-env/types' +import { makePtyDataPayload, sendPtyDataToRenderer } from './payload' +import { getRendererInFlightCharsForPty } from './accounting' +import { clearFlushTimerIfIdle } from './flush' +import type { PtyIpcSession } from '../session' + +export function rememberSyntheticKillExit(session: PtyIpcSession, id: string): void { + const existing = session.syntheticKillExitPtyIds.get(id) + if (existing) { + clearTimeout(existing) + } + // Why a timed window: providers may report the real exit after kill completes; skip only that late duplicate, not a future reused id forever. + const cleanupTimer = setTimeout(() => { + session.syntheticKillExitPtyIds.delete(id) + }, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS) + cleanupTimer.unref?.() + session.syntheticKillExitPtyIds.set(id, cleanupTimer) +} + +export function rememberRetiredRejectedPty(session: PtyIpcSession, id: string): void { + const existing = session.retiredRejectedPtyIds.get(id) + if (existing) { + clearTimeout(existing) + } + const cleanupTimer = setTimeout(() => { + session.retiredRejectedPtyIds.delete(id) + }, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS) + cleanupTimer.unref?.() + session.retiredRejectedPtyIds.set(id, cleanupTimer) +} + +export function consumeSyntheticKillExit(session: PtyIpcSession, id: string): boolean { + const cleanupTimer = session.syntheticKillExitPtyIds.get(id) + if (!cleanupTimer) { + return false + } + clearTimeout(cleanupTimer) + session.syntheticKillExitPtyIds.delete(id) + return true +} + +export function preparePtyExitForRenderer( + session: PtyIpcSession, + payload: { id: string; code: number; incarnationId?: string } +): (() => void) | null { + if (session.mainWindow.isDestroyed()) { + session.sshOutputIntake?.transferPtyProjections(payload.id, 'renderer-destroyed') + return () => {} + } + if (session.rendererExitingPtyIds.has(payload.id)) { + return null + } + session.rendererExitingPtyIds.add(payload.id) + let released = false + const release = (): void => { + if (released) { + return + } + released = true + session.rendererExitingPtyIds.delete(payload.id) + } + try { + if (!session.rendererCreditBeforeExitByPty.has(payload.id)) { + session.rendererCreditBeforeExitByPty.set( + payload.id, + getRendererInFlightCharsForPty(session, payload.id) > 0 + ) + } + // Why flush before exit: the renderer tears down the terminal on pty:exit, so any batched output not yet flushed would be silently lost. + const remaining = session.pendingData.delete(payload.id) + clearFlushTimerIfIdle(session) + if (remaining) { + if (remaining.droppedOutput === true) { + // Sentinel entry: only salvaged query bytes remain; keep the flag so the renderer knows the span was dropped. + sendPtyDataToRenderer( + session, + payload.id, + { + id: payload.id, + data: remaining.data, + droppedOutput: true + }, + remaining.projectionAdmissionIds + ) + } else { + sendPtyDataToRenderer( + session, + payload.id, + makePtyDataPayload( + payload.id, + remaining.data, + remaining.startSeq, + remaining.containsBackgroundOutput, + remaining.rawLength, + remaining.transformed + ), + remaining.projectionAdmissionIds + ) + } + } + return release + } catch (error) { + release() + throw error + } +} + +export function finalizePtyExitForRenderer( + session: PtyIpcSession, + payload: { id: string; code: number; incarnationId?: string } +): void { + if (session.mainWindow.isDestroyed()) { + session.rendererCreditBeforeExitByPty.delete(payload.id) + return + } + const hadReleasableRendererCredit = + session.rendererCreditBeforeExitByPty.get(payload.id) ?? + getRendererInFlightCharsForPty(session, payload.id) > 0 + session.rendererCreditBeforeExitByPty.delete(payload.id) + // Why resume a dead PTY (no-op): avoid leaving a stale paused mark behind for a reused id. + session.producerFlowControl.release(payload.id) + session.sourceCreditPendingPtys.delete(payload.id) + session.pendingOverflowMarkedPtys.delete(payload.id) + session.rendererDeliveryRestoreNeededPtys.delete(payload.id) + lastInputAtByPty.delete(payload.id) + interactiveOutputCharsByPty.delete(payload.id) + const releasedRendererCredit = getRendererInFlightCharsForPty(session, payload.id) + session.rendererInFlightTotalChars = Math.max( + 0, + session.rendererInFlightTotalChars - releasedRendererCredit + ) + // Why: the renderer also drops its cumulative total on pty:exit, so a reused id restarts aligned at zero on both sides. + session.rendererDeliveryAccountingByPty.delete(payload.id) + if (hadReleasableRendererCredit) { + if (session.pendingDataFlushActive) { + // Why: let the open round coalesce this wake into its one post-round continuation. + const reactivatedBlocked = session.pendingData.reactivateBlocked() + session.pendingDataCreditReleasedDuringFlush ||= reactivatedBlocked + } else { + session.schedulePendingDataAfterCreditReport(true) + } + } + session.mainWindow.webContents.send('pty:exit', { + ...payload, + ...(session.reversibleStopOwnersByPtyId.has(payload.id) + ? { preserveRendererBinding: true } + : {}) + }) +} + +export function sendPtyExitToRenderer( + session: PtyIpcSession, + payload: { id: string; code: number; incarnationId?: string } +): void { + session.options?.onPtyExit?.(payload.id, allocatePtyLifecycleSequence()) + const release = preparePtyExitForRenderer(session, payload) + if (!release) { + return + } + try { + session.sshOutputIntake?.transferPtyProjections(payload.id, 'legacy-pty-exit') + finalizePtyExitForRenderer(session, payload) + } finally { + release() + } +} + +export function sendPtySpawnedToRenderer(session: PtyIpcSession, id: string): void { + if (!session.mainWindow.isDestroyed()) { + session.mainWindow.webContents.send('pty:spawned', { id }) + } +} diff --git a/src/main/ipc/pty/delivery/flush.ts b/src/main/ipc/pty/delivery/flush.ts new file mode 100644 index 00000000000..038d955a932 --- /dev/null +++ b/src/main/ipc/pty/delivery/flush.ts @@ -0,0 +1,235 @@ +import { + recordHiddenRendererPtyDataDrop, + shouldDropHiddenRendererPtyData +} from '../../pty-hidden-delivery-gate' +import { propagatePendingProjectionRemainder } from '../../pty-pending-projection-admissions' +import type { PendingPtyData } from '../../pty-pending-data-drain-queue' +import { activeRendererPtys } from './visibility-state' +import { + PTY_BATCH_DRAIN_CONTINUE_MS, + PTY_BATCH_FLUSH_CHUNK_CHARS, + PTY_BATCH_FLUSH_MAX_WRITES, + PTY_DISPATCHER_READY_WATCHDOG_MS +} from './constants' +import { + getDroppedMode2031RendererData, + pendingProjectionAdmissionOptions, + updatePendingProjectionAdmissions +} from './pending' +import { makePtyDataPayload, sendModelRestoreNeededMarker, sendPtyDataToRenderer } from './payload' +import { warnIfDroppingHiddenBytesForVisiblePty } from './debug-snapshot' +import type { PtyIpcSession } from '../session' + +export function schedulePendingDataFlush(session: PtyIpcSession, delayMs: number): void { + if (session.flushTimer) { + return + } + session.flushTimer = setTimeout(() => session.flushPendingData(), delayMs) +} + +export function invalidatePendingPtyDrainClassification( + session: PtyIpcSession, + id?: string, + schedule = true +): void { + const invalidated = + typeof id === 'string' + ? session.pendingData.invalidate(id) + : session.pendingData.invalidateAll() + if (invalidated && schedule && !session.flushTimer) { + schedulePendingDataFlush(session, 0) + } +} + +export function clearDispatcherReadyWatchdog(session: PtyIpcSession): void { + if (session.dispatcherReadyWatchdogTimer) { + clearTimeout(session.dispatcherReadyWatchdogTimer) + session.dispatcherReadyWatchdogTimer = null + } +} + +export function armDispatcherReadyWatchdog(session: PtyIpcSession): void { + clearDispatcherReadyWatchdog(session) + if (session.mainWindow.isDestroyed()) { + return + } + // Why: one-shot self-heal — force the gate open if the reloaded page never signals ready, so a dropped handshake can't hold it forever. Unref'd so it can't keep the process alive. + session.dispatcherReadyWatchdogTimer = setTimeout(() => { + session.dispatcherReadyWatchdogTimer = null + if (session.rendererPtyDispatcherReady || session.mainWindow.isDestroyed()) { + return + } + session.rendererPtyDispatcherReady = true + session.rendererDispatcherReadyForcedCount += 1 + session.pendingData.reactivateBlocked() + schedulePendingDataFlush(session, 0) + }, PTY_DISPATCHER_READY_WATCHDOG_MS) + session.dispatcherReadyWatchdogTimer.unref?.() +} + +export function clearFlushTimerIfIdle(session: PtyIpcSession): void { + if (session.pendingData.size > 0 || session.flushTimer === null) { + return + } + clearTimeout(session.flushTimer) + session.flushTimer = null +} + +export function flushPendingData(session: PtyIpcSession): void { + session.flushTimer = null + if (session.mainWindow.isDestroyed()) { + // Why release now: bookkeeping is being wiped, so no future drain can resume these producers — local shells would wedge. + session.producerFlowControl.releaseAll() + session.clearDeliveryResyncProbe() + session.clearPendingPtyData() + session.pendingOverflowMarkedPtys.clear() + session.rendererDeliveryAccountingByPty.clear() + session.rendererInFlightTotalChars = 0 + session.clearDispatcherReadyWatchdog() + return + } + // Ordinary boot-window data is blocked in the queue; hidden-droppable entries still retire before renderer readiness. + const settings = session.getSettings?.() + let writes = 0 + let sendFailed = false + const round = session.pendingData.beginRound() + let creditReleasedDuringFlush = false + session.pendingDataFlushActive = true + session.pendingDataCreditReleasedDuringFlush = false + try { + while (writes < PTY_BATCH_FLUSH_MAX_WRITES) { + const selection = session.pendingData.takeNext(round) + if (!selection) { + break + } + const { id, pending } = selection + // Why drop, never re-queue: the model already ingested hidden-gated bytes; reveal restores from the snapshot+seq machinery. + if (shouldDropHiddenRendererPtyData(id, settings)) { + session.pendingData.remove(selection) + session.pendingOverflowMarkedPtys.delete(id) + session.updateProducerFlowControl(id) + const drop = recordHiddenRendererPtyDataDrop(id, pending.data.length) + if (pending.projectionAdmissionIds) { + session.sshOutputIntake?.transferProjections( + pending.projectionAdmissionIds, + 'hidden-drop' + ) + } + warnIfDroppingHiddenBytesForVisiblePty(session, id, pending.data.length) + if (drop.shouldEmitRestoreMarker) { + sendModelRestoreNeededMarker( + session, + id, + 'hidden-drop', + session.runtime?.getPtyOutputSequence(id) + ) + } + continue + } + if (!session.canSendPtyDataToRenderer(id, { interactive: activeRendererPtys.has(id) })) { + session.pendingData.block(selection) + continue + } + if (pending.droppedOutput === true) { + session.pendingData.remove(selection) + session.updateProducerFlowControl(id) + // Why droppedOutput sentinel: pending-cap drop means the pane must repaint from the snapshot, not continue a gapped stream (data = carved query bytes only). + if ( + !sendPtyDataToRenderer( + session, + id, + { + id, + data: pending.data + getDroppedMode2031RendererData(pending), + droppedOutput: true + }, + pending.projectionAdmissionIds + ).sent + ) { + sendFailed = true + break + } + writes++ + continue + } + const { data } = pending + const indivisible = pending.transformed === true + const chunk = indivisible ? data : data.slice(0, PTY_BATCH_FLUSH_CHUNK_CHARS) + const remaining = indivisible ? '' : data.slice(PTY_BATCH_FLUSH_CHUNK_CHARS) + let nextPending: PendingPtyData | undefined + if (remaining) { + nextPending = { data: remaining } + if (typeof pending.startSeq === 'number') { + nextPending.startSeq = pending.startSeq + chunk.length + } + if (pending.containsBackgroundOutput === true) { + nextPending.containsBackgroundOutput = true + } + if (pending.projectionAdmissionIds) { + nextPending.projectionAdmissionIds = pending.projectionAdmissionIds + } + if (pending.projectionAdmissionsTransferred) { + nextPending.projectionAdmissionsTransferred = true + } + session.pendingData.replaceWithRemainder(selection, nextPending) + } else { + session.pendingData.remove(selection) + session.pendingOverflowMarkedPtys.delete(id) + } + session.updateProducerFlowControl(id) + const delivery = sendPtyDataToRenderer( + session, + id, + makePtyDataPayload( + id, + chunk, + pending.startSeq, + pending.containsBackgroundOutput, + pending.rawLength, + pending.transformed + ), + pending.projectionAdmissionIds + ) + if (nextPending) { + updatePendingProjectionAdmissions( + nextPending, + propagatePendingProjectionRemainder( + nextPending, + delivery, + pendingProjectionAdmissionOptions(session) + ) + ) + } + if (!delivery.sent) { + sendFailed = true + break + } + writes++ + } + } finally { + session.pendingDataFlushActive = false + creditReleasedDuringFlush = session.pendingDataCreditReleasedDuringFlush + session.pendingDataCreditReleasedDuringFlush = false + session.pendingData.endRound(round) + } + if ( + session.rendererPtyDispatcherReady && + session.pendingData.size > 0 && + writes === 0 && + !sendFailed + ) { + session.ackGatedFlushSkipCount++ + } + if (sendFailed && session.pendingData.size > 0) { + if (session.flushTimer) { + clearTimeout(session.flushTimer) + session.flushTimer = null + } + schedulePendingDataFlush(session, PTY_BATCH_DRAIN_CONTINUE_MS) + return + } + if (session.pendingData.size > 0 && (writes > 0 || creditReleasedDuringFlush)) { + // Why yield between slices: a background terminal can dump megabytes at once, and keystroke writes must not stall behind one flush. + schedulePendingDataFlush(session, writes > 0 ? PTY_BATCH_DRAIN_CONTINUE_MS : 0) + } +} diff --git a/src/main/ipc/pty/delivery/hidden-transition.ts b/src/main/ipc/pty/delivery/hidden-transition.ts new file mode 100644 index 00000000000..a6ca1204875 --- /dev/null +++ b/src/main/ipc/pty/delivery/hidden-transition.ts @@ -0,0 +1,35 @@ +import { + markHiddenRendererPty, + shouldDropHiddenRendererPtyData, + unmarkHiddenRendererPty +} from '../../pty-hidden-delivery-gate' +import { invalidatePendingPtyDrainPolicy } from './visibility-state' +import type { PtyIpcSession } from '../session' + +export function transitionHiddenRendererPtyDeliveryState( + session: PtyIpcSession, + id: string, + hidden: boolean +): { droppable: boolean; droppedWhileHidden: boolean; policyChanged: boolean } { + const settings = session.getSettings?.() + const wasDroppable = shouldDropHiddenRendererPtyData(id, settings) + let droppedWhileHidden = false + if (hidden) { + markHiddenRendererPty(id) + } else { + droppedWhileHidden = unmarkHiddenRendererPty(id).droppedWhileHidden + } + const droppable = shouldDropHiddenRendererPtyData(id, settings) + return { droppable, droppedWhileHidden, policyChanged: wasDroppable !== droppable } +} + +export function transitionSpawnHiddenRendererPtyDeliveryState( + session: PtyIpcSession, + id: string, + hidden: boolean +): void { + const transition = transitionHiddenRendererPtyDeliveryState(session, id, hidden) + if (transition.policyChanged) { + invalidatePendingPtyDrainPolicy(id) + } +} diff --git a/src/main/ipc/pty/delivery/interactive.ts b/src/main/ipc/pty/delivery/interactive.ts new file mode 100644 index 00000000000..6a69af426f8 --- /dev/null +++ b/src/main/ipc/pty/delivery/interactive.ts @@ -0,0 +1,34 @@ +import { interactiveOutputCharsByPty, lastInputAtByPty } from './visibility-state' +import { + INTERACTIVE_OUTPUT_BUDGET_CHARS, + INTERACTIVE_OUTPUT_MAX_CHARS, + INTERACTIVE_OUTPUT_WINDOW_MS, + INTERACTIVE_REDRAW_MAX_CHARS +} from './constants' + +export function isLikelyInteractiveRedraw(data: string): boolean { + if (data.length <= INTERACTIVE_OUTPUT_MAX_CHARS) { + return true + } + // Why the ANSI check: Codex-style TUIs repaint >1 KB per keypress (latency-sensitive), while plain command output should stay on the throughput batch path. + return data.length <= INTERACTIVE_REDRAW_MAX_CHARS && data.includes('\x1b[') +} + +export function shouldSendInteractiveOutputNow(id: string, data: string, now: number): boolean { + const lastInputAt = lastInputAtByPty.get(id) + if (lastInputAt === undefined || now - lastInputAt > INTERACTIVE_OUTPUT_WINDOW_MS) { + interactiveOutputCharsByPty.delete(id) + return false + } + if (!isLikelyInteractiveRedraw(data)) { + interactiveOutputCharsByPty.set(id, INTERACTIVE_OUTPUT_BUDGET_CHARS) + return false + } + const usedChars = interactiveOutputCharsByPty.get(id) ?? 0 + if (usedChars + data.length > INTERACTIVE_OUTPUT_BUDGET_CHARS) { + interactiveOutputCharsByPty.set(id, INTERACTIVE_OUTPUT_BUDGET_CHARS) + return false + } + interactiveOutputCharsByPty.set(id, usedChars + data.length) + return true +} diff --git a/src/main/ipc/pty/delivery/lifecycle-reset.ts b/src/main/ipc/pty/delivery/lifecycle-reset.ts new file mode 100644 index 00000000000..961c09188a1 --- /dev/null +++ b/src/main/ipc/pty/delivery/lifecycle-reset.ts @@ -0,0 +1,96 @@ +import type { WebContents } from 'electron' +import { + didFinishLoadHandler, + didFinishLoadWebContents, + rendererDidStartNavigationHandler, + rendererGateResetGoneHandler, + rendererGateResetLoadHandler, + rendererGateResetWebContents, + rendererLifecycleResetHandler, + rendererLifecycleResetWebContents, + setDidFinishLoadHandler, + setRendererGateResetState, + setRendererLifecycleResetState +} from '../provider/listener-lifecycle' +import { mainDeliveryBreadcrumbs, resetRendererDeliveryAccountingForLifecycleReset } from './debug' +import { + activeRendererPtys, + invalidatePendingPtyDrainPriority, + visibleRendererPtys +} from './visibility-state' + +export function clearDidFinishLoadHandler(): void { + if (didFinishLoadHandler && didFinishLoadWebContents) { + didFinishLoadWebContents.removeListener('did-finish-load', didFinishLoadHandler) + } + setDidFinishLoadHandler(null, null) +} + +export function markRendererPtysHiddenForRendererLifecycleReset(): void { + // A reload/crash in the breadcrumb history is load-bearing context for any freeze report. + mainDeliveryBreadcrumbs.record('renderer-lifecycle-reset') + // Why: renderer-owned hints die with the page; clear visibility so surviving daemon/SSH PTYs fail closed until the new renderer reports. + const activePriorityChanged = activeRendererPtys.size > 0 + activeRendererPtys.clear() + visibleRendererPtys.clear() + // Why: the dead page never ACKs its in-flight bytes, so leaked accounting would delivery-gate surviving PTYs forever after a reload/crash. + resetRendererDeliveryAccountingForLifecycleReset() + if (activePriorityChanged) { + invalidatePendingPtyDrainPriority() + } +} + +export function clearRendererLifecycleResetHandlers(): void { + if (!rendererLifecycleResetWebContents) { + return + } + if (rendererDidStartNavigationHandler) { + rendererLifecycleResetWebContents.removeListener( + 'did-start-navigation', + rendererDidStartNavigationHandler + ) + } + if (rendererLifecycleResetHandler) { + rendererLifecycleResetWebContents.removeListener( + 'render-process-gone', + rendererLifecycleResetHandler + ) + rendererLifecycleResetWebContents.removeListener('destroyed', rendererLifecycleResetHandler) + } + setRendererLifecycleResetState({ contents: null, handler: null, navigation: null }) +} + +export function registerRendererLifecycleResetHandlers(webContents: WebContents): void { + clearRendererLifecycleResetHandlers() + markRendererPtysHiddenForRendererLifecycleReset() + const handler = markRendererPtysHiddenForRendererLifecycleReset + const navigationHandler = (details: { isMainFrame: boolean; isSameDocument: boolean }) => { + if (!details.isMainFrame || details.isSameDocument) { + return + } + markRendererPtysHiddenForRendererLifecycleReset() + } + setRendererLifecycleResetState({ + contents: webContents, + handler, + navigation: navigationHandler + }) + webContents.on('did-start-navigation', navigationHandler) + webContents.on('render-process-gone', handler) + webContents.on('destroyed', handler) +} + +export function clearRendererGateResetHandlers(): void { + if (rendererGateResetWebContents) { + if (rendererGateResetLoadHandler) { + rendererGateResetWebContents.removeListener('did-finish-load', rendererGateResetLoadHandler) + } + if (rendererGateResetGoneHandler) { + rendererGateResetWebContents.removeListener( + 'render-process-gone', + rendererGateResetGoneHandler + ) + } + } + setRendererGateResetState({ contents: null, load: null, gone: null }) +} diff --git a/src/main/ipc/pty/delivery/payload.ts b/src/main/ipc/pty/delivery/payload.ts new file mode 100644 index 00000000000..119bb173ed7 --- /dev/null +++ b/src/main/ipc/pty/delivery/payload.ts @@ -0,0 +1,137 @@ +import { redactPtyIdForDiagnostics } from '../../../../shared/pty-delivery-diagnostics' +import type { PtyModelRestoreReason } from '../../../../shared/pty-model-restore-marker' +import { mainDeliveryBreadcrumbs } from './debug' +import { recordPtyRendererDeliveryPressure } from './accounting' +import type { PtyDataPayload, PtyIpcSession } from '../session' + +export function makePtyDataPayload( + id: string, + data: string, + startSeq: number | undefined, + containsBackgroundOutput: boolean | undefined, + rawLength = data.length, + transformed = false +): PtyDataPayload { + const payload: PtyDataPayload = { id, data } + if (typeof startSeq === 'number') { + payload.seq = startSeq + rawLength + } + if (typeof startSeq === 'number' || rawLength !== data.length || transformed) { + payload.rawLength = rawLength + } + if (transformed) { + payload.transformed = true + } + if (containsBackgroundOutput === true) { + payload.background = true + } + return payload +} + +export function getPtyPayloadCharCount(payload: { data: string; rawLength?: number }): number { + return Math.max(0, payload.rawLength ?? payload.data.length) +} + +export function sendModelRestoreNeededMarker( + session: PtyIpcSession, + id: string, + reason: PtyModelRestoreReason, + markerSeq: number | undefined +): boolean { + if (session.mainWindow.isDestroyed()) { + return false + } + try { + session.mainWindow.webContents.send('pty:modelRestoreNeeded', { + id, + reason, + ...(typeof markerSeq === 'number' ? { markerSeq } : {}) + }) + } catch (error) { + // Why: a disposed render frame throws synchronously here, and this rides the data path. + console.error('[pty] renderer model-restore marker send failed', error) + return false + } + return true +} + +export function sendPtyDataToRenderer( + session: PtyIpcSession, + id: string, + payload: PtyDataPayload, + projectionAdmissionIds?: readonly string[] +): { sent: boolean; projectionsTransferred: boolean } { + const charCount = getPtyPayloadCharCount(payload) + const accounting = session.rendererDeliveryAccountingByPty.get(id) + const hadAccounting = accounting !== undefined + if (accounting) { + accounting.sentChars += charCount + accounting.lastSendAtMs = Date.now() + } else { + session.rendererDeliveryAccountingByPty.set(id, { + sentChars: charCount, + ackedChars: 0, + lastSendAtMs: Date.now(), + lastAckAtMs: null + }) + } + session.rendererInFlightTotalChars += charCount + recordPtyRendererDeliveryPressure(session, id) + try { + session.mainWindow.webContents.send('pty:data', payload) + } catch (error) { + const current = session.rendererDeliveryAccountingByPty.get(id) + if (current) { + const inFlightBeforeRollback = current.sentChars - current.ackedChars + current.sentChars = Math.max(0, current.sentChars - charCount) + current.ackedChars = Math.min(current.ackedChars, current.sentChars) + const inFlightAfterRollback = current.sentChars - current.ackedChars + session.rendererInFlightTotalChars = Math.max( + 0, + session.rendererInFlightTotalChars - (inFlightBeforeRollback - inFlightAfterRollback) + ) + if (!hadAccounting && current.sentChars === 0) { + session.rendererDeliveryAccountingByPty.delete(id) + } + } + session.rendererDeliveryRestoreNeededPtys.add(id) + if (projectionAdmissionIds) { + session.sshOutputIntake?.transferProjections(projectionAdmissionIds, 'renderer-send-failed') + } + mainDeliveryBreadcrumbs.record('pty-data-send-failed', { + id: redactPtyIdForDiagnostics(id), + chars: charCount + }) + console.error('[pty] renderer data send failed; payload will not be retried', error) + return { sent: false, projectionsTransferred: projectionAdmissionIds !== undefined } + } + let projectionsTransferred = false + if (projectionAdmissionIds) { + try { + session.sshOutputIntake?.publishProjectionPrefix( + projectionAdmissionIds, + payload.data.length, + charCount + ) + } catch { + session.sshOutputIntake?.transferProjections( + projectionAdmissionIds, + 'projection-publish-failed' + ) + projectionsTransferred = true + } + } + if ( + session.rendererDeliveryRestoreNeededPtys.has(id) && + sendModelRestoreNeededMarker( + session, + id, + 'delivery-heal', + session.runtime?.getPtyOutputSequence(id) + ) + ) { + // Why cleared only on a successful send: an unsent marker leaves the restore pending. + session.rendererDeliveryRestoreNeededPtys.delete(id) + } + return { sent: true, projectionsTransferred } +} diff --git a/src/main/ipc/pty/delivery/pending.ts b/src/main/ipc/pty/delivery/pending.ts new file mode 100644 index 00000000000..ff14ccc8c52 --- /dev/null +++ b/src/main/ipc/pty/delivery/pending.ts @@ -0,0 +1,193 @@ +import { extractHiddenStartupRendererQueryData } from '../../../../shared/terminal-reply-query-extraction' +import { + INITIAL_MODE_2031_REPLY_SCAN_STATE, + scanMode2031ReplyDecision, + type Mode2031ReplyScanState +} from '../../../../shared/terminal-color-scheme-protocol' +import { redactPtyIdForDiagnostics } from '../../../../shared/pty-delivery-diagnostics' +import { recordCrashBreadcrumb } from '../../../crash-reporting/crash-breadcrumb-store' +import { terminalOutputBacklogCapChars } from '../../../../shared/terminal-scrollback-policy' +import { isHiddenPtyDeliveryGateEnabled } from '../../pty-hidden-delivery-gate' +import { + appendPendingProjectionAdmission, + compactPendingProjectionAdmissions, + type PendingProjectionAdmissions +} from '../../pty-pending-projection-admissions' +import type { PendingPtyData } from '../../pty-pending-data-drain-queue' +import { DROPPED_QUERY_SALVAGE_MAX_CHARS } from './constants' +import type { PtyIpcSession } from '../session' + +export function pendingDataCapChars(session: PtyIpcSession): number { + return terminalOutputBacklogCapChars(session.getSettings?.().terminalScrollbackRows) +} + +export function extractDroppedPtyQueryBytes(data: string): string { + if (!data.includes('\x1b')) { + return '' + } + const extracted = extractHiddenStartupRendererQueryData(data, '') + return extracted.statelessQueryData + extracted.statefulQueryData + extracted.oscColorQueryData +} + +export function scanDroppedMode2031Data( + data: string, + previous: Mode2031ReplyScanState +): { data: string; state: Mode2031ReplyScanState } { + const result = scanMode2031ReplyDecision(previous, data) + const decisionData = + result.decision === 'subscribed' + ? '\x1b[?2031h' + : result.decision === 'unsubscribed' + ? '\x1b[?2031l' + : '' + return { data: decisionData, state: result.state } +} + +export function getDroppedMode2031RendererData(pending: PendingPtyData): string { + const state = pending.droppedMode2031ScanState + if (!state) { + return pending.droppedMode2031Data ?? '' + } + const pendingSubscribe = state.pendingSubscribe ? '\x1b[?2031h' : '' + return (pending.droppedMode2031Data ?? '') + pendingSubscribe + state.tail +} + +export function pendingProjectionAdmissionOptions(session: PtyIpcSession) { + return { + isPending: (id: string) => session.sshOutputIntake?.hasUnpublishedProjection(id) ?? false, + transfer: (ids: readonly string[], reason: string) => + session.sshOutputIntake?.transferProjections(ids, reason) + } +} + +export function updatePendingProjectionAdmissions( + pending: PendingPtyData, + state: PendingProjectionAdmissions +): void { + delete pending.projectionAdmissionIds + delete pending.projectionAdmissionsTransferred + if (state.projectionAdmissionIds) { + pending.projectionAdmissionIds = state.projectionAdmissionIds + } + if (state.projectionAdmissionsTransferred) { + pending.projectionAdmissionsTransferred = true + } +} + +export function compactPendingProjectionState( + session: PtyIpcSession, + pending: PendingProjectionAdmissions, + projectionSemanticsId?: string +): PendingProjectionAdmissions { + const options = pendingProjectionAdmissionOptions(session) + const compacted = compactPendingProjectionAdmissions(pending, options) + return projectionSemanticsId + ? appendPendingProjectionAdmission(compacted, projectionSemanticsId, options) + : compacted +} + +export function dropOversizedPendingPtyData( + session: PtyIpcSession, + id: string, + pending: PendingPtyData +): PendingPtyData { + const capChars = pendingDataCapChars(session) + if (pending.droppedOutput === true || pending.data.length <= capChars) { + return pending + } + if (!session.pendingDataDropWarnedPtys.has(id)) { + session.pendingDataDropWarnedPtys.add(id) + console.error( + `[pty] dropped ${pending.data.length} buffered chars for ${redactPtyIdForDiagnostics(id)}: renderer not receiving and per-PTY pending cap exceeded; pane will restore from the main-owned snapshot` + ) + // Why: field visibility for cap tuning (issue #2836 / #7017); no pty id since session ids can embed workspace paths. + recordCrashBreadcrumb('terminal_pending_output_dropped', { + droppedChars: pending.data.length, + capChars + }) + } + if ( + isHiddenPtyDeliveryGateEnabled(session.getSettings?.()) && + !session.pendingOverflowMarkedPtys.has(id) + ) { + session.pendingOverflowMarkedPtys.add(id) + } + session.pendingDroppedChars += pending.data.length + if (pending.projectionAdmissionIds) { + session.sshOutputIntake?.transferProjections(pending.projectionAdmissionIds, 'pending-cap') + } + const mode2031 = scanDroppedMode2031Data(pending.data, INITIAL_MODE_2031_REPLY_SCAN_STATE) + // Why no trimmed content tail: a mid-stream gap would corrupt the pane; the droppedOutput sentinel repaints from the snapshot and realigns by sequence (only query bytes ride along). + return { + data: extractDroppedPtyQueryBytes(pending.data).slice(0, DROPPED_QUERY_SALVAGE_MAX_CHARS), + droppedOutput: true, + droppedMode2031Data: mode2031.data, + droppedMode2031ScanState: mode2031.state + } +} + +export function appendPendingPtyData( + session: PtyIpcSession, + id: string, + existing: PendingPtyData | undefined, + data: string, + startSeq: number | undefined, + preservesSeq: boolean, + containsBackgroundOutput: boolean, + rawLength = data.length, + transformed = false, + projectionSemanticsId?: string +): PendingPtyData { + // Why stay dropped at O(1): once over the cap the restore sentinel supersedes interim bytes; queries still get carved out (bounded) so replies survive the whole episode. + if (existing?.droppedOutput === true) { + if (projectionSemanticsId) { + session.sshOutputIntake?.transferProjections([projectionSemanticsId], 'pending-cap') + } + const mode2031 = scanDroppedMode2031Data( + data, + existing.droppedMode2031ScanState ?? INITIAL_MODE_2031_REPLY_SCAN_STATE + ) + const remainingQueryCapacity = Math.max( + 0, + DROPPED_QUERY_SALVAGE_MAX_CHARS - existing.data.length + ) + const salvaged = extractDroppedPtyQueryBytes(data).slice(0, remainingQueryCapacity) + return { + ...existing, + data: existing.data + salvaged, + droppedMode2031Data: mode2031.data || existing.droppedMode2031Data, + droppedMode2031ScanState: mode2031.state + } + } + const projectionState = compactPendingProjectionState( + session, + existing ?? {}, + projectionSemanticsId + ) + const nextContainsBackgroundOutput = + existing?.containsBackgroundOutput === true || containsBackgroundOutput + if (!existing) { + const pending: PendingPtyData = { + data, + ...(typeof startSeq === 'number' ? { startSeq } : {}), + ...(rawLength !== data.length ? { rawLength } : {}), + ...(transformed ? { transformed: true } : {}), + ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}) + } + updatePendingProjectionAdmissions(pending, projectionState) + return dropOversizedPendingPtyData(session, id, pending) + } + const existingRawLength = existing.rawLength ?? existing.data.length + const next: PendingPtyData = { + data: existing.data + data, + ...(!preservesSeq || existing.transformed || transformed + ? { rawLength: existingRawLength + rawLength, transformed: true as const } + : {}), + ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}) + } + updatePendingProjectionAdmissions(next, projectionState) + if (typeof existing.startSeq === 'number') { + next.startSeq = existing.startSeq + } + return dropOversizedPendingPtyData(session, id, next) +} diff --git a/src/main/ipc/pty/delivery/producer-sync.ts b/src/main/ipc/pty/delivery/producer-sync.ts new file mode 100644 index 00000000000..b718e3cebe7 --- /dev/null +++ b/src/main/ipc/pty/delivery/producer-sync.ts @@ -0,0 +1,64 @@ +import { recordDaemonStreamBacklogEvent } from '../../../daemon/daemon-stream-backlog-probe' +import { tryGetProviderForPty } from '../provider/registry' +import { + PRODUCER_FLOW_CONTROL_ENABLED, + rendererVisibilityKnownPtys, + visibleRendererPtys +} from './visibility-state' +import type { PtyIpcSession } from '../session' + +export function updateProducerFlowControl(session: PtyIpcSession, id: string): void { + if (!PRODUCER_FLOW_CONTROL_ENABLED) { + return + } + if (session.sourceCreditPendingPtys.has(id)) { + if (session.pendingData.get(id)) { + return + } + session.sourceCreditPendingPtys.delete(id) + } + session.producerFlowControl.update(id, session.pendingData.get(id)?.data.length ?? 0) +} + +export function syncPtyBackgroundedDelivery( + session: PtyIpcSession, + id: string, + caller: string +): void { + const background = + session.rendererPtyIsKnownHidden(id) && + !(session.runtime?.hasRawTerminalViewSubscriber?.(id) ?? false) + if (session.backgroundedDeliverySyncByPty.get(id) === background) { + return + } + const provider = tryGetProviderForPty(id) + if (!provider?.setPtyBackgrounded) { + return + } + recordDaemonStreamBacklogEvent('mainBackgroundSync', { + sessionIdSuffix: id.slice(-10), + background, + caller, + known: rendererVisibilityKnownPtys.has(id), + visible: visibleRendererPtys.has(id) + }) + const previous = session.backgroundedDeliverySyncByPty.get(id) + session.backgroundedDeliverySyncByPty.set(id, background) + try { + provider.setPtyBackgrounded(id, background) + } catch (error) { + // Why: a recorded state the provider never took would short-circuit every later sync for this PTY. + if (previous === undefined) { + session.backgroundedDeliverySyncByPty.delete(id) + } else { + session.backgroundedDeliverySyncByPty.set(id, previous) + } + console.error('[pty] setPtyBackgrounded failed; delivery sync state rolled back', error) + } +} + +export function resyncBackgroundedDeliveriesAfterGateReset(session: PtyIpcSession): void { + for (const id of session.backgroundedDeliverySyncByPty.keys()) { + syncPtyBackgroundedDelivery(session, id, 'gate-reset') + } +} diff --git a/src/main/ipc/pty/delivery/ssh-intake.ts b/src/main/ipc/pty/delivery/ssh-intake.ts new file mode 100644 index 00000000000..2f86608fb82 --- /dev/null +++ b/src/main/ipc/pty/delivery/ssh-intake.ts @@ -0,0 +1,101 @@ +import type { IPtyProvider } from '../../../providers/types' +import { SshPtyOutputIntake } from '../../ssh-pty-output-intake' +import { + cancelSshPtySourceDelivery, + installSshPtyOutputIntake, + publishSshPtySourceAck +} from '../../ssh-pty-output-intake-registry' +import { sshProvidersByGeneration } from '../provider/registry' +import { setSshOutputIntakeCleanup, sshOutputIntakeCleanup } from '../provider/listener-lifecycle' +import type { PtyIpcSession } from '../session' + +export function installSessionSshOutputIntake(session: PtyIpcSession): void { + sshOutputIntakeCleanup?.() + session.sshOutputIntake = new SshPtyOutputIntake({ + getModelSequence: (id) => session.runtime?.getPtyOutputSequence(id) ?? 0, + acceptModel: (event, projection) => { + if (!session.runtime) { + throw new Error('SSH PTY output requires the main terminal model') + } + return session.runtime.acceptPtyDataBounded( + event.id, + event.data, + Date.now(), + event.rawLength, + event.transformed, + projection.desktopSpan ? [projection.desktopSpan] : undefined + ) + }, + project: (event, projection) => + session.acceptPtyDataForRenderer( + { + id: event.id, + data: event.data, + sequenceChars: event.rawLength, + transformed: event.transformed + }, + projection.identity.sequenceEnd, + projection + ), + prepareExit: (event) => { + const release = session.preparePtyExitForRenderer(event) + if (!release) { + throw new Error('pty_renderer_exit_in_progress') + } + return release + }, + finalizeExit: (event) => { + session.runtime?.onPtyExit(event.id, event.code, event.ptyIncarnation, { + hostExitConfirmed: true + }) + session.finalizePtyExitForRenderer(event) + }, + pauseProvider: (generation, id) => { + const provider = sshProvidersByGeneration.get(generation) as + | (IPtyProvider & { hasPtyDeliveryPauseAdapter?: () => boolean }) + | undefined + if (!provider?.hasPtyDeliveryPauseAdapter?.()) { + return false + } + provider.pauseProducer?.(id) + return true + }, + resumeProvider: (generation, id) => + sshProvidersByGeneration.get(generation)?.resumeProducer?.(id), + closeProvider: (generation, reason) => { + const provider = sshProvidersByGeneration.get(generation) + ;( + provider as (IPtyProvider & { closeOutputIntake?: (reason: string) => void }) | undefined + )?.closeOutputIntake?.(reason) + }, + resetModelForMigration: (_generation, id) => + session.runtime?.resetPtyModelAfterMigrationFailure(id), + onGenerationClosed: (providerGeneration) => { + for (const id of session.pendingData.keys()) { + const pending = session.pendingData.get(id) + if ( + pending?.projectionAdmissionIds && + session.sshOutputIntake?.hasProjectionFromGeneration( + pending.projectionAdmissionIds, + providerGeneration + ) + ) { + session.pendingData.delete(id) + session.updateProducerFlowControl(id) + session.pendingOverflowMarkedPtys.delete(id) + } + } + sshProvidersByGeneration.delete(providerGeneration) + }, + publishSourceAck: publishSshPtySourceAck, + cancelSourceDelivery: cancelSshPtySourceDelivery + }) + session.runtime?.setRemoteTerminalSourceRangeConsumerHooks?.( + session.sshOutputIntake.getRemoteSourceRangeConsumerHooks() + ) + const cleanupSshOutputIntakeRegistry = installSshPtyOutputIntake(session.sshOutputIntake) + setSshOutputIntakeCleanup(() => { + session.runtime?.setRemoteTerminalSourceRangeConsumerHooks?.(null) + cleanupSshOutputIntakeRegistry() + }) +} diff --git a/src/main/ipc/pty/delivery/visibility-state.ts b/src/main/ipc/pty/delivery/visibility-state.ts new file mode 100644 index 00000000000..dbe9526ae2e --- /dev/null +++ b/src/main/ipc/pty/delivery/visibility-state.ts @@ -0,0 +1,31 @@ +export const SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS = 30_000 +// Why: kill switch — flip to disable producer flow control (pause/resume) without untangling the wiring. +export const PRODUCER_FLOW_CONTROL_ENABLED = true +// Why: mobile clients must mirror desktop PTY geometry even before the renderer can provide an xterm snapshot (e.g. right after tab creation). +export const ptySizes = new Map() +// Why: the "recent user input" signal is PTY-scoped and must be cleared by every teardown path, incl. SSH/daemon shutdowns that skip the local exit listener. +export const lastInputAtByPty = new Map() +export const interactiveOutputCharsByPty = new Map() +export const activeRendererPtys = new Set() +export const visibleRendererPtys = new Set() +export const rendererVisibilityKnownPtys = new Set() +export let invalidatePendingPtyDrainPriority = (_id?: string, _schedule?: boolean): void => {} +export let invalidatePendingPtyDrainPolicy = (_id?: string, _schedule?: boolean): void => {} +export const pendingHiddenRendererResizeOutputPtys = new Set() +export const deliveredHiddenRendererResizeOutputPtys = new Set() +export const KEEP_HISTORY_STOP_SETTLE_MS = 1_000 +export const KEEP_HISTORY_STOP_POLL_MS = 100 +// Why: after daemon keep-tail thinning main's mirror holds only the kept tail, so recovery must keep consulting the daemon's complete model until exit. +export const providerSnapshotRequiredPtys = new Set() + +export function setInvalidatePendingPtyDrainPriority( + fn: (id?: string, schedule?: boolean) => void +): void { + invalidatePendingPtyDrainPriority = fn +} + +export function setInvalidatePendingPtyDrainPolicy( + fn: (id?: string, schedule?: boolean) => void +): void { + invalidatePendingPtyDrainPolicy = fn +} diff --git a/src/main/ipc/pty/delivery/wire-session.ts b/src/main/ipc/pty/delivery/wire-session.ts new file mode 100644 index 00000000000..5425acb9f10 --- /dev/null +++ b/src/main/ipc/pty/delivery/wire-session.ts @@ -0,0 +1,159 @@ +import { resetHiddenRendererPtyDeliveryDebugCounters } from '../../pty-hidden-delivery-gate' +import { + setReadPtyRendererDeliveryDebugSnapshot, + setResetPtyRendererDeliveryDebugSnapshot, + setResetRendererDeliveryAccountingForLifecycleReset, + setClearRendererDispatcherReadyWatchdog +} from './debug' +import { + setInvalidatePendingPtyDrainPolicy, + setInvalidatePendingPtyDrainPriority +} from './visibility-state' +import { setClearBackgroundedDeliverySyncForPty } from '../provider/listener-lifecycle' +import { + applyCumulativeAck, + canSendPtyDataToRenderer, + clearDeliveryResyncProbe, + clearPendingPtyData, + deletePendingPtyData, + getRendererInFlightCharsForPty, + schedulePendingDataAfterCreditReport, + setPendingPtyData, + writeOffLostRendererDelivery +} from './accounting' +import { + readCurrentPtyRendererDeliveryDebugSnapshot, + seedPtyRendererDeliveryPeaksFromCurrentState +} from './debug-snapshot' +import { + armDispatcherReadyWatchdog, + clearDispatcherReadyWatchdog, + flushPendingData, + invalidatePendingPtyDrainClassification, + schedulePendingDataFlush +} from './flush' +import { sendModelRestoreNeededMarker, sendPtyDataToRenderer } from './payload' +import { + resyncBackgroundedDeliveriesAfterGateReset, + syncPtyBackgroundedDelivery, + updateProducerFlowControl +} from './producer-sync' +import { + acceptPtyDataForRenderer, + clearDeliveredHiddenRendererResizeOutput, + clearHiddenRendererResizeOutput, + rendererPtyIsKnownHidden +} from './accept' +import { + consumeSyntheticKillExit, + finalizePtyExitForRenderer, + preparePtyExitForRenderer, + rememberRetiredRejectedPty, + rememberSyntheticKillExit, + sendPtyExitToRenderer, + sendPtySpawnedToRenderer +} from './exit' +import { + transitionHiddenRendererPtyDeliveryState, + transitionSpawnHiddenRendererPtyDeliveryState +} from './hidden-transition' +import { requestSerializedBuffer } from '../ipc/serialize-buffer' +import { shutdownProviderAndDetectExit } from '../provider/shutdown-detect' +import type { PtyIpcSession } from '../session' + +export function wirePtyIpcSession(session: PtyIpcSession): void { + session.canSendPtyDataToRenderer = (id, options) => canSendPtyDataToRenderer(session, id, options) + session.schedulePendingDataFlush = (delayMs) => schedulePendingDataFlush(session, delayMs) + session.flushPendingData = () => flushPendingData(session) + session.sendPtyDataToRenderer = (id, payload, projectionAdmissionIds) => + sendPtyDataToRenderer(session, id, payload, projectionAdmissionIds) + session.sendModelRestoreNeededMarker = (id, reason, markerSeq) => + sendModelRestoreNeededMarker(session, id, reason, markerSeq) + session.updateProducerFlowControl = (id) => updateProducerFlowControl(session, id) + session.applyCumulativeAck = (id, processedChars) => + applyCumulativeAck(session, id, processedChars) + session.readCurrentPtyRendererDeliveryDebugSnapshot = () => + readCurrentPtyRendererDeliveryDebugSnapshot(session) + session.clearDeliveryResyncProbe = () => clearDeliveryResyncProbe(session) + session.clearPendingPtyData = () => clearPendingPtyData(session) + session.deletePendingPtyData = (id) => deletePendingPtyData(session, id) + session.setPendingPtyData = (id, pending) => setPendingPtyData(session, id, pending) + session.clearDispatcherReadyWatchdog = () => clearDispatcherReadyWatchdog(session) + session.armDispatcherReadyWatchdog = () => armDispatcherReadyWatchdog(session) + session.acceptPtyDataForRenderer = (payload, outputSeq, projection) => + acceptPtyDataForRenderer(session, payload, outputSeq, projection) + session.preparePtyExitForRenderer = (payload) => preparePtyExitForRenderer(session, payload) + session.finalizePtyExitForRenderer = (payload) => finalizePtyExitForRenderer(session, payload) + session.sendPtyExitToRenderer = (payload) => sendPtyExitToRenderer(session, payload) + session.sendPtySpawnedToRenderer = (id) => sendPtySpawnedToRenderer(session, id) + session.requestSerializedBuffer = (ptyId, opts) => requestSerializedBuffer(session, ptyId, opts) + session.shutdownProviderAndDetectExit = (provider, id, opts) => + shutdownProviderAndDetectExit(provider, id, opts) + session.rememberSyntheticKillExit = (id) => rememberSyntheticKillExit(session, id) + session.rememberRetiredRejectedPty = (id) => rememberRetiredRejectedPty(session, id) + session.consumeSyntheticKillExit = (id) => consumeSyntheticKillExit(session, id) + session.syncPtyBackgroundedDelivery = (id, caller) => + syncPtyBackgroundedDelivery(session, id, caller) + session.resyncBackgroundedDeliveriesAfterGateReset = () => + resyncBackgroundedDeliveriesAfterGateReset(session) + session.transitionHiddenRendererPtyDeliveryState = (id, hidden) => + transitionHiddenRendererPtyDeliveryState(session, id, hidden) + session.transitionSpawnHiddenRendererPtyDeliveryState = (id, hidden) => + transitionSpawnHiddenRendererPtyDeliveryState(session, id, hidden) + session.rendererPtyIsKnownHidden = rendererPtyIsKnownHidden + session.clearHiddenRendererResizeOutput = clearHiddenRendererResizeOutput + session.clearDeliveredHiddenRendererResizeOutput = clearDeliveredHiddenRendererResizeOutput + session.schedulePendingDataAfterCreditReport = (creditedAny) => + schedulePendingDataAfterCreditReport(session, creditedAny) + session.writeOffLostRendererDelivery = (report) => writeOffLostRendererDelivery(session, report) + session.getRendererInFlightCharsForPty = (id) => getRendererInFlightCharsForPty(session, id) + + setClearBackgroundedDeliverySyncForPty((id: string) => { + session.backgroundedDeliverySyncByPty.delete(id) + }) + if (session.runtime) { + session.runtime.onRemoteTerminalViewPresenceChanged = (id) => + session.syncPtyBackgroundedDelivery(id, 'remote-view') + } + + setReadPtyRendererDeliveryDebugSnapshot(session.readCurrentPtyRendererDeliveryDebugSnapshot) + setResetPtyRendererDeliveryDebugSnapshot(() => { + session.peakPendingChars = 0 + session.peakMaxPendingCharsByPty = 0 + session.peakRendererInFlightChars = 0 + session.peakMaxRendererInFlightCharsByPty = 0 + session.ackGatedFlushSkipCount = 0 + session.pendingDroppedChars = 0 + resetHiddenRendererPtyDeliveryDebugCounters() + seedPtyRendererDeliveryPeaksFromCurrentState(session) + }) + setResetRendererDeliveryAccountingForLifecycleReset(() => { + // Why lossless: pendingData bytes were bound for the dead page; the replacement repaints from main's authoritative sources, which superset it. + session.lastLifecycleResetClearedChars = session.rendererInFlightTotalChars + session.rendererLifecycleResetCount += 1 + // Why release before clearing: pending bytes and credits belonged to the dead page; releasing producer pauses first keeps no shell wedged. + session.producerFlowControl.releaseAll() + session.clearDeliveryResyncProbe() + session.deliveryResyncUnansweredWarnLogged = false + for (const id of session.rendererDeliveryAccountingByPty.keys()) { + session.sshOutputIntake?.transferPtyProjections(id, 'renderer-lifecycle-reset') + } + session.rendererDeliveryAccountingByPty.clear() + session.rendererInFlightTotalChars = 0 + session.clearPendingPtyData() + session.pendingOverflowMarkedPtys.clear() + session.rendererDeliveryRestoreNeededPtys.clear() + // Why hold sends: the reloading page's pty:data listener is gone until it re-registers/handshakes, so bytes would drop into a listener-less page and re-pin the gate. + session.rendererPtyDispatcherReady = false + // Why: arm the self-heal watchdog so a never-arriving handshake can't hold the gate forever; the real handshake cancels it. + session.armDispatcherReadyWatchdog() + }) + // Why the bridge: let a later re-registration cancel this closure's watchdog (armed via a hoisted fn, so this assignment can precede its definition). + setClearRendererDispatcherReadyWatchdog(session.clearDispatcherReadyWatchdog) + setInvalidatePendingPtyDrainPriority((id, schedule) => + invalidatePendingPtyDrainClassification(session, id, schedule) + ) + setInvalidatePendingPtyDrainPolicy((id, schedule) => + invalidatePendingPtyDrainClassification(session, id, schedule) + ) +} diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts new file mode 100644 index 00000000000..569b40052df --- /dev/null +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -0,0 +1,272 @@ +import { join, delimiter } from 'node:path' +import { resolveSetupAgentSequenceLaunchCommand } from '../../../../shared/setup-agent-sequencing' +import { + detectExplicitPiAgentKindFromCommand, + isPiCompatibleAgentType +} from '../../../../shared/pi-agent-kind' +import { applyTerminalGitCredentialPromptGuard } from '../../terminal-git-credential-guard' +import { openCodeHookService } from '../../../opencode/hook-service' +import { mimoCodeHookService } from '../../../mimo/hook-service' +import { agentHookServer } from '../../../agent-hooks/server' +import { wslHookRelayManager } from '../../../agent-hooks/wsl-hook-relay-manager' +import { piTitlebarExtensionService } from '../../../pi/titlebar-extension-service' +import { ensureLinuxTerminalOrcaCliShimDir } from '../../../cli/linux-terminal-orca-cli-shim' +import { stripLegacyTerminalShimEnv } from '../../../pty/legacy-terminal-shim-dir' +import { resolvePathEnvKey, mergePersistedWindowsPath } from '../../../pty/windows-environment-path' +import { resolveCodexShellLaunchPreflightCommand } from '../../../pty/codex-shell-launch-preflight' +import { buildConfiguredProxyEnv } from '../../../../shared/network-proxy' +import type { BuildPtyHostEnvOptions } from './types' +import { readInheritedPath } from './path' +import { stripInheritedOrcaCodexHomeOverride } from './codex-home' +import { + clearPiAgentShadowEnv, + exposePiManagedExtensionEnv, + isMimoLaunchCommand, + resolveMimocodeSourceHome, + resolveOpenCodeSourceConfigDir, + resolvePiAgentSourceDir, + resolveScopedPiAgentSourceDir, + restoreOrStripOverlayEnv +} from './pi-agent' +import { AGENT_HOOK_RUNTIME_ENV_KEYS } from './spawn-env-keys' + +/** + * Mutates `baseEnv` in place with all host-local PTY env vars and returns it. + * + * Do NOT call when `args.connectionId` is set (SSH): every injection is host-loopback + * or references local filesystem paths meaningless to a remote shell. + */ +export function buildPtyHostEnv( + id: string, + baseEnv: Record, + opts: BuildPtyHostEnvOptions +): Record { + mergePersistedWindowsPath(baseEnv) + Object.assign(baseEnv, buildConfiguredProxyEnv(opts.networkProxySettings)) + + // Why: local path's baseEnv includes process.env but the daemon path doesn't (fork inheritance, not IPC); check both sources so guards stay in lock-step across spawn paths. + const preexistingOpenCodeConfigDir = resolveOpenCodeSourceConfigDir(baseEnv) + const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(baseEnv, opts.launchCommand) + const explicitPiAgentKind = isPiCompatibleAgentType(opts.launchAgent) + ? opts.launchAgent + : opts.launchAgent === undefined + ? detectExplicitPiAgentKindFromCommand(launchCommandHint) + : null + const piAgentKind = explicitPiAgentKind ?? 'pi' + const hasLaunchCommand = + typeof launchCommandHint === 'string' && launchCommandHint.trim().length > 0 + + // Why: unattended agents must fail instead of looping on OS credential prompts; user terminals keep normal Git behavior. + applyTerminalGitCredentialPromptGuard(baseEnv, { + launchCommand: launchCommandHint, + isUnattended: opts.launchAgent !== undefined, + deferGitConfigGuardToHost: opts.deferGitConfigGuardToDaemon + }) + + const shouldPrepareOmpShadow = piAgentKind === 'omp' || !hasLaunchCommand + // Why: source shadows are agent-scoped; trusting the other kind's source reintroduces Pi/OMP extension-state shadowing. + const preexistingPiAgentDir = resolvePiAgentSourceDir(baseEnv, 'pi') + const preexistingOmpAgentDir = + piAgentKind === 'omp' + ? resolvePiAgentSourceDir(baseEnv, 'omp') + : resolveScopedPiAgentSourceDir(baseEnv, 'omp') + const preexistingPrimeAgentDir = + piAgentKind === 'prime-agent' + ? resolvePiAgentSourceDir(baseEnv, 'prime-agent') + : resolveScopedPiAgentSourceDir(baseEnv, 'prime-agent') + + if (opts.agentStatusHooksEnabled) { + // Why: OPENCODE_CONFIG_DIR is a single path, not a colon-list; mirror the user's value into an overlay so their plugins and Orca's status plugin coexist. See docs/opencode-config-dir-collision.md. + Object.assign(baseEnv, openCodeHookService.buildPtyEnv(id, preexistingOpenCodeConfigDir)) + if (baseEnv.OPENCODE_CONFIG_DIR) { + // Why: ~/.zshrc can re-export the user's default after spawn; shell-ready wrappers restore this PTY-scoped value. + baseEnv.ORCA_OPENCODE_CONFIG_DIR = baseEnv.OPENCODE_CONFIG_DIR + if (preexistingOpenCodeConfigDir) { + // Why: nested Orca terminals inherit the overlay as OPENCODE_CONFIG_DIR; keep the real source so overlays don't mirror overlays. + baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR = preexistingOpenCodeConfigDir + } else { + delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR + } + } + if (isMimoLaunchCommand(launchCommandHint)) { + const preexistingMimocodeHome = resolveMimocodeSourceHome(baseEnv) + Object.assign(baseEnv, mimoCodeHookService.buildPtyEnv(id, preexistingMimocodeHome)) + if (baseEnv.MIMOCODE_HOME) { + baseEnv.ORCA_MIMOCODE_HOME = baseEnv.MIMOCODE_HOME + if (preexistingMimocodeHome) { + baseEnv.ORCA_MIMOCODE_SOURCE_HOME = preexistingMimocodeHome + } else { + delete baseEnv.ORCA_MIMOCODE_SOURCE_HOME + } + } + } + } else { + restoreOrStripOverlayEnv(baseEnv, { + primary: 'OPENCODE_CONFIG_DIR', + overlay: 'ORCA_OPENCODE_CONFIG_DIR', + source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR' + }) + restoreOrStripOverlayEnv(baseEnv, { + primary: 'MIMOCODE_HOME', + overlay: 'ORCA_MIMOCODE_HOME', + source: 'ORCA_MIMOCODE_SOURCE_HOME' + }) + } + + // Why: strip inherited hook coordinates before injecting this PTY's fresh loopback receiver, so nested-terminal callbacks route to the owning pane. + for (const key of AGENT_HOOK_RUNTIME_ENV_KEYS) { + delete baseEnv[key] + } + if (opts.agentStatusHooksEnabled) { + Object.assign(baseEnv, agentHookServer.buildPtyEnv()) + if (opts.isWsl === true) { + // Why: hook POSTs to 127.0.0.1 die inside WSL's NAT namespace; use the guest-resident relay's endpoint instead of the Windows one. + const distro = opts.wslDistro ?? null + wslHookRelayManager.ensureForDistro(distro) + const guestEndpoint = wslHookRelayManager.getGuestEndpointFilePath(distro) + if (guestEndpoint) { + baseEnv.ORCA_AGENT_HOOK_ENDPOINT = guestEndpoint + } + // Why: OpenCode loads its status plugin from a guest config overlay, so point OPENCODE_CONFIG_DIR at the guest dir the relay materialized. + const opencodeOverlayDir = wslHookRelayManager.getOpenCodeOverlayDir(distro) + if (opencodeOverlayDir) { + baseEnv.OPENCODE_CONFIG_DIR = opencodeOverlayDir + baseEnv.ORCA_OPENCODE_CONFIG_DIR = opencodeOverlayDir + delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR + } else { + // Why: relay not connected yet (or older guest bundle) — never cross the Windows overlay path into WSL; drop it so in-guest OpenCode uses its own config (pre-fix behavior, no status but no regression). + delete baseEnv.OPENCODE_CONFIG_DIR + delete baseEnv.ORCA_OPENCODE_CONFIG_DIR + delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR + } + } + } + + // Why: PI_CODING_AGENT_DIR is the user's config/session root; install only Orca-owned extension files, don't override it. + if (opts.agentStatusHooksEnabled) { + clearPiAgentShadowEnv(baseEnv, 'pi') + clearPiAgentShadowEnv(baseEnv, 'omp') + clearPiAgentShadowEnv(baseEnv, 'prime-agent') + // Why: bare shells historically defaulted to Pi + OMP shadow prep and + // created ~/./agent even when the user never launches those agents + // (#10196). Only create default homes on an explicit Pi/OMP launch; + // otherwise install only into an existing agent dir (or userData for OMP + // status so a typed `omp` still gets the shell wrapper extension). + if (piAgentKind === 'pi') { + const piEnv = piTitlebarExtensionService.buildPtyEnv(id, preexistingPiAgentDir, 'pi', { + materializeDefaultHome: explicitPiAgentKind === 'pi' + }) + Object.assign(baseEnv, piEnv) + exposePiManagedExtensionEnv(baseEnv, 'pi', piEnv) + } + + if (shouldPrepareOmpShadow) { + const ompEnv = piTitlebarExtensionService.buildPtyEnv(id, preexistingOmpAgentDir, 'omp', { + materializeDefaultHome: explicitPiAgentKind === 'omp' + }) + Object.assign(baseEnv, ompEnv) + exposePiManagedExtensionEnv(baseEnv, 'omp', ompEnv) + } + + if (piAgentKind === 'prime-agent' && !opts.isWsl) { + const primeEnv = piTitlebarExtensionService.buildPtyEnv( + id, + preexistingPrimeAgentDir, + 'prime-agent', + { materializeDefaultHome: explicitPiAgentKind === 'prime-agent' } + ) + Object.assign(baseEnv, primeEnv) + exposePiManagedExtensionEnv(baseEnv, 'prime-agent', primeEnv) + } + } else { + // Why: nested PTYs must not inherit stale source or overlay state from another agent. + restoreOrStripOverlayEnv(baseEnv, { + primary: 'PI_CODING_AGENT_DIR', + overlay: 'ORCA_PI_CODING_AGENT_DIR', + source: 'ORCA_PI_SOURCE_AGENT_DIR' + }) + restoreOrStripOverlayEnv(baseEnv, { + primary: 'PI_CODING_AGENT_DIR', + overlay: 'ORCA_OMP_CODING_AGENT_DIR', + source: 'ORCA_OMP_SOURCE_AGENT_DIR' + }) + delete baseEnv.ORCA_OMP_STATUS_EXTENSION + delete baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR + delete baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION + } + + // Why: keep the Codex home override PTY-scoped so dev/prod Orcas don't share hooks through ~/.codex. + if (opts.skipCodexHomeEnv) { + delete baseEnv.CODEX_HOME + delete baseEnv.ORCA_CODEX_HOME + delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT + } else if (opts.selectedCodexHomePath) { + baseEnv.CODEX_HOME = opts.selectedCodexHomePath + // Why: user startup files may re-export CODEX_HOME; shell-ready wrappers restore this runtime home before Codex launches. + baseEnv.ORCA_CODEX_HOME = opts.selectedCodexHomePath + const preflightCommand = resolveCodexShellLaunchPreflightCommand({ + hooksEnabled: opts.codexStatusHooksEnabled ?? opts.agentStatusHooksEnabled, + isPackaged: opts.isPackaged, + isWsl: opts.isWsl, + managedHomePath: opts.selectedCodexHomePath, + userDataPath: opts.userDataPath, + resourcesPath: opts.resourcesPath + }) + if (preflightCommand) { + baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT = preflightCommand + } else { + delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT + } + } else if (opts.stripInheritedOrcaCodexHome) { + stripInheritedOrcaCodexHomeOverride(baseEnv) + delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT + } else { + delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT + } + + // Why: WSL shells need the managed userData root for shell-ready wrappers; dev-mode terminals need the same export so `orca` targets the live dev instance. + if (opts.isWsl) { + baseEnv.ORCA_USER_DATA_PATH = opts.userDataPath + // Why: managed WSL registration uses `orca-ide`; exposing that literal scopes agent guidance to WSL without a bare-orca shim. + baseEnv.ORCA_CLI_COMMAND = opts.isPackaged ? 'orca-ide' : 'orca-dev' + } else { + if (!opts.isPackaged) { + baseEnv.ORCA_USER_DATA_PATH ??= opts.userDataPath + } + delete baseEnv.ORCA_CLI_COMMAND + } + // Why: dev mode needs the launcher PATH override so `orca` resolves to the dev build instead of the production binary at /usr/local/bin/orca. + if (!opts.isPackaged) { + const devCliBin = join(opts.userDataPath, 'cli', 'bin') + const inheritedPath = readInheritedPath(baseEnv) + // Why: an empty PATH segment resolves as `.` in some shells (commands run from cwd); avoid a trailing delimiter. + baseEnv[resolvePathEnvKey(baseEnv, process.platform)] = inheritedPath + ? `${devCliBin}${delimiter}${inheritedPath}` + : devCliBin + } else if (process.platform === 'linux') { + // Why: bare-`orca` shim scoped to Orca PTYs — Linux CLI installs as `orca-ide` to avoid shadowing GNOME's /usr/bin/orca screen reader (stablyai/orca#7904). + const shimDir = ensureLinuxTerminalOrcaCliShimDir({ userDataPath: opts.userDataPath }) + if (shimDir) { + const inheritedEntries = readInheritedPath(baseEnv) + .split(delimiter) + .filter((entry) => entry.length > 0 && entry !== shimDir) + baseEnv.PATH = [shimDir, ...inheritedEntries].join(delimiter) + } + } else if ( + opts.resourcesPath && + (process.platform === 'darwin' || process.platform === 'win32') + ) { + // Why: global CLI registration is optional, but agents in Orca-managed PTYs must always reach this app's bundled CLI. + const bundledCliBin = join(opts.resourcesPath, 'bin') + const inheritedPath = readInheritedPath(baseEnv) + baseEnv[resolvePathEnvKey(baseEnv, process.platform)] = inheritedPath + ? `${bundledCliBin}${delimiter}${inheritedPath}` + : bundledCliBin + } + + // Why: must run after the prepends above — they re-read PATH from the unscrubbed + // process.env when baseEnv carries none, which is the daemon path's normal shape. + stripLegacyTerminalShimEnv(baseEnv, process.platform) + + return baseEnv +} diff --git a/src/main/ipc/pty/host-env/codex-home.ts b/src/main/ipc/pty/host-env/codex-home.ts new file mode 100644 index 00000000000..ae825e31e78 --- /dev/null +++ b/src/main/ipc/pty/host-env/codex-home.ts @@ -0,0 +1,301 @@ +import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' +import { isAgentStatusHooksEnabled } from '../../../agent-hooks/managed-agent-hook-controls' +import { + isCodexHomeAuthReadyForLaunch, + waitForManagedCodexAuthReady +} from '../../../codex-accounts/managed-codex-auth-readiness' +import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' +import { + forgetCodexPaneAccount, + getCodexPaneAccount, + recordCodexPaneAccount, + type CodexPaneHomeRoute +} from '../../../codex/codex-pane-account-registry' +import { resolveCodexPaneLaunchAccount } from '../../../codex/codex-pane-launch-account' +import { getSystemCodexHomePath } from '../../../codex/codex-home-paths' +import { + environmentCodexHomeOverrideContextsEqual, + getCustomCodexHomeOverrideForLaunch, + shellStartupCodexHomeOverrideContextsEqual +} from '../../../codex/codex-real-home-path' +import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../../../pty/codex-home-wsl-env' +import { isWslShellName } from '../../../../shared/local-windows-terminal-runtime' +import { parseWslPath } from '../../../wsl' + +export function shouldSkipCodexHomeEnvForWindowsShell( + shellPath: string | undefined, + cwd: string | undefined +): boolean { + return isWslShellName(shellPath) || (typeof cwd === 'string' && parseWslPath(cwd) !== null) +} + +export function isCodexStatusHooksEnabled(settings: GlobalSettings | undefined): boolean { + return ( + isAgentStatusHooksEnabled(settings) && isTuiAgentEnabled('codex', settings?.disabledTuiAgents) + ) +} + +// Why: with the real-home flag ON, a host system-default launch resolves to a +// null managed home. Signal the env builder to strip a nested-Orca-inherited +// override instead of injecting one, so Codex runs on the user's own ~/.codex. +export function shouldStripInheritedOrcaCodexHome(args: { + target: CodexAccountSelectionTarget + selectedCodexHomePath: string | null + skipCodexHomeEnv: boolean + settings: GlobalSettings | undefined +}): boolean { + return ( + args.target.runtime === 'host' && args.selectedCodexHomePath === null && !args.skipCodexHomeEnv + ) +} + +export const CODEX_HOME_ENV_KEYS = ['CODEX_HOME', 'ORCA_CODEX_HOME'] as const + +// Why: system-default real-home routing runs Codex on the user's own ~/.codex. +// Nested Orca panes inherit the parent's Orca-owned override; strip only that +// (CODEX_HOME matching Orca's private ORCA_CODEX_HOME marker), and always drop +// the marker so a shell-ready wrapper cannot restore the managed home. A +// user-set CODEX_HOME with no Orca marker is preserved untouched (see #8606). +export function stripInheritedOrcaCodexHomeOverride(baseEnv: Record): void { + for (const key of getLocalOrcaCodexHomeEnvKeysToDelete(baseEnv)) { + delete baseEnv[key] + } +} + +// Why: in-process spawns share main's inherited environment, so equality with +// the private marker is authoritative here. Persistent daemons compare locally. +export function getLocalOrcaCodexHomeEnvKeysToDelete(env: Record): string[] { + const inheritedOrcaOverride = env.ORCA_CODEX_HOME ?? process.env.ORCA_CODEX_HOME + const inheritedCodexHome = env.CODEX_HOME ?? process.env.CODEX_HOME + const keysToDelete = ['ORCA_CODEX_HOME'] + if (inheritedOrcaOverride && inheritedCodexHome === inheritedOrcaOverride) { + keysToDelete.push('CODEX_HOME') + } + return keysToDelete +} + +export function getCodexSelectionTargetForPty( + shellPath: string | undefined, + cwd: string | undefined, + wslDistro?: string | null +): CodexAccountSelectionTarget { + const wslPath = typeof cwd === 'string' ? parseWslPath(cwd) : null + if (isWslShellName(shellPath) || wslPath) { + return { runtime: 'wsl', wslDistro: wslPath?.distro ?? wslDistro ?? null } + } + return { runtime: 'host' } +} + +export function getCompatibleSelectedCodexHomePath( + target: CodexAccountSelectionTarget, + selectedCodexHomePath: string | null +): string | null { + if (!selectedCodexHomePath) { + return null + } + const wslInfo = parseWslPath(selectedCodexHomePath) + if (target.runtime === 'wsl') { + return wslInfo || !isHostCodexHomeForWsl(selectedCodexHomePath) ? selectedCodexHomePath : null + } + return wslInfo || (process.platform === 'win32' && isWslCodexHomeForHost(selectedCodexHomePath)) + ? null + : selectedCodexHomePath +} + +export const MANAGED_CODEX_AUTH_UNAVAILABLE_MESSAGE = + 'The selected Codex account credentials are temporarily unavailable. Try opening the terminal again.' +export const CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE = + 'The Codex account credentials for this session are temporarily unavailable. Try opening the terminal again.' + +type ManagedCodexAuthResolutionArgs = { + selectedCodexHomePath: string | null + getSettings: () => GlobalSettings | undefined + requiredCodexHomePath?: string + target: CodexAccountSelectionTarget + resolveCurrent: () => string | null + resolveAfterUnavailable: (unavailableManagedHomePath: string) => string | null +} + +export function resolveCodexHomeAfterManagedAuthReadiness( + args: ManagedCodexAuthResolutionArgs +): string | null | Promise { + const selectedCodexHomePath = args.selectedCodexHomePath + if ( + args.requiredCodexHomePath && + !codexHomePathsEqual(selectedCodexHomePath, args.requiredCodexHomePath) + ) { + throw new Error(CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE) + } + const readiness = waitForManagedCodexAuthReady({ + codexHomePath: selectedCodexHomePath, + settings: args.getSettings(), + target: args.target + }) + return readiness + ? continueCodexHomeAfterManagedAuthWait(args, selectedCodexHomePath, readiness) + : selectedCodexHomePath +} + +async function continueCodexHomeAfterManagedAuthWait( + args: ManagedCodexAuthResolutionArgs, + initialCodexHomePath: string | null, + initialReadiness: Promise +): Promise { + let selectedCodexHomePath = initialCodexHomePath + let readiness = initialReadiness + for (let attempt = 0; attempt < 2; attempt += 1) { + if (await readiness) { + if (args.requiredCodexHomePath) { + return selectedCodexHomePath + } + const currentCodexHomePath = args.resolveCurrent() + if (codexHomeSelectionsEqual(selectedCodexHomePath, currentCodexHomePath)) { + return selectedCodexHomePath + } + selectedCodexHomePath = currentCodexHomePath + if (attempt === 1) { + break + } + const nextReadiness = waitForManagedCodexAuthReady({ + codexHomePath: selectedCodexHomePath, + settings: args.getSettings(), + target: args.target + }) + if (!nextReadiness) { + return selectedCodexHomePath + } + readiness = nextReadiness + continue + } + if (args.requiredCodexHomePath) { + throw new Error(CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE) + } + selectedCodexHomePath = args.resolveAfterUnavailable(selectedCodexHomePath!) + if (attempt === 1) { + break + } + const nextReadiness = waitForManagedCodexAuthReady({ + codexHomePath: selectedCodexHomePath, + settings: args.getSettings(), + target: args.target + }) + if (!nextReadiness) { + return selectedCodexHomePath + } + readiness = nextReadiness + } + if ( + isCodexHomeAuthReadyForLaunch({ + codexHomePath: selectedCodexHomePath, + settings: args.getSettings(), + target: args.target + }) + ) { + return selectedCodexHomePath + } + throw new Error(MANAGED_CODEX_AUTH_UNAVAILABLE_MESSAGE) +} + +export function codexHomeSelectionsEqual(left: string | null, right: string | null): boolean { + return ( + left === right || + (left !== null && + right !== null && + normalizeRuntimePathForComparison(left) === normalizeRuntimePathForComparison(right)) + ) +} + +export function codexHomePathsEqual(left: string | null, right: string): boolean { + return codexHomeSelectionsEqual(left, right) +} + +// Why: CODEX_HOME is fixed in a shell's environment at spawn and the daemon +// keeps that shell alive across app restarts, so the launch account is the only +// way to tell later that a pane still runs Codex as the previously selected +// account. A reattach inherits that baked environment rather than choosing one, +// so re-recording it under the current selection would erase the very evidence +// that the pane is stale. +export function recordCodexPaneAccountForSpawn(args: { + ptyId: string | undefined + isDaemonHostSpawn: boolean + isReattach: boolean + pinnedByResume: boolean + launchCodexHomePath: string | null + launchEnv?: NodeJS.ProcessEnv + target: CodexAccountSelectionTarget + settings: GlobalSettings | undefined +}): void { + if (!args.ptyId || !args.isDaemonHostSpawn || args.isReattach) { + return + } + const customHomeOverride = getCustomCodexHomeOverrideForLaunch(args.launchEnv) + const processHomeOverride = customHomeOverride ? getCustomCodexHomeOverrideForLaunch() : null + const recheckableEnvironmentOverride = + customHomeOverride?.source === 'environment' && + processHomeOverride?.source === 'environment' && + environmentCodexHomeOverrideContextsEqual( + customHomeOverride.context, + processHomeOverride.context + ) + ? customHomeOverride.context + : undefined + const recheckableShellStartupOverride = + customHomeOverride?.source === 'shell-startup' && + processHomeOverride?.source === 'shell-startup' && + shellStartupCodexHomeOverrideContextsEqual( + customHomeOverride.context, + processHomeOverride.context + ) + ? customHomeOverride.context + : undefined + const record = args.settings + ? resolveCodexPaneLaunchAccount({ + pinnedByResume: args.pinnedByResume, + launchCodexHomePath: args.launchCodexHomePath, + // Why: pane-local overrides cannot be re-derived when a restart builds + // a fresh launch env, so route prompts would guess and block valid input. + recordComparableHomeRoute: + args.pinnedByResume || + ((customHomeOverride?.source !== 'environment' || + recheckableEnvironmentOverride !== undefined) && + (customHomeOverride?.source !== 'shell-startup' || + recheckableShellStartupOverride !== undefined)), + shellStartupHomeOverride: args.pinnedByResume ? undefined : recheckableShellStartupOverride, + environmentHomeOverride: args.pinnedByResume ? undefined : recheckableEnvironmentOverride, + systemCodexHomePath: getSystemCodexHomePath(), + settings: args.settings, + target: args.target + }) + : null + if (!record) { + forgetCodexPaneAccount(args.ptyId) + return + } + recordCodexPaneAccount(args.ptyId, record) +} + +export function snapshotCodexPaneHomeRoutes( + ptyIds: readonly (string | null | undefined)[] +): ReadonlyMap { + const routes = new Map() + for (const ptyId of ptyIds) { + if (!ptyId || routes.has(ptyId)) { + continue + } + routes.set(ptyId, getCodexPaneAccount(ptyId)?.homeRoute ?? null) + } + return routes +} + +export function codexReattachedHomeRouteField( + routes: ReadonlyMap, + ptyId: string, + reattached: boolean +): { reattachedHomeRoute?: CodexPaneHomeRoute | null } { + if (!reattached || !routes.has(ptyId)) { + return {} + } + return { reattachedHomeRoute: routes.get(ptyId) ?? null } +} diff --git a/src/main/ipc/pty/host-env/codex-resume.ts b/src/main/ipc/pty/host-env/codex-resume.ts new file mode 100644 index 00000000000..a32aefe9057 --- /dev/null +++ b/src/main/ipc/pty/host-env/codex-resume.ts @@ -0,0 +1,138 @@ +import type { TuiAgent } from '../../../../shared/tui-agent' +import { + normalizeAgentProviderSession, + type AgentProviderSessionMetadata +} from '../../../../shared/agent-session-resume' +import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../../../shared/setup-agent-sequencing' +import { dropAgentResumeArgvFromCommand } from '../../../../shared/agent-resume-argv-drop' +import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' +import { dropUnverifiedCodexResumeArgv } from '../../../codex/codex-unverified-resume-launch' +import type { CodexSessionResumePreparation } from '../../../codex/codex-session-resume-home' +import { CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE, codexHomePathsEqual } from './codex-home' +import type { PrepareCodexSessionResume } from './types' + +export type CodexResumeLaunch = { + codexResumeHome: Extract | null + command: string | undefined + notifyResumeUnavailable: boolean + droppedResumeArgv: boolean + providerSession: AgentProviderSessionMetadata | null +} + +export type PreparedCodexResumeHome = { + providerSession: AgentProviderSessionMetadata + preparation: Promise +} + +export type PrepareCodexResumeHomeArgs = { + connectionId?: string | null + launchAgent?: TuiAgent + providerSession?: AgentProviderSessionMetadata + target: CodexAccountSelectionTarget + launchEnv?: NodeJS.ProcessEnv + workspacePath?: string +} + +export function prepareCodexResumeHome( + prepareCodexSessionResume: PrepareCodexSessionResume | undefined, + args: PrepareCodexResumeHomeArgs +): PreparedCodexResumeHome | null { + if (args.connectionId || args.launchAgent !== 'codex' || !prepareCodexSessionResume) { + return null + } + const providerSession = normalizeAgentProviderSession(args.providerSession) + if (!providerSession) { + return null + } + return { + providerSession, + preparation: prepareCodexSessionResume({ + providerSession, + target: args.target, + launchEnv: args.launchEnv, + workspacePath: args.workspacePath + }) + } +} + +/** Kept separate from resolveCodexResumeLaunch so non-Codex spawns never await: + * an extra tick reorders the pane-spawn reservation races this handler arbitrates. */ +export function noCodexResumeLaunch(command: string | undefined): CodexResumeLaunch { + return { + codexResumeHome: null, + command, + notifyResumeUnavailable: false, + droppedResumeArgv: false, + providerSession: null + } +} + +/** The command a Codex launch actually runs: unchanged when provenance is verified, + * stripped of `resume ` when it is not. */ +export function resolveCodexResumeLaunch( + command: string | undefined, + preparation: PreparedCodexResumeHome +): Promise { + return preparation.preparation.then((prepared) => { + const providerSession = preparation.providerSession + if (prepared?.outcome !== 'fresh') { + return { + codexResumeHome: prepared ?? null, + command, + notifyResumeUnavailable: false, + droppedResumeArgv: false, + providerSession + } + } + const dropped = dropUnverifiedCodexResumeArgv({ + command, + providerSession, + claimedCodexProvenance: prepared.claimedCodexProvenance + }) + return { + codexResumeHome: null, + command: dropped.command, + // Why: staying silent only makes sense for metadata that positively belongs to + // another agent; a resume with no transcript path at all still owes the user a notice. + notifyResumeUnavailable: + dropped.droppedResumeArgv && + (prepared.claimedCodexProvenance || !providerSession.transcriptPath), + droppedResumeArgv: dropped.droppedResumeArgv, + providerSession + } + }) +} + +export function reconcileSharedRuntimeResumeHome( + resumeHome: Extract, + resolveCurrentHome: () => string | null +): string { + if (!resumeHome.reconcileSharedRuntimeAuth) { + return resumeHome.codexHomePath + } + const currentHome = resolveCurrentHome() + if (!codexHomePathsEqual(currentHome, resumeHome.codexHomePath)) { + throw new Error(CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE) + } + return resumeHome.codexHomePath +} + +/** Why: buildPtyHostEnv prefers ORCA_SEQUENCED_STARTUP_COMMAND over the launch command + * and the sequenced wrapper `eval`s it, so a dropped resume argv has to go there too. */ +export function stripSequencedStartupResumeArgv | undefined>( + env: T, + launch: CodexResumeLaunch +): T { + const sequenced = env?.[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV] + if (!env || !sequenced || !launch.droppedResumeArgv || !launch.providerSession) { + return env + } + const drop = dropAgentResumeArgvFromCommand({ + command: sequenced, + agent: 'codex', + providerSession: launch.providerSession + }) + return drop.status === 'dropped' + ? { ...env, [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: drop.command } + : env +} diff --git a/src/main/ipc/pty/host-env/fresh-spawn-routing.ts b/src/main/ipc/pty/host-env/fresh-spawn-routing.ts new file mode 100644 index 00000000000..64034d544c2 --- /dev/null +++ b/src/main/ipc/pty/host-env/fresh-spawn-routing.ts @@ -0,0 +1,56 @@ +import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' +import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree/id' +import type { IPtyProvider } from '../../../providers/types' +import { beginTerminalInstall } from '../../watcher-removal-gate' + +export function isClaudeLaunchCommand(command: string | undefined): boolean { + if (!command) { + return false + } + return /(^|[\s;&|('"`])(?:[^\s;&|('"`]*[\\/])?claude(?:\.cmd|\.exe)?($|[\s;&|)'"`])/i.test( + command + ) +} + +export function routesFreshSpawnsToLocalProvider(provider: IPtyProvider): boolean { + return provider.routesFreshSpawnsToLocalProvider === true +} + +export function recoverFreshSpawnProviderRouting( + provider: IPtyProvider, + connectionId: string | null | undefined, + sessionId: string | undefined, + isNewSession = sessionId === undefined +): Promise | undefined { + if (connectionId || (!isNewSession && sessionId) || !routesFreshSpawnsToLocalProvider(provider)) { + return + } + return provider.recoverFreshSpawnRouting?.() +} + +export function beginPtySpawnForWorktree( + worktreeId: string | undefined, + cwd: string | undefined, + connectionId: string | null | undefined +): () => void { + const worktreePath = worktreeId + ? splitWorktreeIdForFilesystem(worktreeId)?.worktreePath + : undefined + const installPaths = new Map() + for (const candidate of [worktreePath, cwd]) { + if (candidate) { + installPaths.set(normalizeRuntimePathForComparison(candidate), candidate) + } + } + const finishes: (() => void)[] = [] + try { + for (const candidate of installPaths.values()) { + finishes.push(beginTerminalInstall(candidate, connectionId ?? undefined)) + } + } catch (error) { + // Why: worktree ID and cwd can be different roots; release earlier admissions before rejecting. + finishes.toReversed().forEach((finish) => finish()) + throw error + } + return () => finishes.toReversed().forEach((finish) => finish()) +} diff --git a/src/main/ipc/pty/host-env/path.ts b/src/main/ipc/pty/host-env/path.ts new file mode 100644 index 00000000000..226ad8691fe --- /dev/null +++ b/src/main/ipc/pty/host-env/path.ts @@ -0,0 +1,47 @@ +import { delimiter } from 'node:path' +import { isLegacyTerminalShimPathEntry } from '../../../pty/legacy-terminal-shim-dir' +import { resolvePathEnvKey } from '../../../pty/windows-environment-path' + +export function readInheritedPath(baseEnv: Record): string { + const pathKey = resolvePathEnvKey(baseEnv, process.platform) + return baseEnv[pathKey] ?? process.env[pathKey] ?? '' +} + +export function firstPathEntry(pathValue: string | undefined): string | null { + const first = pathValue?.split(delimiter).find((entry) => entry.trim().length > 0) + return first ?? null +} + +export function promoteAgentTeamsShimPath( + env: Record | undefined, + requestedPath: string | undefined +): void { + if (!env?.ORCA_AGENT_TEAMS_TEAM_ID) { + return + } + const shimPath = firstPathEntry(requestedPath) + // Why: requestedPath is captured before buildPtyHostEnv scrubs, so a legacy entry that + // reached the front would be re-prepended here and outlive the scrub. + if (!shimPath || isLegacyTerminalShimPathEntry(shimPath)) { + return + } + const currentPathKey = env.PATH !== undefined || env.Path === undefined ? 'PATH' : 'Path' + const currentPath = env[currentPathKey] ?? '' + const remaining = currentPath + .split(delimiter) + .filter((entry) => entry.length > 0 && entry !== shimPath) + // Why: host env injection prepends Orca's shims; Claude Agent Teams must still resolve our fake tmux before any real tmux. + env[currentPathKey] = [shimPath, ...remaining].join(delimiter) +} + +export function deleteRequestedEnvKeys( + env: Record | undefined, + keys: string[] | undefined +): void { + if (!env || !keys) { + return + } + for (const key of keys) { + delete env[key] + } +} diff --git a/src/main/ipc/pty/host-env/pi-agent.ts b/src/main/ipc/pty/host-env/pi-agent.ts new file mode 100644 index 00000000000..545d04d063d --- /dev/null +++ b/src/main/ipc/pty/host-env/pi-agent.ts @@ -0,0 +1,202 @@ +import { + getCommandTokenPathBasename, + getFirstCommandToken +} from '../../../../shared/command-token-scanner' +import { + PRIMARY_AGENT_DIR_ENV_BY_KIND, + SOURCE_AGENT_DIR_ENV_BY_KIND, + type PiAgentKind +} from '../../../../shared/pi-agent-kind' +import { readSessionShellStartupEnvVar } from '../../../pty/shell-startup-env' +import { AGENT_HOOK_RUNTIME_ENV_KEYS, CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS } from './spawn-env-keys' + +export function readEnvWithProcessFallback( + baseEnv: Record, + key: string +): string | undefined { + return baseEnv[key] ?? process.env[key] +} + +export function resolvePiAgentSourceDir( + baseEnv: Record, + kind: PiAgentKind +): string | undefined { + const sourceKey = SOURCE_AGENT_DIR_ENV_BY_KIND[kind] + const primaryKey = PRIMARY_AGENT_DIR_ENV_BY_KIND[kind] + + const sourceDir = readEnvWithProcessFallback(baseEnv, sourceKey) + if (sourceDir) { + return sourceDir + } + + if (kind === 'prime-agent') { + return ( + readEnvWithProcessFallback(baseEnv, primaryKey) ?? + readSessionShellStartupEnvVar(primaryKey, baseEnv) + ) + } + + const overlayKey = kind === 'omp' ? 'ORCA_OMP_CODING_AGENT_DIR' : 'ORCA_PI_CODING_AGENT_DIR' + const otherOverlayKey = kind === 'omp' ? 'ORCA_PI_CODING_AGENT_DIR' : 'ORCA_OMP_CODING_AGENT_DIR' + + const publicDir = readEnvWithProcessFallback(baseEnv, primaryKey) + const ownOverlayDir = readEnvWithProcessFallback(baseEnv, overlayKey) + const otherOverlayDir = readEnvWithProcessFallback(baseEnv, otherOverlayKey) + // Why: if PI_CODING_AGENT_DIR is a restored Orca overlay with no source shadow, remirroring leaks another agent's overlay tree; fall through to defaults. + if (publicDir && publicDir !== ownOverlayDir && publicDir !== otherOverlayDir) { + return publicDir + } + + return readSessionShellStartupEnvVar(primaryKey, baseEnv) +} + +export function resolveScopedPiAgentSourceDir( + baseEnv: Record, + kind: PiAgentKind +): string | undefined { + return readEnvWithProcessFallback(baseEnv, SOURCE_AGENT_DIR_ENV_BY_KIND[kind]) +} + +export function clearPiAgentShadowEnv(baseEnv: Record, kind: PiAgentKind): void { + if (kind === 'omp') { + delete baseEnv.ORCA_OMP_CODING_AGENT_DIR + delete baseEnv.ORCA_OMP_SOURCE_AGENT_DIR + delete baseEnv.ORCA_OMP_STATUS_EXTENSION + return + } + if (kind === 'prime-agent') { + delete baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR + delete baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION + return + } + delete baseEnv.ORCA_PI_CODING_AGENT_DIR + delete baseEnv.ORCA_PI_SOURCE_AGENT_DIR +} + +export function exposePiManagedExtensionEnv( + baseEnv: Record, + kind: PiAgentKind, + managedEnv: Record +): void { + if (kind === 'omp') { + delete baseEnv.ORCA_OMP_CODING_AGENT_DIR + if (managedEnv.ORCA_OMP_SOURCE_AGENT_DIR) { + baseEnv.ORCA_OMP_SOURCE_AGENT_DIR = managedEnv.ORCA_OMP_SOURCE_AGENT_DIR + } else { + delete baseEnv.ORCA_OMP_SOURCE_AGENT_DIR + } + if (managedEnv.ORCA_OMP_STATUS_EXTENSION) { + baseEnv.ORCA_OMP_STATUS_EXTENSION = managedEnv.ORCA_OMP_STATUS_EXTENSION + } else { + delete baseEnv.ORCA_OMP_STATUS_EXTENSION + } + return + } + if (kind === 'prime-agent') { + if (managedEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR) { + baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR = managedEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR + } else { + delete baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR + } + return + } + delete baseEnv.ORCA_PI_CODING_AGENT_DIR + if (managedEnv.ORCA_PI_SOURCE_AGENT_DIR) { + baseEnv.ORCA_PI_SOURCE_AGENT_DIR = managedEnv.ORCA_PI_SOURCE_AGENT_DIR + } else { + delete baseEnv.ORCA_PI_SOURCE_AGENT_DIR + } +} + +// Why: variadic because a nested call per source made intermediate `string[] | undefined` collide with the parameter type. +export function mergePtyEnvDeletions( + existingKeys: string[] | undefined, + ...additionalKeyGroups: readonly (readonly string[])[] +): string[] | undefined { + if (!existingKeys && additionalKeyGroups.every((keys) => keys.length === 0)) { + return undefined + } + return Array.from(new Set([...(existingKeys ?? []), ...additionalKeyGroups.flat()])) +} + +export function removeCodexHomeDeletionRequests(keys: string[] | undefined): string[] | undefined { + // Why: resume provenance is launch-authoritative; late deletions must not fall back to the current account. + const filtered = keys?.filter((key) => key !== 'CODEX_HOME' && key !== 'ORCA_CODEX_HOME') + return filtered?.length ? filtered : undefined +} + +export function getInheritedAgentHookEnvKeysToDelete( + spawnEnv: Record | undefined +): string[] { + const env = spawnEnv ?? {} + // Why: providers merge process.env after cleanup; delete stale hook keys without dropping fresh coordinates buildPtyHostEnv set. + return AGENT_HOOK_RUNTIME_ENV_KEYS.filter((key) => env[key] === undefined) +} + +export function getInheritedClaudeSessionStampEnvKeysToDelete( + spawnEnv: Record | undefined +): string[] { + const env = spawnEnv ?? {} + // Why: strip only values inherited from the pty host; a caller that explicitly + // provides a stamp (deliberately spawning a nested Claude child) keeps it. + return CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS.filter((key) => env[key] === undefined) +} + +// Why: a nested terminal can inherit prior OpenCode/Pi/OMP overlay env; restore the user's recorded source dir, else strip only Orca-owned values. +export function restoreOrStripOverlayEnv( + baseEnv: Record, + keys: { + primary: string + overlay: string + source: string + } +): void { + const sourceValue = baseEnv[keys.source] ?? process.env[keys.source] + const overlayValue = baseEnv[keys.overlay] ?? process.env[keys.overlay] + if (sourceValue) { + baseEnv[keys.primary] = sourceValue + } else if (overlayValue && baseEnv[keys.primary] === overlayValue) { + delete baseEnv[keys.primary] + } + delete baseEnv[keys.overlay] + delete baseEnv[keys.source] +} + +export function isMimoLaunchCommand(launchCommand: string | undefined): boolean { + const binary = getCommandTokenPathBasename(getFirstCommandToken(launchCommand ?? '')) + .toLowerCase() + .replace(/\.(?:cmd|exe|sh)$/, '') + return binary === 'mimo' +} + +export function resolveMimocodeSourceHome(baseEnv: Record): string | undefined { + const sourceHome = baseEnv.ORCA_MIMOCODE_SOURCE_HOME ?? process.env.ORCA_MIMOCODE_SOURCE_HOME + if (sourceHome) { + return sourceHome + } + const configHome = baseEnv.MIMOCODE_HOME ?? process.env.MIMOCODE_HOME + const orcaHome = baseEnv.ORCA_MIMOCODE_HOME ?? process.env.ORCA_MIMOCODE_HOME + if (configHome && orcaHome && configHome === orcaHome) { + return undefined + } + return configHome +} + +export function resolveOpenCodeSourceConfigDir( + baseEnv: Record +): string | undefined { + const sourceDir = + baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR ?? process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR + if (sourceDir) { + return sourceDir + } + + const configDir = baseEnv.OPENCODE_CONFIG_DIR ?? process.env.OPENCODE_CONFIG_DIR + const orcaConfigDir = baseEnv.ORCA_OPENCODE_CONFIG_DIR ?? process.env.ORCA_OPENCODE_CONFIG_DIR + // Why: with no recorded source dir, an inherited OPENCODE_CONFIG_DIR is Orca-owned, not user config; treating it as user config makes child Orcas mirror the hook dir. + if (configDir && orcaConfigDir && configDir === orcaConfigDir) { + return undefined + } + + return configDir ?? readSessionShellStartupEnvVar('OPENCODE_CONFIG_DIR', baseEnv) +} diff --git a/src/main/ipc/pty/host-env/spawn-cwd.ts b/src/main/ipc/pty/host-env/spawn-cwd.ts new file mode 100644 index 00000000000..03bac6611be --- /dev/null +++ b/src/main/ipc/pty/host-env/spawn-cwd.ts @@ -0,0 +1,55 @@ +import { statSync } from 'node:fs' +import type { Store } from '../../../persistence' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { isWslUncPath } from '../../../../shared/wsl-paths' +import { + resolveTerminalStartupCwdForWorkspace, + type TerminalStartupCwdMissingDirFallback +} from '../../../../shared/terminal-startup-cwd' +import { + assertFolderWorkspacePathUsable, + getFolderWorkspacePathStatus +} from '../../../project-groups/folder-workspace-path-status' +import { getSshFilesystemProvider } from '../../../providers/ssh-filesystem-dispatch' + +export function assertFolderWorkspacePtyPathUsable( + store: Store | undefined, + worktreeId: string | undefined +): Promise | void { + const workspaceScope = typeof worktreeId === 'string' ? parseWorkspaceKey(worktreeId) : null + if (!store || workspaceScope?.type !== 'folder') { + return + } + return getFolderWorkspacePathStatus( + store, + { scope: 'folder-workspace', folderWorkspaceId: workspaceScope.folderWorkspaceId }, + { getSshFilesystemProvider } + ).then(assertFolderWorkspacePathUsable) +} + +export function resolvePtySpawnStartupCwd( + store: Store | undefined, + worktreeId: string | undefined, + cwd: string | undefined, + missingDirFallback?: TerminalStartupCwdMissingDirFallback +): string | undefined { + return resolveTerminalStartupCwdForWorkspace({ + workspaceId: worktreeId, + requestedCwd: cwd, + missingDirFallback, + resolveFolderWorkspacePath: (folderWorkspaceId) => + store?.getFolderWorkspace(folderWorkspaceId)?.folderPath + }) +} + +export function localStartupCwdDirectoryExists(path: string): boolean { + // Why: Win32 statSync on \\wsl.localhost 9P shares can falsely report ENOENT; defer to the provider's WSL-aware validation. + if (isWslUncPath(path)) { + return true + } + try { + return statSync(path).isDirectory() + } catch { + return false + } +} diff --git a/src/main/ipc/pty/host-env/spawn-env-keys.ts b/src/main/ipc/pty/host-env/spawn-env-keys.ts new file mode 100644 index 00000000000..0cd0b183729 --- /dev/null +++ b/src/main/ipc/pty/host-env/spawn-env-keys.ts @@ -0,0 +1,16 @@ +export const AGENT_HOOK_RUNTIME_ENV_KEYS = [ + 'ORCA_AGENT_HOOK_PORT', + 'ORCA_AGENT_HOOK_TOKEN', + 'ORCA_AGENT_HOOK_ENV', + 'ORCA_AGENT_HOOK_VERSION', + 'ORCA_AGENT_HOOK_ENDPOINT', + // Why: PR 2778 briefly exported this path; keep deleting stale inherited values so older PTYs can't leak the reverted path. + 'ORCA_CLAUDE_AGENT_STATUS_SETTINGS' +] as const + +// Why: Orca never sets these, so an inherited value means a pty host launched from inside a Claude session — Claude reads it as a nested child and silently stops persisting the transcript. +export const CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS = [ + 'CLAUDE_CODE_CHILD_SESSION', + 'CLAUDE_CODE_SESSION_ID', + 'CLAUDE_CODE_BRIDGE_SESSION_ID' +] as const diff --git a/src/main/ipc/pty/host-env/types.ts b/src/main/ipc/pty/host-env/types.ts new file mode 100644 index 00000000000..61ff335b43d --- /dev/null +++ b/src/main/ipc/pty/host-env/types.ts @@ -0,0 +1,73 @@ +import type { TuiAgent } from '../../../../shared/tui-agent' +import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' +import type { NetworkProxySettings } from '../../../../shared/network-proxy' +import type { ClaudeRuntimeAuthPreparation } from '../../../claude-accounts/runtime-auth-service' +import type { ClaudeAccountSelectionTarget } from '../../../claude-accounts/runtime-selection' +import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' +import type { CodexPaneHomeRoute } from '../../../codex/codex-pane-account-registry' +import type { CodexSessionResumePreparation } from '../../../codex/codex-session-resume-home' + +export type BuildPtyHostEnvOptions = { + isPackaged: boolean + resourcesPath?: string + userDataPath: string + selectedCodexHomePath: string | null + skipCodexHomeEnv?: boolean + /** System-default real-home routing (flag ON): inject no managed CODEX_HOME, + * and strip only an inherited Orca-owned override so nested Orca panes do not + * leak the parent's managed home. A user-set CODEX_HOME is preserved. */ + stripInheritedOrcaCodexHome?: boolean + /** Launch command the renderer chose (e.g. 'pi', 'omp', 'claude'); resolves the per-agent + * extension target for Pi/OMP. Undefined for bare shells → defaults to Pi. NEVER infer from + * disk presence (cross-agent shadowing when both dirs exist). */ + launchCommand?: string + /** Trusted agent identity for wrapped commands that cannot be recognized from text. */ + launchAgent?: TuiAgent + isWsl?: boolean + /** Distro for WSL spawns (null = Windows default distro); drives the WSL hook relay + endpoint repoint. Only read when isWsl. */ + wslDistro?: string | null + agentStatusHooksEnabled: boolean + codexStatusHooksEnabled?: boolean + networkProxySettings?: NetworkProxySettings + /** Keep indexed Git config off the sparse daemon wire; the daemon appends guard entries after merging its inherited env. */ + deferGitConfigGuardToDaemon?: boolean +} + +export type CodexHomeLaunchContext = { + workspacePath?: string + launchAgent?: TuiAgent + unavailableManagedHomePath?: string +} + +export type GetSelectedCodexHomePath = ( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv, + launchContext?: CodexHomeLaunchContext +) => string | null + +export type PrepareCodexSessionResume = (args: { + providerSession: AgentProviderSessionMetadata + target: CodexAccountSelectionTarget + launchEnv?: NodeJS.ProcessEnv + workspacePath?: string +}) => Promise + +export type CodexHomePtySpawnedLifecycleArgs = { + id: string + codexHomePath: string | null + reattached?: boolean + reattachedHomeRoute?: CodexPaneHomeRoute | null + launchEnv?: NodeJS.ProcessEnv + startedAt?: Date + startedSequence?: number +} + +let ptyLifecycleSequence = 0 + +export function allocatePtyLifecycleSequence(): number { + return ++ptyLifecycleSequence +} + +export type PrepareClaudeAuth = ( + target?: ClaudeAccountSelectionTarget +) => Promise diff --git a/src/main/ipc/pty/ipc/inspect.ts b/src/main/ipc/pty/ipc/inspect.ts new file mode 100644 index 00000000000..2bb1b6a07e1 --- /dev/null +++ b/src/main/ipc/pty/ipc/inspect.ts @@ -0,0 +1,340 @@ +import { getPtyIpc } from '../../pty-host-bindings' +import type { Store } from '../../../persistence' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { IPtyProvider } from '../../../providers/types' +import { parseAppSshPtyId } from '../../../providers/ssh-pty-id' +import { inspectPtyProviderProcessForRenderer } from '../../../providers/pty-process-inspection' +import { + PtyProcessListAdmission, + visitPtyProcessListingsInBatches +} from '../../../providers/pty-process-list-admission' +import type { PtyListedSession } from '../../../../shared/pty-listed-session' +import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../../../shared/pty-liveness-verdict' +import { ptyOwnership } from '../provider/ownership-state' +import { + getProviderForPty, + hasPtyProviderForInspection, + registeredPtyProviders, + sshProviders, + tryGetProviderForPty +} from '../provider/registry' +import { finishPtyShutdown, isPtyAlreadyGoneError } from '../provider/liveness' +import { ptySizes } from '../delivery/visibility-state' +import { isValidPaneKey } from '../pane/key-state' +import { + declarePendingPaneSerializer, + pendingPtyIdBySerializerGeneration, + rendererSerializerReadiness, + settlePendingPaneSerializer +} from '../pane/serializer-state' + +export function installPtyInspectIpcHandlers(deps: { + store?: Store + runtime?: OrcaRuntimeService + getLocalPtyProviderStartupPromise: (connectionId?: string | null) => Promise | undefined + shutdownProviderAndDetectExit: ( + provider: IPtyProvider, + id: string, + opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } + ) => Promise + rememberSyntheticKillExit: (id: string) => void + sendPtyExitToRenderer: (payload: { id: string; code: number; incarnationId?: string }) => void +}): void { + const ipcMain = getPtyIpc() + const { + store, + runtime, + getLocalPtyProviderStartupPromise, + shutdownProviderAndDetectExit, + rememberSyntheticKillExit, + sendPtyExitToRenderer + } = deps + + ipcMain.handle('pty:kill', async (_event, args: { id: string; keepHistory?: boolean }) => { + if (typeof args?.id !== 'string' || !args.id || args.id.startsWith('remote:')) { + // Why: runtime terminal handles belong to terminal.close; unowned PTY routing could target the local provider. + throw new Error('Invalid PTY provider id') + } + runtime?.markPtyStopRequested?.(args.id) + const ownedConnectionId = ptyOwnership.get(args.id) + const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null + const connectionId = ownedConnectionId ?? parsedSshId?.connectionId + // Why: wait for daemon startup before selecting the local provider, else a fallback shutdown falsely succeeds and orphans a restored daemon PTY (#7742). + const startupPromise = getLocalPtyProviderStartupPromise(connectionId) + if (startupPromise) { + await startupPromise + } + const provider = connectionId ? sshProviders.get(connectionId) : tryGetProviderForPty(args.id) + if (!provider && connectionId) { + // Why: detached SSH PTYs intentionally keep ownership after their + // provider is unregistered; hydrated app-scoped ids can also arrive + // before ownership is rebuilt. Tombstone instead of falling back local. + const incarnationId = finishPtyShutdown(args.id, connectionId, store) + runtime?.markPtyLivenessUnverifiable?.(args.id, SSH_PROVIDER_UNREGISTERED_REASON) + runtime?.onPtyExit(args.id, -1, incarnationId) + rememberSyntheticKillExit(args.id) + sendPtyExitToRenderer({ + id: args.id, + code: -1, + ...(incarnationId ? { incarnationId } : {}) + }) + return + } + const shutdownProvider = provider ?? getProviderForPty(args.id) + let providerExitObserved = false + try { + providerExitObserved = await shutdownProviderAndDetectExit(shutdownProvider, args.id, { + immediate: true, + keepHistory: args.keepHistory ?? false + }) + } catch (err) { + if (!isPtyAlreadyGoneError(err)) { + // Why: a failed shutdown can leave the process alive (SSH relay grace window / local daemon); keep ownership/lease state so the user can retry. + throw err + } + /* session already dead — cleanup below handles the rest */ + } + // Why: some shutdown paths do not emit onExit through the provider listener. + // Explicit cleanup is idempotent and covers already-dead PTYs. + const incarnationId = finishPtyShutdown(args.id, connectionId, store) + if (!providerExitObserved) { + runtime?.onPtyExit(args.id, -1, incarnationId) + rememberSyntheticKillExit(args.id) + sendPtyExitToRenderer({ + id: args.id, + code: -1, + ...(incarnationId ? { incarnationId } : {}) + }) + } + }) + + ipcMain.handle('pty:listSessions', async (): Promise => { + const deduped = new Map() + const admission = new PtyProcessListAdmission() + await visitPtyProcessListingsInBatches( + registeredPtyProviders(), + ({ provider, connectionId }) => + connectionId === null ? provider.listProcesses() : provider.listProcesses().catch(() => []), + ({ provider, connectionId }, sessions) => { + for (const rawSession of sessions) { + const session = admission.admit(rawSession) + // Why: kill actions only send back the PTY id, so rebuild ownership while listing to keep reconnect-discovered remote sessions routed to their provider. + ptyOwnership.set(session.id, connectionId) + deduped.set(session.id, { + id: session.id, + cwd: session.cwd, + title: session.title, + // Why: the renderer's binding map is empty during restore, so ownership is the only + // liveness evidence it has. Absence is authoritative only from a provider that + // serializes claims — otherwise it is 'unknown', never 'absent' (#8459). + agentOwnership: + (session.agentSessionOwners?.length ?? 0) > 0 + ? 'present' + : provider.providesAgentSessionOwnerListings?.(session.id) === true + ? 'absent' + : 'unknown' + }) + } + } + ) + return Array.from(deduped.values()) + }) + + ipcMain.handle( + 'pty:getAuthoritativeBufferSnapshotCapabilities', + async (_event, args: { ids?: unknown }) => { + const ids = Array.isArray(args?.ids) ? args.ids.slice(0, 512) : [] + const hasLocalPtyId = ids.some((value) => { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > 512 || + value.startsWith('remote:') || + parseAppSshPtyId(value) + ) { + return false + } + const ownedConnectionId = ptyOwnership.get(value) + return ownedConnectionId === undefined || ownedConnectionId === null + }) + if (hasLocalPtyId) { + await getLocalPtyProviderStartupPromise() + } + const capabilities: { id: string; authoritative: boolean | null }[] = [] + const seen = new Set() + for (const value of ids) { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > 512 || + seen.has(value) + ) { + continue + } + seen.add(value) + const provider = tryGetProviderForPty(value) + // Resolved providers without the optional method are definitively non-authoritative; null remains retryable. + capabilities.push({ + id: value, + authoritative: + provider === undefined || provider === null + ? null + : provider.canProvideAuthoritativeBufferSnapshot + ? provider.canProvideAuthoritativeBufferSnapshot(value) + : false + }) + } + return capabilities + } + ) + + ipcMain.handle('pty:hasPty', async (_event, args: { id: string }): Promise => { + if (typeof args?.id !== 'string' || args.id.startsWith('remote:')) { + // Why: same routing hazard pty:kill guards against — ptyOwnership never holds + // a runtime terminal handle and parseAppSshPtyId ignores it, so the lookup + // falls through to the local provider and its "not in my table" reads as an + // authoritative dead. That is a fabricated answer about another host's PTY. + return null + } + const ownedConnectionId = ptyOwnership.get(args.id) + const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null + const provider = parsedSshId + ? sshProviders.get(parsedSshId.connectionId) + : tryGetProviderForPty(args.id) + if (!provider?.hasPty) { + return null + } + try { + return provider.hasPty(args.id) + } catch { + // Why: liveness is only allowed to close panes on an authoritative false. + return null + } + }) + + ipcMain.handle( + 'pty:hasChildProcesses', + async (_event, args: { id: string }): Promise => { + if (typeof args?.id !== 'string' || !hasPtyProviderForInspection(args.id)) { + return false + } + return getProviderForPty(args.id).hasChildProcesses(args.id) + } + ) + + ipcMain.handle( + 'pty:getForegroundProcess', + async (_event, args: { id: string }): Promise => { + if (typeof args?.id !== 'string' || !hasPtyProviderForInspection(args.id)) { + return null + } + return getProviderForPty(args.id).getForegroundProcess(args.id) + } + ) + + ipcMain.handle('pty:inspectProcess', async (_event, args: { id: string }) => { + // Why: same routing hazard as pty:hasPty — an unroutable id must read as unavailable, not as a local-provider answer or a raised IPC error. + if ( + typeof args?.id !== 'string' || + !args.id || + args.id.startsWith('remote:') || + !hasPtyProviderForInspection(args.id) + ) { + return { foregroundProcess: null, hasChildProcesses: false, unavailable: true as const } + } + return inspectPtyProviderProcessForRenderer(getProviderForPty(args.id), args.id) + }) + + ipcMain.handle( + 'pty:confirmForegroundProcess', + async (_event, args: { id: string }): Promise => { + if (typeof args?.id !== 'string' || !hasPtyProviderForInspection(args.id)) { + return null + } + const provider = getProviderForPty(args.id) + // Why: the cached foreground API would turn stale process identity into shell/agent authority at a command boundary. + return provider.confirmForegroundProcess?.(args.id) ?? null + } + ) + + // Why: Cmd+D split needs the live shell cwd so the new pane inherits it (not the worktree root); '' means unknown/unresolvable (Windows) → renderer falls through. + ipcMain.handle('pty:getCwd', async (_event, args: { id: string }): Promise => { + try { + return await getProviderForPty(args?.id).getCwd(args.id) + } catch { + return '' + } + }) + + // Why: prefer the provider's APPLIED size over the requested ptySizes so the renderer's resume drift-check can spot a dropped resize; null means "cannot confirm" → re-forward once. + ipcMain.handle( + 'pty:getSize', + async (_event, args: { id: string }): Promise<{ cols: number; rows: number } | null> => { + const provider = tryGetProviderForPty(args?.id) + try { + if (provider?.getAppliedSize) { + // Why: a provider-owned null means it could not verify the applied + // grid; preserve null so the renderer re-forwards instead of trusting + // the requested-size cache that may describe a dropped resize. + return await provider.getAppliedSize(args.id) + } + } catch { + // Fall through to the requested-size cache so a dead daemon/relay can't throw across the IPC boundary. + } + return ptySizes.get(args?.id) ?? null + } + ) + + // Pre-signal handshake handlers (declare→spawn→settle/clear); see docs/mobile-prefer-renderer-scrollback.md and `pendingByPaneKey` above. + ipcMain.handle( + 'pty:declarePendingPaneSerializer', + async (event, args: { paneKey?: unknown }): Promise => { + if (!isValidPaneKey(args?.paneKey)) { + throw new Error('Invalid paneKey') + } + return declarePendingPaneSerializer(args.paneKey, event?.sender) + } + ) + + ipcMain.handle( + 'pty:settlePaneSerializer', + async (_event, args: { paneKey?: unknown; gen?: unknown }): Promise => { + if (!isValidPaneKey(args?.paneKey) || typeof args.gen !== 'number') { + return + } + const ptyId = pendingPtyIdBySerializerGeneration.get(args.gen) + const settledCurrentGeneration = settlePendingPaneSerializer(args.paneKey, args.gen) + // Why: the generation-to-PTY binding survives late teardown of a reused id; paneKey reverse maps may already be gone. + pendingPtyIdBySerializerGeneration.delete(args.gen) + if (settledCurrentGeneration && ptyId) { + rendererSerializerReadiness.markReady(ptyId) + } + } + ) + + ipcMain.handle( + 'pty:clearPendingPaneSerializer', + async (_event, args: { paneKey?: unknown; gen?: unknown }): Promise => { + if (!isValidPaneKey(args?.paneKey) || typeof args.gen !== 'number') { + return + } + settlePendingPaneSerializer(args.paneKey, args.gen) + pendingPtyIdBySerializerGeneration.delete(args.gen) + } + ) + + ipcMain.handle( + 'pty:reportRendererSerializerReady', + async (_event, args: { ptyId?: unknown }): Promise => { + if ( + typeof args?.ptyId !== 'string' || + !args.ptyId.startsWith('remote:') || + args.ptyId.length > 512 + ) { + return + } + // Why: remote-runtime panes skip the local spawn cooperation gate, so their exact PTY id is the only readiness key. + rendererSerializerReadiness.markReady(args.ptyId) + } + ) +} diff --git a/src/main/ipc/pty/ipc/resize-visibility.ts b/src/main/ipc/pty/ipc/resize-visibility.ts new file mode 100644 index 00000000000..d0c376fabc8 --- /dev/null +++ b/src/main/ipc/pty/ipc/resize-visibility.ts @@ -0,0 +1,339 @@ +import { getPtyIpc } from '../../pty-host-bindings' +import type { + PtyDeliveryWriteOff, + PtyRendererDeliveryHealthReply, + PtyRendererDeliveryStateReport +} from '../../../../shared/pty-renderer-delivery-health' +import { redactPtyIdForDiagnostics } from '../../../../shared/pty-delivery-diagnostics' +import { setTerminalViewAttributes } from '../../../runtime/terminal-view-attribute-store' +import { validateTerminalViewAttributes } from '../../../../shared/terminal-view-attributes' +import { + recordHiddenRendererPtyDataDrop, + setRendererPtyDeliveryInterest, + shouldDropHiddenRendererPtyData +} from '../../pty-hidden-delivery-gate' +import { tryGetProviderForPty, closeStartupQueryAuthorityForPty } from '../provider/registry' +import { + activeRendererPtys, + deliveredHiddenRendererResizeOutputPtys, + invalidatePendingPtyDrainPolicy, + invalidatePendingPtyDrainPriority, + pendingHiddenRendererResizeOutputPtys, + ptySizes, + rendererVisibilityKnownPtys, + visibleRendererPtys +} from '../delivery/visibility-state' +import { + mainDeliveryBreadcrumbs, + resetRendererDeliveryAccountingForLifecycleReset +} from '../delivery/debug' +import { PTY_DELIVERY_HEAL_MIN_ACK_SILENCE_MS } from '../delivery/constants' +import { applyCumulativeAck } from '../delivery/accounting' +import { sendModelRestoreNeededMarker } from '../delivery/payload' +import { isMainWindowPtyIpcEvent } from './write-input' +import type { PtyIpcSession } from '../session' + +export function installPtyResizeVisibilityIpc(session: PtyIpcSession): void { + const ipcMain = getPtyIpc() + const { runtime, mainWindow } = session + + // Why: resize is fire-and-forget — ipcMain.on (not .handle) halves IPC traffic by skipping the empty acknowledgement reply. + ipcMain.removeAllListeners('pty:resize') + ipcMain.on('pty:resize', (_event, args: { id: string; cols: number; rows: number }) => { + // Why: after a desktop-fit override change the renderer's safeFit cascade re-measures ALL panes (background ones at full width), so suppress every pty:resize in this window to avoid corrupting PTY dimensions. + if (runtime?.isResizeSuppressed()) { + return + } + // Why: presence-lock defense-in-depth — while a phone or remote-desktop viewer drives the width, host-side resizes must not reach the PTY or its alt-screen grid garbles; load-bearing because the renderer mirror lags one IPC hop. See docs/mobile-presence-lock.md. + const mobileOwnsResize = runtime?.getDriver(args.id).kind === 'mobile' + const remoteDesktopOwnsResize = runtime?.isRemoteDesktopResizeDriven?.(args.id) === true + if (mobileOwnsResize || remoteDesktopOwnsResize) { + if (remoteDesktopOwnsResize) { + runtime?.recordRemoteDesktopHostReclaimTarget(args.id, args.cols, args.rows) + } + return + } + const provider = tryGetProviderForPty(args.id) + if (!provider) { + return + } + const markedHiddenResizeOutput = session.rendererPtyIsKnownHidden(args.id) + if (markedHiddenResizeOutput) { + // Why: alt-screen TUIs repaint on SIGWINCH; a hidden repaint read after switch-back must not masquerade as live output and overwrite the correctly-sized screen. + pendingHiddenRendererResizeOutputPtys.add(args.id) + deliveredHiddenRendererResizeOutputPtys.delete(args.id) + } else if (visibleRendererPtys.has(args.id)) { + // Why: after the stale hidden-resize repaint is observed, the renderer's visible resize pulse owns the next repaint. + session.clearDeliveredHiddenRendererResizeOutput(args.id) + } + try { + provider.resize(args.id, args.cols, args.rows) + } catch { + if (markedHiddenResizeOutput) { + pendingHiddenRendererResizeOutputPtys.delete(args.id) + } + return + } + ptySizes.set(args.id, { cols: args.cols, rows: args.rows }) + runtime?.onExternalPtyResize(args.id, args.cols, args.rows) + }) + + // Why: pty:reportGeometry is a measurement-only sibling of pty:resize — it refreshes the restore-target cache (never resizes) so mobile-fit hold learns real desktop dims even while resize is blocked. See docs/mobile-fit-hold.md. + ipcMain.removeAllListeners('pty:reportGeometry') + ipcMain.on('pty:reportGeometry', (_event, args: { id: string; cols: number; rows: number }) => { + runtime?.recordRendererGeometry(args.id, args.cols, args.rows) + }) + + // Why: fire-and-forget — clears the DaemonPtyAdapter's sticky cold-restore cache after the renderer consumed it; no-op for non-daemon providers. + ipcMain.removeAllListeners('pty:ackColdRestore') + ipcMain.on('pty:ackColdRestore', (_event, args: { id: string }) => { + const provider = tryGetProviderForPty(args.id) + if (provider && 'ackColdRestore' in provider && typeof provider.ackColdRestore === 'function') { + provider.ackColdRestore(args.id) + } + }) + + // Why: renderer ACKs bound main→renderer delivery without stopping PTY ingestion — agent/status consumers still see every chunk via the provider/runtime path. + ipcMain.removeAllListeners('pty:ackData') + ipcMain.on( + 'pty:ackData', + (_event, args: { id: string; charCount?: number; processedChars?: number }) => { + session.lastAckReceivedAtMs = Date.now() + // Why: a live ACK channel means a future unanswered probe is a fresh diagnostic event, not a continuation of the last silent streak. + session.deliveryResyncUnansweredWarnLogged = false + let acknowledged = 0 + if (typeof args.processedChars === 'number' && Number.isFinite(args.processedChars)) { + acknowledged = applyCumulativeAck(session, args.id, Math.max(0, args.processedChars)) + } else { + // Why: tolerate legacy per-chunk delta payloads — dev hot-reload can pair an old renderer with a new main. + const accounting = session.rendererDeliveryAccountingByPty.get(args.id) + const delta = Number.isFinite(args.charCount) ? Math.max(0, args.charCount ?? 0) : 0 + acknowledged = accounting + ? applyCumulativeAck(session, args.id, accounting.ackedChars + delta) + : 0 + } + tryGetProviderForPty(args.id)?.acknowledgeDataEvent(args.id, acknowledged) + session.schedulePendingDataAfterCreditReport(acknowledged > 0) + } + ) + + ipcMain.removeAllListeners('pty:deliveryResyncResponse') + ipcMain.on( + 'pty:deliveryResyncResponse', + (_event, args: { requestId: number; processedCharsByPty: Record }) => { + if ( + session.deliveryResyncOutstandingRequestId === null || + args?.requestId !== session.deliveryResyncOutstandingRequestId + ) { + return + } + session.clearDeliveryResyncProbe() + session.deliveryResyncUnansweredWarnLogged = false + // Why max-merge: the renderer's cumulative totals are authoritative for what it processed, draining exactly the in-flight debt from lost ACKs. + let creditedAny = false + for (const [id, processedChars] of Object.entries(args.processedCharsByPty ?? {})) { + if (typeof processedChars !== 'number' || !Number.isFinite(processedChars)) { + continue + } + const acknowledged = applyCumulativeAck(session, id, Math.max(0, processedChars)) + if (acknowledged > 0) { + creditedAny = true + tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged) + } + } + session.schedulePendingDataAfterCreditReport(creditedAny) + } + ) + + // Why invoke + renderer-initiated: the field wedge (v1.4.121-rc.0) kills every main→renderer push channel while invoke survives, so the resync rides here plus a write-off lane. + ipcMain.removeHandler('pty:reportRendererDeliveryState') + ipcMain.handle( + 'pty:reportRendererDeliveryState', + (_event, args: PtyRendererDeliveryStateReport): PtyRendererDeliveryHealthReply => { + // Extra repair lane for the lost-ACK variant: identical max-merge to the resync response, so a heal is only reached when merging cannot drain. + let creditedAny = false + for (const [id, processedChars] of Object.entries(args?.processedCharsByPty ?? {})) { + if (typeof processedChars !== 'number' || !Number.isFinite(processedChars)) { + continue + } + const acknowledged = applyCumulativeAck(session, id, Math.max(0, processedChars)) + if (acknowledged > 0) { + creditedAny = true + tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged) + } + } + let writtenOff: PtyDeliveryWriteOff[] = [] + // Why the main-side ACK-silence check: requiring main to have also seen no ACK stops a buggy/foreign caller from writing off live delivery. + if ( + args?.heal === true && + session.rendererInFlightTotalChars > 0 && + (session.lastAckReceivedAtMs === null || + Date.now() - session.lastAckReceivedAtMs >= PTY_DELIVERY_HEAL_MIN_ACK_SILENCE_MS) + ) { + writtenOff = session.writeOffLostRendererDelivery(args) + creditedAny ||= writtenOff.length > 0 + } + session.schedulePendingDataAfterCreditReport(creditedAny) + let inFlightPtyCount = 0 + for (const accounting of session.rendererDeliveryAccountingByPty.values()) { + if (accounting.sentChars - accounting.ackedChars > 0) { + inFlightPtyCount++ + } + } + return { + inFlightTotalChars: session.rendererInFlightTotalChars, + inFlightPtyCount, + msSinceLastAck: + session.lastAckReceivedAtMs === null ? null : Date.now() - session.lastAckReceivedAtMs, + ...(writtenOff.length > 0 ? { writtenOff } : {}) + } + } + ) + + // Why: renderer signals its pty:data listener is live; until then sends are held so boot-window bytes can't drop into a listener-less page and pin the gate. + ipcMain.removeAllListeners('pty:rendererDispatcherReady') + ipcMain.on('pty:rendererDispatcherReady', (event) => { + // Why: the reconcile below destructively clears delivery accounting, so a straggler handshake from a dying window must not reset the new window. + if (!isMainWindowPtyIpcEvent(event, mainWindow, mainWindow.webContents)) { + return + } + // Why: a handshake while the gate is already open means a page load whose lifecycle reset was missed; clear the dead page's stale accounting so it can't permanently gate survivors. + if (session.rendererPtyDispatcherReady) { + resetRendererDeliveryAccountingForLifecycleReset() + } + // Why: real handshake landed — cancel the self-heal watchdog so it can't later force-open the gate. + session.clearDispatcherReadyWatchdog() + session.rendererPtyDispatcherReady = true + session.pendingData.reactivateBlocked() + session.schedulePendingDataFlush(0) + }) + + ipcMain.removeAllListeners('pty:setActiveRendererPty') + ipcMain.on('pty:setActiveRendererPty', (_event, args: { id: string; active: boolean }) => { + if (typeof args.id !== 'string' || !args.id) { + return + } + // Why: renderer scheduling hint only — active panes just get first chance at the bounded output reserve; reads/state/notifications continue for inactive terminals. + if (args.active) { + if (activeRendererPtys.has(args.id)) { + return + } + activeRendererPtys.add(args.id) + } else if (!activeRendererPtys.delete(args.id)) { + return + } + invalidatePendingPtyDrainPriority(args.id) + }) + + ipcMain.removeAllListeners('pty:setRendererPtyVisible') + ipcMain.on('pty:setRendererPtyVisible', (_event, args: { id: string; visible: boolean }) => { + if (typeof args.id !== 'string' || !args.id) { + return + } + // Why: data produced while no renderer can see this PTY must keep that origin through batching, even if the user switches back before the flush lands. + rendererVisibilityKnownPtys.add(args.id) + if (args.visible) { + visibleRendererPtys.add(args.id) + closeStartupQueryAuthorityForPty(args.id) + } else { + visibleRendererPtys.delete(args.id) + } + session.syncPtyBackgroundedDelivery(args.id, 'visibility-report') + }) + + ipcMain.removeAllListeners('pty:setHiddenRendererPty') + ipcMain.on('pty:setHiddenRendererPty', (_event, args: { id: string; hidden: boolean }) => { + if (typeof args.id !== 'string' || !args.id) { + return + } + mainDeliveryBreadcrumbs.record(args.hidden === true ? 'gate-mark' : 'gate-unmark', { + id: redactPtyIdForDiagnostics(args.id) + }) + const transition = session.transitionHiddenRendererPtyDeliveryState( + args.id, + args.hidden === true + ) + if (args.hidden === true) { + closeStartupQueryAuthorityForPty(args.id) + // Why: drop bytes queued for a newly hidden PTY instead of holding them under ACK starvation; reveal restores from the snapshot. + const pending = session.pendingData.get(args.id) + if (pending && transition.droppable) { + session.pendingData.delete(args.id) + if (pending.projectionAdmissionIds) { + session.sshOutputIntake?.transferProjections( + pending.projectionAdmissionIds, + 'hidden-drop' + ) + } + session.updateProducerFlowControl(args.id) + session.pendingOverflowMarkedPtys.delete(args.id) + const drop = recordHiddenRendererPtyDataDrop(args.id, pending.data.length) + if (drop.shouldEmitRestoreMarker) { + sendModelRestoreNeededMarker( + session, + args.id, + 'hidden-drop', + runtime?.getPtyOutputSequence(args.id) + ) + } + } + if (transition.policyChanged) { + invalidatePendingPtyDrainPolicy(args.id) + } + session.syncPtyBackgroundedDelivery(args.id, 'gate-mark') + return + } + if (transition.policyChanged) { + invalidatePendingPtyDrainPolicy(args.id) + } + session.syncPtyBackgroundedDelivery(args.id, 'gate-unmark') + // Why: a reload/remount may have replaced the view that latched restore-needed, so re-emit on unhide; a redundant replay is cheap/idempotent, a missed restore corrupts the pane. + if (transition.droppedWhileHidden) { + sendModelRestoreNeededMarker( + session, + args.id, + 'unhide', + runtime?.getPtyOutputSequence(args.id) + ) + } + }) + + ipcMain.removeAllListeners('pty:terminalViewAttributes') + ipcMain.on('pty:terminalViewAttributes', (_event, args: unknown) => { + // Why validate-or-drop: a malformed palette gives a wrong color reply that breaks TUI theme detection worse than the silent-until-first-push default. + const attributes = validateTerminalViewAttributes(args) + if (attributes) { + setTerminalViewAttributes(attributes) + } + }) + + ipcMain.removeAllListeners('pty:setPtyDeliveryInterest') + ipcMain.on('pty:setPtyDeliveryInterest', (_event, args: { id: string; interested: boolean }) => { + if (typeof args.id !== 'string' || !args.id) { + return + } + // Why: any delivery interest suppresses the hidden-delivery gate (raw-byte consumers keep receiving while hidden); not synced to the daemon pacer so interest churn can't un-pace a flood. + const settings = session.getSettings?.() + const wasDroppable = shouldDropHiddenRendererPtyData(args.id, settings) + setRendererPtyDeliveryInterest(args.id, args.interested === true) + if (wasDroppable !== shouldDropHiddenRendererPtyData(args.id, settings)) { + invalidatePendingPtyDrainPolicy(args.id) + } + }) + + ipcMain.removeAllListeners('pty:signal') + ipcMain.on('pty:signal', (_event, args: { id: string; signal: string }) => { + tryGetProviderForPty(args.id) + ?.sendSignal(args.id, args.signal) + .catch(() => {}) + }) + + ipcMain.removeAllListeners('pty:clearBuffer') + ipcMain.on('pty:clearBuffer', (_event, args: { id: string }) => { + // Why: clear PTY-side state (ConPTY/daemon/SSH buffer) so the next prompt repaint doesn't land at a stale cursor row. + tryGetProviderForPty(args.id) + ?.clearBuffer(args.id) + .catch(() => {}) + runtime?.clearHeadlessTerminalBuffer(args.id).catch(() => {}) + }) +} diff --git a/src/main/ipc/pty/ipc/serialize-buffer.ts b/src/main/ipc/pty/ipc/serialize-buffer.ts new file mode 100644 index 00000000000..10011c19aae --- /dev/null +++ b/src/main/ipc/pty/ipc/serialize-buffer.ts @@ -0,0 +1,111 @@ +import { randomUUID } from 'node:crypto' +import { getPtyIpc } from '../../pty-host-bindings' +import { parseTerminalKittyKeyboardFlags } from '../../../../shared/terminal-kitty-keyboard-flags' +import { isMainWindowPtyIpcEvent } from './write-input' +import type { PtyIpcSession, SerializeResult } from '../session' + +export function settleSerializeRequest( + session: PtyIpcSession, + requestId: string, + result: SerializeResult +): void { + const pending = session.pendingSerializeRequests.get(requestId) + if (!pending) { + return + } + clearTimeout(pending.timeout) + session.pendingSerializeRequests.delete(requestId) + pending.resolve(result) +} + +export function installPtySerializeBufferIpc(session: PtyIpcSession): void { + const ipcMain = getPtyIpc() + // Why: one persistent listener with a request-ID dispatch table instead of one per call, so concurrent serialize requests don't trip Node's MaxListeners=10 warning. + ipcMain.on( + 'pty:serializeBuffer:response', + ( + event, + args: { + requestId?: string + snapshot?: { + data?: unknown + cols?: unknown + rows?: unknown + seq?: unknown + lastTitle?: unknown + kittyKeyboardFlags?: unknown + } | null + } + ) => { + // Why: the snapshot seeds terminal restore state, so only the main window may settle it. + if ( + !isMainWindowPtyIpcEvent(event, session.mainWindow, session.mainWindow.webContents) || + typeof args?.requestId !== 'string' + ) { + return + } + const snapshot = args.snapshot + if ( + snapshot && + typeof snapshot.data === 'string' && + typeof snapshot.cols === 'number' && + typeof snapshot.rows === 'number' + ) { + const result: { + data: string + cols: number + rows: number + seq?: number + lastTitle?: string + kittyKeyboardFlags?: number + } = { + data: snapshot.data, + cols: snapshot.cols, + rows: snapshot.rows + } + if (typeof snapshot.seq === 'number' && Number.isFinite(snapshot.seq)) { + result.seq = snapshot.seq + } + if (typeof snapshot.lastTitle === 'string' && snapshot.lastTitle.length > 0) { + result.lastTitle = snapshot.lastTitle + } + // Why gated on seq: without a boundary the flags cannot be reconciled + // against live bytes, so they prove nothing. + const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags(snapshot.kittyKeyboardFlags) + if (result.seq !== undefined && kittyKeyboardFlags !== undefined) { + result.kittyKeyboardFlags = kittyKeyboardFlags + } + settleSerializeRequest(session, args.requestId, result) + } else { + settleSerializeRequest(session, args.requestId, null) + } + } + ) +} + +export function requestSerializedBuffer( + session: PtyIpcSession, + ptyId: string, + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } +): Promise { + if (session.mainWindow.isDestroyed()) { + return Promise.resolve(null) + } + + const requestId = randomUUID() + return new Promise((resolve) => { + const timeout = setTimeout(() => { + settleSerializeRequest(session, requestId, null) + }, 750) + session.pendingSerializeRequests.set(requestId, { resolve, timeout }) + const payload: { + requestId: string + ptyId: string + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + } = { requestId, ptyId } + if (opts) { + payload.opts = opts + } + session.mainWindow.webContents.send('pty:serializeBuffer:request', payload) + }) +} diff --git a/src/main/ipc/pty/ipc/snapshot.ts b/src/main/ipc/pty/ipc/snapshot.ts new file mode 100644 index 00000000000..607b3bfe781 --- /dev/null +++ b/src/main/ipc/pty/ipc/snapshot.ts @@ -0,0 +1,105 @@ +import { getPtyIpc } from '../../pty-host-bindings' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import { tryGetProviderForPty } from '../provider/registry' +import { providerSnapshotRequiredPtys } from '../delivery/visibility-state' +import type { PtyPendingDataDrainQueue } from '../../pty-pending-data-drain-queue' +import { + getPtyRendererDeliveryDebugSnapshot, + installPowerSignalBreadcrumbs, + resetPtyRendererDeliveryDebug, + type PtyRendererDeliveryDebugSnapshot +} from '../delivery/debug' + +function normalizeSnapshotScrollbackRows(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return undefined + } + return Math.max(0, Math.min(50_000, Math.floor(value))) +} + +export function installPtySnapshotIpcHandlers(deps: { + runtime?: OrcaRuntimeService + pendingData: PtyPendingDataDrainQueue +}): void { + const ipcMain = getPtyIpc() + const { runtime, pendingData } = deps + + ipcMain.handle( + 'pty:getMainBufferSnapshot', + async ( + _event, + args: { id?: unknown; opts?: { scrollbackRows?: unknown } } + ): Promise<{ + data: string + frameRestoreAnsi?: string + cols: number + rows: number + cwd?: string | null + lastTitle?: string + seq?: number + pendingDeliveryStartSeq?: number + source?: 'headless' | 'renderer' + alternateScreen?: boolean + scrollbackAnsi?: string + pendingEscapeTailAnsi?: string + kittyKeyboardFlags?: number + } | null> => { + if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) { + return null + } + const scrollbackRows = normalizeSnapshotScrollbackRows(args.opts?.scrollbackRows) + try { + const runtimeSeqBeforeSnapshot = runtime.getPtyOutputSequence(args.id) + const providerSnapshotRequired = providerSnapshotRequiredPtys.has(args.id) + const providerSnapshot = providerSnapshotRequired + ? await tryGetProviderForPty(args.id)?.getBufferSnapshot?.(args.id, { + scrollbackRows + }) + : null + // Why: after a data gap main holds only the retained tail; returning it as a full snapshot would erase older scrollback. + if (providerSnapshotRequired && !providerSnapshot) { + return null + } + const snapshot = + providerSnapshot ?? + (await runtime.serializeHiddenOutputRecoveryBuffer(args.id, { + scrollbackRows + })) + if (!snapshot || typeof snapshot.seq !== 'number') { + return snapshot + } + // Why: the renderer's post-restore dedupe needs this pending-queue bound, or a stale baseline swallows new chunks whose seq sits below the snapshot counter. + const pending = pendingData.get(args.id) + if (pending && typeof pending.startSeq !== 'number') { + // Why: a seq-less backlog cannot be bounded — stay conservative. + return snapshot + } + return { + ...snapshot, + pendingDeliveryStartSeq: Math.min( + pending?.startSeq ?? (providerSnapshot ? runtimeSeqBeforeSnapshot : snapshot.seq), + snapshot.seq + ) + } + } catch { + return null + } + } + ) + + // Why: main owns side effects, so this replay restores title state only — never historical bells/completions (no-attention-replay rule, terminal-side-effect-authority.md). + ipcMain.handle('pty:sideEffectSnapshot', (_event, args: { id: string }) => { + if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) { + return null + } + return runtime.getTerminalSideEffectSnapshot(args.id) + }) + + installPowerSignalBreadcrumbs() + ipcMain.handle('pty:getRendererDeliveryDebugSnapshot', (): PtyRendererDeliveryDebugSnapshot => { + return getPtyRendererDeliveryDebugSnapshot() + }) + ipcMain.handle('pty:resetRendererDeliveryDebug', (): void => { + resetPtyRendererDeliveryDebug() + }) +} diff --git a/src/main/ipc/pty/ipc/spawn-begin.ts b/src/main/ipc/pty/ipc/spawn-begin.ts new file mode 100644 index 00000000000..0b59d347e4d --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-begin.ts @@ -0,0 +1,122 @@ +import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id' +import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' +import type { PtySpawnResult } from '../../../providers/types' +import type { CodexPaneHomeRoute } from '../../../codex/codex-pane-account-registry' +import { createPtySpawnTiming } from '../../pty-spawn-timing' +import { allocatePtyLifecycleSequence } from '../host-env/types' +import { snapshotCodexPaneHomeRoutes } from '../host-env/codex-home' +import { getAppPtyId } from '../provider/registry' +import { + makePaneSpawnReservationKey, + reservePaneSpawn, + paneSpawnReservationsByOwnerKey, + pendingRuntimePaneCreatesByOwnerKey +} from '../pane/spawn-reservation' +import { resolveStablePaneOwner } from '../pane/stable-owner' +import type { PtyIpcSpawnState } from './spawn-state' + +export async function beginPtyIpcSpawn( + ctx: PtyIpcSpawnState +): Promise { + const args = ctx.args + ctx.codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined + ctx.codexHomeLaunchStartedSequence = !args.connectionId + ? allocatePtyLifecycleSequence() + : undefined + const initialLeafId = + typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null + const initialPaneKey = + typeof args.worktreeId === 'string' && + typeof args.tabId === 'string' && + isValidTerminalTabId(args.tabId) && + args.tabId.length <= 512 && + initialLeafId + ? makePaneKey(args.tabId, initialLeafId) + : null + const initialStablePanePtyId = (() => { + try { + return !args.connectionId && initialPaneKey + ? resolveStablePaneOwner( + ctx.deps.runtime, + ctx.deps.store, + initialPaneKey, + args.worktreeId, + args.connectionId + )?.ptyId + : undefined + } catch { + return undefined + } + })() + ctx.reattachedCodexHomeRoutes = !args.connectionId + ? new Map( + snapshotCodexPaneHomeRoutes([ + initialStablePanePtyId, + args.sessionId ? getAppPtyId(args.connectionId, args.sessionId) : undefined + ]) + ) + : new Map() + ctx.spawnTiming = createPtySpawnTiming() + ctx.cwd = ctx.deps.resolvePtySpawnStartupCwd(args.worktreeId, args.cwd) + + const earlyLeafId = + typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null + const earlyPaneKey = + typeof args.worktreeId === 'string' && + typeof args.tabId === 'string' && + isValidTerminalTabId(args.tabId) && + args.tabId.length <= 512 && + earlyLeafId + ? makePaneKey(args.tabId, earlyLeafId) + : null + const earlyReservationKey = makePaneSpawnReservationKey( + args.worktreeId, + args.connectionId, + earlyPaneKey + ) + const pendingRuntimeCreate = earlyReservationKey + ? pendingRuntimePaneCreatesByOwnerKey.get(earlyReservationKey) + : undefined + if (pendingRuntimeCreate) { + await pendingRuntimeCreate.promise + } + const existingPaneSpawn = earlyReservationKey + ? paneSpawnReservationsByOwnerKey.get(earlyReservationKey) + : undefined + if (existingPaneSpawn) { + return { ...(await existingPaneSpawn.promise), isReattach: true } + } + ctx.earlyStablePaneOwner = + earlyPaneKey && args.worktreeId + ? resolveStablePaneOwner( + ctx.deps.runtime, + ctx.deps.store, + earlyPaneKey, + args.worktreeId, + args.connectionId + ) + : null + ctx.earlyWorktreeId = args.worktreeId + // Reserve early so renderer/runtime materialization cannot start duplicate provider spawns. + ctx.paneSpawnReservationKey = earlyReservationKey + ctx.paneSpawnReservation = ctx.paneSpawnReservationKey + ? reservePaneSpawn(ctx.paneSpawnReservationKey) + : null + ctx.finishTerminalInstall = (): void => {} + ctx.stablePaneOwner = null + ctx.stablePaneBindingPersisted = false + ctx.rejectedRegistrationCandidate = null + ctx.pendingRegistrationPtyId = null + // Why hoisted to the reply scope: main reconciles the provider sequence + // deep inside the spawn path, but the pane needs that renderer-domain + // boundary beside the daemon snapshot's kitty flags. + ctx.reconciledSnapshotSeq = null + // False when bytes crossed the data socket during the spawn RPC: the + // reconciled boundary covers them, but the daemon proved its kitty flags + // before they existed, so the claim must not erase what the pane may + // have scanned from those bytes live. + ctx.snapshotKittyFlagsCoverReconciledSeq = true + ctx.preparedProvisionalExecutionContext = false + + return null +} diff --git a/src/main/ipc/pty/ipc/spawn-commit-persist.ts b/src/main/ipc/pty/ipc/spawn-commit-persist.ts new file mode 100644 index 00000000000..be9cb31394a --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-commit-persist.ts @@ -0,0 +1,154 @@ +import { toSshExecutionHostId } from '../../../../shared/execution-host' +import { markNativeWindowsConptyPty } from '../../../runtime/terminal-model-query-authority' +import { closeStartupQueryAuthorityForPty, getRelayPtyId } from '../provider/registry' +import { createTerminalSessionStateSaveFailureMessage } from '../../../../shared/terminal-session-state-save-failure' +import { recordCodexPaneAccountForSpawn } from '../host-env/codex-home' +import { persistAdmittedStablePaneBinding } from '../pane/stable-owner' +import { + pendingByPaneKey, + pendingPtyIdBySerializerGeneration, + rendererSerializerReadiness +} from '../pane/serializer-state' +import { ptyOwnership, ptyIncarnationById, deletePtyOwnership } from '../provider/ownership-state' +import { ptySizes } from '../delivery/visibility-state' +import { clearProviderPtyState } from '../provider/state-cleanup' +import type { PtyIpcSpawnState } from './spawn-state' + +export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{ + rendererPreSignaled: boolean + rendererAlreadyRegistered: boolean +}> { + const args = ctx.args + try { + ctx.stablePaneBindingPersisted = persistAdmittedStablePaneBinding({ + store: ctx.deps.store, + owner: ctx.stablePaneOwner, + result: ctx.result, + worktreeId: args.worktreeId, + startupCwd: ctx.cwd, + connectionId: args.connectionId + }) + } catch (error) { + if (error instanceof Error && error.message === 'terminal_pane_owner_changed') { + throw error + } + console.error('[pty] failed to persist PTY binding after attach:', error) + throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { + agentSessionOperationOutcome: 'unknown' as const + }) + } + ctx.spawnTiming.log(ctx.result.id, { + daemon: ctx.isDaemonHostSpawn, + reattach: ctx.result.isReattach ?? false + }) + recordCodexPaneAccountForSpawn({ + ptyId: ctx.result.id, + isDaemonHostSpawn: ctx.isDaemonHostSpawn, + isReattach: ctx.result.isReattach === true, + pinnedByResume: ctx.codexResumeHomeSelected, + launchCodexHomePath: ctx.selectedCodexHomePath, + launchEnv: ctx.baseEnv, + target: ctx.codexSelectionTarget, + settings: ctx.deps.getSettings?.() + }) + ptyOwnership.set(ctx.result.id, args.connectionId ?? null) + if (ctx.result.incarnationId) { + ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) + } + if (ctx.initiallyHidden) { + // Why marked synchronously here: provider data events dispatch on later tasks, so this still lands ahead of the first byte's delivery decision (idempotent if already marked pre-spawn). + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.result.id, true) + if (ctx.preSpawnHiddenMarkId !== null && ctx.preSpawnHiddenMarkId !== ctx.result.id) { + // Defense: never strand a mark on an id the provider renamed. + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, false) + } + // Why after ptyOwnership.set: provider lookup routes by ownership, and a hidden-spawned agent should be paceable from its first flood. + ctx.deps.syncPtyBackgroundedDelivery(ctx.result.id, 'spawn') + closeStartupQueryAuthorityForPty(ctx.result.id) + } + // Why: record the native-Windows-ConPTY determination before the headless seed so the emulator's DA1 override exists from byte zero. + if (ctx.nativeWindowsConptySpawn) { + markNativeWindowsConptyPty(ctx.result.id) + } + const relayResultId = getRelayPtyId(args.connectionId, ctx.result.id) + if (ctx.deps.store && args.connectionId) { + // Why: remote PTYs live in the SSH relay grace window after Orca detaches; persist IDs immediately so reconnect reattaches instead of spawning a fresh shell. + ctx.deps.store.upsertSshRemotePtyLease({ + targetId: args.connectionId, + ptyId: relayResultId, + ...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}), + ...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}), + ...(ctx.validatedLeafId ? { leafId: ctx.validatedLeafId } : {}), + state: 'attached', + lastAttachedAt: Date.now() + }) + } + if (ctx.preAllocatedHandle && !ctx.stablePaneOwner?.handle) { + if (ctx.deps.runtime?.registerPreAllocatedHandleForPty) { + ctx.deps.runtime.registerPreAllocatedHandleForPty(ctx.result.id, ctx.preAllocatedHandle) + ctx.agentTeamsLeaderHandle = null + } + } + ptySizes.set(ctx.result.id, { cols: args.cols, rows: args.rows }) + if (ctx.effectiveSessionAppId !== undefined && ctx.effectiveSessionAppId !== ctx.result.id) { + ptySizes.delete(ctx.effectiveSessionAppId) + } + // Why: patch the load-bearing ptyId binding synchronously so a force-quit in the renderer's ~450 ms debounce window can't orphan daemon history or an SSH relay lease (Issue #217). + if ( + ctx.deps.store && + typeof args.worktreeId === 'string' && + typeof args.tabId === 'string' && + ctx.validatedLeafId !== null && + !ctx.stablePaneBindingPersisted + ) { + try { + const binding = { + worktreeId: args.worktreeId, + tabId: args.tabId, + leafId: ctx.validatedLeafId, + ptyId: ctx.result.id, + ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), + ...(ctx.cwd ? { startupCwd: ctx.cwd } : {}) + } + if (args.connectionId) { + ctx.deps.store.persistPtyBinding(binding, toSshExecutionHostId(args.connectionId)) + } else { + ctx.deps.store.persistPtyBinding(binding) + } + } catch (err) { + console.error('[pty] failed to persist PTY binding after spawn:', err) + if (!ctx.result.isReattach) { + try { + await ctx.provider.shutdown(ctx.result.id, { immediate: true }) + } catch (shutdownErr) { + console.warn('[pty] failed to clean up PTY after persistence failure:', shutdownErr) + } + clearProviderPtyState(ctx.result.id) + deletePtyOwnership(ctx.result.id) + } + if (!ctx.result.isReattach && args.connectionId && ctx.deps.store) { + ctx.deps.store.removeSshRemotePtyLease(args.connectionId, relayResultId) + } + throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { + agentSessionOperationOutcome: 'unknown' as const + }) + } + } + // Why: when the renderer has declared it will own the serializer for this paneKey, suppress the daemon-snapshot seed so its hydration path is sole authority (keyed on paneKey since the ptyId isn't known yet). See docs/mobile-prefer-renderer-scrollback.md. + const rendererPreSignaled = ctx.validatedPaneKey + ? pendingByPaneKey.has(ctx.validatedPaneKey) + : false + const rendererAlreadyRegistered = + ctx.result.isReattach === true && + !rendererPreSignaled && + rendererSerializerReadiness.has(ctx.result.id) + rendererSerializerReadiness.beginIncarnation(ctx.result.id, rendererAlreadyRegistered) + // Why: capture the pending gen at spawn time so this PTY's teardown only settles its own generation, not a remount that replaced the entry. + if (ctx.validatedPaneKey && rendererPreSignaled) { + const pending = pendingByPaneKey.get(ctx.validatedPaneKey) + if (pending) { + pendingPtyIdBySerializerGeneration.set(pending.gen, ctx.result.id) + } + } + return { rendererPreSignaled, rendererAlreadyRegistered } +} diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts new file mode 100644 index 00000000000..7e277c7c3f5 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -0,0 +1,233 @@ +import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' +import { agentHookServer } from '../../../agent-hooks/server' +import { markClaudePtySpawned } from '../../../claude-accounts/live-pty-gate' +import { registerPty } from '../../../memory/pty-registry' +import type { PtySpawnResult } from '../../../providers/types' +import { clearMigrationUnsupportedPtysForPaneKey } from '../../../agent-hooks/migration-unsupported-pty-state' +import { track } from '../../../telemetry/client' +import { getCohortAtEmit } from '../../../telemetry/cohort-classifier' +import { + agentKindSchema, + launchSourceSchema, + requestKindSchema +} from '../../../../shared/telemetry-events' +import { + shouldSkipCodexHomeEnvForWindowsShell, + codexReattachedHomeRouteField +} from '../host-env/codex-home' +import { rememberPaneKeyForPty } from '../pane/key-state' +import { resolvePaneSpawnReservation } from '../pane/spawn-reservation' +import { seedTerminalRestoreRecordsFromSpawnResult } from '../pane/agent-session-owners' +import { admitRendererAgentLaunchAuthority } from '../pane/launch-authority' +import type { PtyIpcSpawnState } from './spawn-state' +import { persistPtyIpcSpawnCommit } from './spawn-commit-persist' + +export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { + const args = ctx.args + const { rendererPreSignaled, rendererAlreadyRegistered } = await persistPtyIpcSpawnCommit(ctx) + + // Why: seed the headless emulator before registerPty so concurrent live PTY data lands on top of the seed, not replacing it (mobile keeps the daemon-restored scrollback). + // Skip when the renderer will be authoritative — its xterm buffer is richer than the daemon snapshot. + if (ctx.deps.runtime && !rendererPreSignaled && !rendererAlreadyRegistered) { + const snapshotSeedSize = + typeof ctx.result.snapshotCols === 'number' && typeof ctx.result.snapshotRows === 'number' + ? { cols: ctx.result.snapshotCols, rows: ctx.result.snapshotRows } + : undefined + if (typeof ctx.result.snapshot === 'string' && ctx.result.snapshot.length > 0) { + // Why kitty flags ride seed metadata: the snapshot omits them, but the re-seeded emulator must answer hidden `CSI ? u` with the running app's flags (terminal-query-authority.md). + ctx.deps.runtime.seedHeadlessTerminal( + ctx.result.id, + ctx.result.snapshot, + snapshotSeedSize, + typeof ctx.result.snapshotKittyKeyboardFlags === 'number' + ? { kittyKeyboardFlags: ctx.result.snapshotKittyKeyboardFlags } + : {} + ) + } else if ( + ctx.result.coldRestore && + typeof ctx.result.coldRestore.scrollback === 'string' && + ctx.result.coldRestore.scrollback.length > 0 + ) { + const coldRestoreSeedSize = + typeof ctx.result.coldRestore.cols === 'number' && + typeof ctx.result.coldRestore.rows === 'number' + ? { cols: ctx.result.coldRestore.cols, rows: ctx.result.coldRestore.rows } + : undefined + ctx.deps.runtime.seedHeadlessTerminal( + ctx.result.id, + ctx.result.coldRestore.scrollback, + coldRestoreSeedSize, + { + cwd: ctx.result.coldRestore.cwd, + oscLinks: ctx.result.coldRestore.oscLinks, + preferProviderIfExisting: true + } + ) + } else if (typeof ctx.result.replay === 'string' && ctx.result.replay.length > 0) { + // Why: relay reattach replay is the only restore main never ingests; skip this seed and park-reveal would replace it with a suffix fragment. + ctx.deps.runtime.seedHeadlessTerminal(ctx.result.id, ctx.result.replay) + } + } + if ( + typeof args.worktreeId === 'string' && + args.worktreeId.length > 0 && + args.worktreeId.length <= 512 + ) { + const agentLaunchAuthority = admitRendererAgentLaunchAuthority({ + launchToken: args.launchToken, + spawnEnv: ctx.spawnEnv, + launchAgent: args.launchAgent, + launchConfig: ctx.effectiveLaunchConfig, + isReattach: ctx.result.isReattach === true, + hasStablePaneOwner: ctx.stablePaneOwner !== null, + incarnationId: ctx.result.incarnationId + }) + ctx.deps.runtime?.registerPty( + ctx.result.id, + args.worktreeId, + args.connectionId ?? null, + // Why: pass validated pane identity so a throttled mobile create publishes its surface main-side instead of destroying the live PTY (#7587); bound the untrusted tabId. + typeof args.tabId === 'string' && + isValidTerminalTabId(args.tabId) && + args.tabId.length <= 512 && + ctx.metadataLeafId !== null + ? { + tabId: args.tabId, + leafId: ctx.metadataLeafId, + ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), + ...(agentLaunchAuthority ? { agentLaunchAuthority } : {}) + } + : undefined, + !args.connectionId + ? shouldSkipCodexHomeEnvForWindowsShell(ctx.effectiveShellOverride, ctx.cwd) + : undefined + ) + ctx.pendingRegistrationPtyId = null + } else if (ctx.pendingRegistrationPtyId) { + ctx.deps.runtime?.cancelPendingPtyRegistration?.( + ctx.pendingRegistrationPtyId, + ctx.result.incarnationId + ) + ctx.pendingRegistrationPtyId = null + } + // Why: seed after registerPty binds the worktree — including on + // desktop, where the renderer-authority gate above skips the emulator + // seed but the list/read records still live main-side. + seedTerminalRestoreRecordsFromSpawnResult(ctx.deps.runtime, ctx.result) + // Why: arm main's per-PTY Command Code output detector from the launch command (startupCommand parity); banner detection covers PTYs without one. + if (!ctx.stablePaneOwner) { + ctx.deps.runtime?.noteTerminalSpawnCommand?.( + ctx.result.id, + typeof ctx.launchCommand === 'string' ? ctx.launchCommand : null + ) + } + if (ctx.isClaudeLaunch && !ctx.stablePaneOwner) { + markClaudePtySpawned(ctx.result.id) + } + // Why: record the paneKey mapping so clearProviderPtyState can clear the agent-hooks server's per-paneKey caches on exit. + // Why: args.env is untrusted IPC JSON (type unenforced); bound the paneKey so malformed/oversized values can't pollute ptyPaneKey or clearPaneState. + const rememberedPaneKey = ctx.validatedPaneKey + ? rememberPaneKeyForPty(ctx.result.id, ctx.validatedPaneKey) + : null + if (ctx.legacySpawnPaneKey && ctx.migrationUnsupportedPaneKey) { + agentHookServer.registerPaneKeyAlias( + ctx.legacySpawnPaneKey.paneKey, + ctx.migrationUnsupportedPaneKey, + ctx.result.id, + Date.now(), + { authorityVerified: true } + ) + clearMigrationUnsupportedPtysForPaneKey(ctx.migrationUnsupportedPaneKey) + } else if (ctx.validatedPaneKey) { + if (!ctx.result.isReattach) { + clearMigrationUnsupportedPtysForPaneKey(ctx.validatedPaneKey) + } + } + // Why: register only local PTYs with the memory collector — SSH PTYs run remotely and their process tree is invisible to our local `ps`. + if (!args.connectionId) { + // Why: record the spawn-result pid once here so the memory module needn't reach back into ipc/pty on a hot path (works for in-process and daemon-hosted PTYs). + const spawnedPid = ctx.result.pid ?? null + // Why: args.worktreeId/sessionId arrive as untrusted IPC strings (type unenforced at the boundary); bound them so malformed/oversized values can't pollute registerPty's maps. + registerPty({ + ptyId: ctx.result.id, + worktreeId: + typeof args.worktreeId === 'string' && + args.worktreeId.length > 0 && + args.worktreeId.length <= 512 + ? args.worktreeId + : null, + sessionId: + typeof args.sessionId === 'string' && + args.sessionId.length > 0 && + args.sessionId.length <= 256 + ? args.sessionId + : null, + paneKey: rememberedPaneKey, + pid: + typeof spawnedPid === 'number' && Number.isFinite(spawnedPid) && spawnedPid > 0 + ? spawnedPid + : null + }) + } + // Why: telemetry-plan.md§Agent launch semantics — fire agent_started only after spawn resolved; safeParse each field so a spoofed IPC payload can't poison the event (missing required field skips it). + if (args.telemetry && !ctx.stablePaneOwner) { + const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind) + const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source) + const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind) + if (agentKindParse.success && launchSourceParse.success && requestKindParse.success) { + track('agent_started', { + agent_kind: agentKindParse.data, + launch_source: launchSourceParse.data, + request_kind: requestKindParse.data, + ...getCohortAtEmit() + }) + } + } + const response = { + ...ctx.result, + // Why both or neither: a pane can only adopt proven kitty flags together + // with the sequence boundary they describe. + ...(typeof ctx.result.snapshotKittyKeyboardFlags === 'number' && + ctx.reconciledSnapshotSeq !== null && + ctx.snapshotKittyFlagsCoverReconciledSeq + ? { snapshotSeq: ctx.reconciledSnapshotSeq } + : { snapshotKittyKeyboardFlags: undefined }), + ...(!ctx.result.isReattach && ctx.effectiveLaunchConfig + ? { launchConfig: ctx.effectiveLaunchConfig } + : {}), + // Why: a daemon-retry race can surface isReattach even for a minted session id, and a reattach must never claim its cwd was remapped. + ...(ctx.startupCwdFallback && !ctx.result.isReattach + ? { startupCwdFallback: ctx.startupCwdFallback } + : {}), + // Why: the pane asked to resume and got a fresh session instead; only the + // renderer can say so, and a reattach never ran this launch command. + ...(ctx.codexResumeLaunch.notifyResumeUnavailable && !ctx.result.isReattach + ? { agentResumeUnavailable: true as const } + : {}) + } + // Why: renderer tab state cannot reliably infer background and reattached PTYs in the daemon inventory. + ctx.deps.sendPtySpawnedToRenderer(ctx.result.id) + if (!args.connectionId) { + ctx.deps.options?.onCodexHomePtySpawned?.({ + id: ctx.result.id, + codexHomePath: ctx.selectedCodexHomePath, + startedAt: ctx.codexHomeLaunchStartedAt, + startedSequence: ctx.codexHomeLaunchStartedSequence, + ...codexReattachedHomeRouteField( + ctx.reattachedCodexHomeRoutes, + ctx.result.id, + ctx.result.isReattach === true + ), + ...(ctx.result.isReattach === true + ? { reattached: true } + : ctx.baseEnv + ? { launchEnv: ctx.baseEnv } + : {}) + }) + } + return resolvePaneSpawnReservation( + ctx.paneSpawnReservationKey, + ctx.paneSpawnReservation, + response + ) +} diff --git a/src/main/ipc/pty/ipc/spawn-env-codex.ts b/src/main/ipc/pty/ipc/spawn-env-codex.ts new file mode 100644 index 00000000000..655a848e980 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-env-codex.ts @@ -0,0 +1,164 @@ +import { getAppEnvironment } from '../../../../shared/app-environment' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { isAgentStatusHooksEnabled } from '../../../agent-hooks/managed-agent-hook-controls' +import { isSafePtySessionId } from '../../../daemon/pty-session-id' +import { isNativeWindowsLocalPtySpawn } from '../../../runtime/terminal-model-query-authority' +import { stampWslOrchestrationCompatibilityHost } from '../../../pty/wsl-orca-env' +import { ensureCodexStateDbBackfillRecoveryStarted } from '../../../codex/codex-state-db-backfill-recovery' +import { buildPtyHostEnv } from '../host-env/assembly' +import { + getCompatibleSelectedCodexHomePath, + getCodexSelectionTargetForPty, + resolveCodexHomeAfterManagedAuthReadiness, + shouldSkipCodexHomeEnvForWindowsShell, + shouldStripInheritedOrcaCodexHome, + isCodexStatusHooksEnabled, + codexHomePathsEqual +} from '../host-env/codex-home' +import { promoteAgentTeamsShimPath } from '../host-env/path' +import { clearProviderPtyState } from '../provider/state-cleanup' +import type { PtyIpcSpawnState } from './spawn-state' + +export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promise { + const args = ctx.args + ctx.effectiveShellOverride = ctx.terminalRuntimeOptions.shellOverride + ctx.nativeWindowsConptySpawn = isNativeWindowsLocalPtySpawn({ + connectionId: args.connectionId, + cwd: args.cwd, + shellOverride: ctx.effectiveShellOverride + }) + ctx.codexSelectionTarget = getCodexSelectionTargetForPty( + ctx.effectiveShellOverride, + ctx.cwd, + ctx.expectedWslDistro + ) + const codexResumePreparation = ctx.preAdoptedStablePane + ? null + : ctx.deps.prepareCodexResumeHome({ + connectionId: args.connectionId, + launchAgent: args.launchAgent, + providerSession: args.resumeProviderSession, + target: ctx.codexSelectionTarget, + launchEnv: ctx.baseEnv, + workspacePath: ctx.cwd + }) + ctx.codexResumeLaunch = codexResumePreparation + ? await ctx.deps.resolveCodexResumeLaunch(args.command, codexResumePreparation) + : ctx.deps.noCodexResumeLaunch(ctx.preAdoptedStablePane ? undefined : args.command) + const codexResumeHome = ctx.codexResumeLaunch.codexResumeHome + ctx.launchCommand = ctx.codexResumeLaunch.command + ctx.baseEnv = ctx.deps.stripSequencedStartupResumeArgv(ctx.baseEnv, ctx.codexResumeLaunch) + // Why: declared after the strip so a local-provider spawn cannot capture the + // pre-strip env — only the daemon branch below re-derives this from baseEnv. + ctx.env = ctx.baseEnv + ctx.selectedCodexHomePath = + !ctx.preAdoptedStablePane && !args.connectionId + ? getCompatibleSelectedCodexHomePath( + ctx.codexSelectionTarget, + codexResumeHome + ? ctx.deps.reconcileSharedRuntimeResumeHome(codexResumeHome, () => + getCompatibleSelectedCodexHomePath( + ctx.codexSelectionTarget, + ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv, { + workspacePath: ctx.cwd, + launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined + }) ?? null + ) + ) + : (ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv, { + workspacePath: ctx.cwd, + launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined + }) ?? null) + ) + : null + if (!ctx.preAdoptedStablePane && args.launchAgent === 'codex' && args.sessionId === undefined) { + const resolution = resolveCodexHomeAfterManagedAuthReadiness({ + selectedCodexHomePath: ctx.selectedCodexHomePath, + getSettings: () => ctx.deps.getSettings?.(), + requiredCodexHomePath: codexResumeHome?.codexHomePath, + target: ctx.codexSelectionTarget, + resolveCurrent: () => + getCompatibleSelectedCodexHomePath( + ctx.codexSelectionTarget, + ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv, { + workspacePath: ctx.cwd, + launchAgent: 'codex' + }) ?? null + ), + resolveAfterUnavailable: (unavailableManagedHomePath) => + getCompatibleSelectedCodexHomePath( + ctx.codexSelectionTarget, + ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv, { + workspacePath: ctx.cwd, + launchAgent: 'codex', + unavailableManagedHomePath + }) ?? null + ) + }) + ctx.selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution + } + if (args.launchAgent === 'codex' && ctx.selectedCodexHomePath) { + await ensureCodexStateDbBackfillRecoveryStarted(ctx.selectedCodexHomePath) + } + ctx.codexResumeHomeSelected = Boolean( + codexResumeHome && codexHomePathsEqual(ctx.selectedCodexHomePath, codexResumeHome.codexHomePath) + ) + ctx.skipCodexHomeEnv = + ctx.isDaemonHostSpawn && + shouldSkipCodexHomeEnvForWindowsShell(ctx.effectiveShellOverride, ctx.cwd) && + !ctx.selectedCodexHomePath + const ptySettings = ctx.isDaemonHostSpawn ? ctx.deps.getSettings?.() : undefined + ctx.stripInheritedOrcaCodexHome = + ctx.isDaemonHostSpawn && + shouldStripInheritedOrcaCodexHome({ + target: ctx.codexSelectionTarget, + selectedCodexHomePath: ctx.selectedCodexHomePath, + skipCodexHomeEnv: ctx.skipCodexHomeEnv, + settings: ptySettings + }) + if (ctx.isDaemonHostSpawn && !ctx.preAdoptedStablePane) { + if (ctx.effectiveSessionId === undefined) { + // Should be unreachable: effectiveSessionId is a string when isDaemonHostSpawn; defense-in-depth. + throw new Error('Invariant violation: daemon spawn without sessionId') + } + const sessionIdForEnv = ctx.effectiveSessionId + // Why: this id reaches filesystem paths; reject traversal/separators so a crafted IPC payload can't escape the expected roots. + if (!isSafePtySessionId(sessionIdForEnv, getAppEnvironment().getPath('userData'))) { + throw new Error('Invalid PTY session id') + } + // Why: clone before mutating so injections don't leak back into args.env (renderer may reuse it). + ctx.env = { ...ctx.baseEnv } + try { + buildPtyHostEnv(sessionIdForEnv, ctx.env, { + isPackaged: getAppEnvironment().isPackaged(), + resourcesPath: process.resourcesPath, + userDataPath: getAppEnvironment().getPath('userData'), + selectedCodexHomePath: ctx.selectedCodexHomePath, + skipCodexHomeEnv: ctx.skipCodexHomeEnv, + stripInheritedOrcaCodexHome: ctx.stripInheritedOrcaCodexHome, + launchCommand: ctx.launchCommand, + launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, + isWsl: shouldSkipCodexHomeEnvForWindowsShell(ctx.effectiveShellOverride, ctx.cwd), + wslDistro: ctx.codexSelectionTarget.runtime === 'wsl' ? ctx.expectedWslDistro : null, + agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings), + codexStatusHooksEnabled: isCodexStatusHooksEnabled(ptySettings), + networkProxySettings: ptySettings, + deferGitConfigGuardToDaemon: + ctx.provider.supportsGitCredentialGuardHost?.(ctx.effectiveSessionId) === true + }) + stampWslOrchestrationCompatibilityHost( + ctx.env, + ctx.deps.runtime?.getOrchestrationCompatibilityHostId?.(), + ctx.codexSelectionTarget.runtime === 'wsl' ? ctx.expectedWslDistro : null + ) + promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath) + } catch (err) { + // Why: buildPtyHostEnv has fs side-effects (Pi/OMP install); clear per-PTY state on throw, but only minted ids — caller ids may name existing PTYs. + if (ctx.isMintedSessionId) { + clearProviderPtyState(sessionIdForEnv) + } + throw err + } + } + ctx.spawnTiming.mark('host_env') +} diff --git a/src/main/ipc/pty/ipc/spawn-env.ts b/src/main/ipc/pty/ipc/spawn-env.ts new file mode 100644 index 00000000000..2fd4c51ac04 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-env.ts @@ -0,0 +1,135 @@ +import { + isTerminalLeafId, + makePaneKey, + parseLegacyNumericPaneKey +} from '../../../../shared/stable-pane-id' +import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' +import { isClaudeAuthSwitchInProgress } from '../../../claude-accounts/live-pty-gate' +import { hasClaudeAuthEnvConflict } from '../../../claude-accounts/environment' +import { LocalPtyProvider } from '../../../providers/local-pty-provider' +import { resolvePathEnvKey } from '../../../pty/windows-environment-path' +import { routesFreshSpawnsToLocalProvider } from '../host-env/fresh-spawn-routing' +import { stripRemotePaneEnvWhenHooksDisabled } from '../provider/liveness' +import { parseValidPaneKey } from '../pane/key-state' +import { shouldRefreshNativeClaudeAgentTeamsEnv } from '../pane/launch-authority' +import type { PtyIpcSpawnState } from './spawn-state' +import { assemblePtyIpcSpawnCodexEnv } from './spawn-env-codex' + +export async function assemblePtyIpcSpawnEnv(ctx: PtyIpcSpawnState): Promise { + const args = ctx.args + if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { + throw new Error('A Claude account switch is in progress. Try again after it finishes.') + } + if (ctx.claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) { + throw new Error( + 'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.' + ) + } + // Why: the daemon-backed provider skips LocalPtyProvider's buildSpawnEnv, so assemble the same host-local env here for parity. + // Safety: skip entirely for SSH — every injection is a loopback secret or a local path that leaks or misleads on the remote host. + // Why: forward pane env to SSH only when the relay hook path is enabled, or a newer relay could emit statuses this build can't route. + const sshSourceEnv = stripRemotePaneEnvWhenHooksDisabled(args.connectionId, args.env) + const baseEnvWithAuth = ctx.claudeAuth + ? { ...sshSourceEnv, ...ctx.claudeAuth.envPatch } + : sshSourceEnv + const spawnPaneKey = baseEnvWithAuth?.ORCA_PANE_KEY + const parsedSpawnPaneKey = parseValidPaneKey(spawnPaneKey) + const verifiedPaneKey = + parsedSpawnPaneKey && + typeof args.tabId === 'string' && + args.tabId === parsedSpawnPaneKey.tabId && + args.leafId === parsedSpawnPaneKey.leafId + ? makePaneKey(parsedSpawnPaneKey.tabId, parsedSpawnPaneKey.leafId) + : null + ctx.verifiedLeafId = verifiedPaneKey && parsedSpawnPaneKey ? parsedSpawnPaneKey.leafId : null + ctx.metadataLeafId = + typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null + ctx.metadataPaneKey = + typeof args.tabId === 'string' && + isValidTerminalTabId(args.tabId) && + args.tabId.length <= 512 && + ctx.metadataLeafId + ? makePaneKey(args.tabId, ctx.metadataLeafId) + : null + ctx.legacySpawnPaneKey = verifiedPaneKey ? null : parseLegacyNumericPaneKey(spawnPaneKey) + ctx.migrationUnsupportedPaneKey = + ctx.legacySpawnPaneKey && + typeof args.tabId === 'string' && + args.tabId === ctx.legacySpawnPaneKey.tabId && + typeof args.leafId === 'string' && + isTerminalLeafId(args.leafId) + ? makePaneKey(args.tabId, args.leafId) + : null + ctx.stablePaneKey = verifiedPaneKey ?? ctx.migrationUnsupportedPaneKey + ctx.baseEnv = baseEnvWithAuth ? { ...baseEnvWithAuth } : undefined + const shouldRefreshAgentTeamsEnv = + !ctx.preAdoptedStablePane && + !args.connectionId && + ctx.deps.runtime !== undefined && + ctx.stablePaneKey !== null && + shouldRefreshNativeClaudeAgentTeamsEnv({ + command: args.command, + launchConfig: args.launchConfig + }) + ctx.effectiveLaunchConfig = args.launchConfig + const shouldPreAllocateTerminalHandle = + ctx.deps.runtime !== undefined && + ((!(ctx.provider instanceof LocalPtyProvider) && + !routesFreshSpawnsToLocalProvider(ctx.provider)) || + shouldRefreshAgentTeamsEnv) + const runtime = ctx.deps.runtime + ctx.preAllocatedHandle = shouldPreAllocateTerminalHandle + ? (ctx.preAdoptedStablePane?.owner.handle ?? + runtime?.createPreAllocatedTerminalHandle() ?? + null) + : null + if (shouldRefreshAgentTeamsEnv && ctx.preAllocatedHandle && runtime) { + // Why: Agent Teams ids/tokens are process-local, so the team env must be regenerated for the new leader PTY. + const prepared = await runtime.prepareClaudeAgentTeamsLeaderForHandle({ + handle: ctx.preAllocatedHandle, + baseEnv: ctx.baseEnv ?? {} + }) + ctx.agentTeamsLeaderHandle = ctx.preAllocatedHandle + ctx.baseEnv = { + ...ctx.baseEnv, + ...prepared.env + } + if (args.launchConfig) { + ctx.effectiveLaunchConfig = { + ...args.launchConfig, + agentEnv: { + ...args.launchConfig.agentEnv, + ...prepared.env + } + } + } + } + ctx.requestedAgentTeamsPath = ctx.baseEnv?.ORCA_AGENT_TEAMS_TEAM_ID + ? ctx.baseEnv[resolvePathEnvKey(ctx.baseEnv, process.platform)] + : undefined + ctx.agentTeamsEnvToDelete = shouldRefreshAgentTeamsEnv ? ['TERM_PROGRAM'] : undefined + if (ctx.baseEnv && ctx.stablePaneKey) { + ctx.baseEnv.ORCA_PANE_KEY = ctx.stablePaneKey + if (typeof args.tabId === 'string') { + ctx.baseEnv.ORCA_TAB_ID = args.tabId + } else if (!args.connectionId) { + delete ctx.baseEnv.ORCA_TAB_ID + } + if (typeof args.worktreeId === 'string') { + ctx.baseEnv.ORCA_WORKTREE_ID = args.worktreeId + } else if (!args.connectionId) { + delete ctx.baseEnv.ORCA_WORKTREE_ID + } + } else if (ctx.baseEnv) { + // Why: ORCA_PANE_KEY crosses into shells/hook registries; only a key proven to match this spawn's tab+leaf may cross the IPC boundary. + delete ctx.baseEnv.ORCA_PANE_KEY + delete ctx.baseEnv.ORCA_TAB_ID + delete ctx.baseEnv.ORCA_WORKTREE_ID + delete ctx.baseEnv.ORCA_AGENT_LAUNCH_TOKEN + } + ctx.validatedPaneKey = ctx.stablePaneKey + // Why: SSH can strip ORCA_PANE_KEY when remote hooks are off; IPC tab/leaf metadata still names the pane. + ctx.reservationPaneKey = ctx.metadataPaneKey ?? ctx.validatedPaneKey + ctx.validatedLeafId = ctx.verifiedLeafId ?? ctx.metadataLeafId + await assemblePtyIpcSpawnCodexEnv(ctx) +} diff --git a/src/main/ipc/pty/ipc/spawn-execute.ts b/src/main/ipc/pty/ipc/spawn-execute.ts new file mode 100644 index 00000000000..58525e162aa --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-execute.ts @@ -0,0 +1,202 @@ +import { ensureWslHookRelayForReattach } from '../../../agent-hooks/wsl-hook-relay-reattach' +import { + SSH_SESSION_EXPIRED_ERROR, + isSshPtyIdentityMismatchError +} from '../../../providers/ssh-pty-errors' +import { classifyError } from '../../../telemetry/classify-error' +import { track } from '../../../telemetry/client' +import { getCohortAtEmit } from '../../../telemetry/cohort-classifier' +import { agentKindSchema } from '../../../../shared/telemetry-events' +import { normalizeNodePtySpawnError } from '../provider/liveness' +import { resolveStablePaneOwner, spawnForStablePane } from '../pane/stable-owner' +import { assertSpawnReplyWasLive } from '../pane/agent-session-owners' +import { deletePtyOwnership } from '../provider/ownership-state' +import { ptySizes } from '../delivery/visibility-state' +import { clearProviderPtyState } from '../provider/state-cleanup' +import type { PtyIpcSpawnState } from './spawn-state' + +export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { + const args = ctx.args + try { + if (ctx.preAllocatedHandle) { + ctx.deps.trustedTerminalHandleEnv.add(ctx.preAllocatedHandle) + } + ctx.spawnTiming.mark('options') + const stablePaneOwnerCandidate = resolveStablePaneOwner( + ctx.deps.runtime, + ctx.deps.store, + ctx.reservationPaneKey, + args.worktreeId, + args.connectionId + ) + const expectedPtyId = + stablePaneOwnerCandidate?.ptyId ?? ctx.effectiveSessionAppId ?? ctx.effectiveSessionId + if (expectedPtyId) { + ctx.deps.runtime?.beginPtyRegistration?.(expectedPtyId) + ctx.pendingRegistrationPtyId = expectedPtyId + } + if (ctx.isDaemonHostSpawn && expectedPtyId) { + ctx.preparedProvisionalExecutionContext = + ctx.deps.runtime?.preparePtyExecutionContext?.(expectedPtyId, ctx.expectedWslDistro, { + resetIncarnation: ctx.isMintedSessionId && !stablePaneOwnerCandidate, + preserveExisting: !ctx.isMintedSessionId || Boolean(stablePaneOwnerCandidate) + }) ?? false + } + const sequenceBeforeProviderSpawn = expectedPtyId + ? (ctx.deps.runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0) + : 0 + const stablePaneSpawn = ctx.preAdoptedStablePane + ? ctx.preAdoptedStablePane + : await spawnForStablePane({ + runtime: ctx.deps.runtime, + store: ctx.deps.store, + provider: ctx.provider, + spawnOptions: ctx.spawnOptions, + owner: stablePaneOwnerCandidate, + worktreeId: args.worktreeId, + connectionId: args.connectionId, + resolveOwner: () => + resolveStablePaneOwner( + ctx.deps.runtime, + ctx.deps.store, + ctx.reservationPaneKey, + args.worktreeId, + args.connectionId + ) + }) + ctx.result = stablePaneSpawn.result + ctx.stablePaneOwner = stablePaneSpawn.owner + if ( + ctx.stablePaneOwner && + ctx.isMintedSessionId && + ctx.effectiveSessionAppId && + ctx.effectiveSessionAppId !== ctx.result.id + ) { + clearProviderPtyState(ctx.effectiveSessionAppId) + } + ctx.rejectedRegistrationCandidate = ctx.result + if (ctx.pendingRegistrationPtyId !== ctx.result.id) { + if (ctx.pendingRegistrationPtyId) { + ctx.deps.runtime?.cancelPendingPtyRegistration?.(ctx.pendingRegistrationPtyId) + } + ctx.deps.runtime?.beginPtyRegistration?.(ctx.result.id, ctx.result.incarnationId) + ctx.pendingRegistrationPtyId = ctx.result.id + } + assertSpawnReplyWasLive(ctx.result) + ctx.deps.runtime?.assertPtyRegistrationAllowed?.(ctx.result.id, ctx.result.incarnationId) + if (ctx.result.providerSequence) { + const runtimeSequenceBeforeReconcile = + ctx.deps.runtime?.getPtyOutputSequence?.(ctx.result.id) ?? 0 + // Why kept: this is the reattach boundary in the RENDERER's sequence + // domain, and the daemon snapshot's kitty flags mean nothing without + // the boundary they were proven at. + ctx.reconciledSnapshotSeq = + ctx.deps.runtime?.synchronizePtyOutputSequenceFromProvider?.( + ctx.result.id, + ctx.result.providerSequence, + sequenceBeforeProviderSpawn + ) ?? null + if (runtimeSequenceBeforeReconcile > sequenceBeforeProviderSpawn) { + ctx.snapshotKittyFlagsCoverReconciledSeq = false + } + } + ensureWslHookRelayForReattach(ctx.result, args.connectionId) + ctx.deps.runtime?.preparePtyExecutionContext?.( + ctx.result.id, + args.connectionId + ? null + : ctx.result.wslDistro === undefined + ? ctx.expectedWslDistro + : ctx.result.wslDistro + ) + ctx.spawnTiming.mark('provider_spawn') + } catch (err) { + if ( + (ctx.isMintedSessionId || ctx.preparedProvisionalExecutionContext) && + ctx.effectiveSessionAppId + ) { + ctx.deps.runtime?.preparePtyExecutionContext?.(ctx.effectiveSessionAppId, null, { + resetIncarnation: true + }) + } + // Why: a stale hidden mark on this session id would gate a later visible attach that reuses it. + if (ctx.preSpawnHiddenMarkId !== null) { + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, false) + } + const rawMessage = err instanceof Error ? err.message : String(err) + if (rawMessage === 'agent_session_exited_during_start' && ctx.rejectedRegistrationCandidate) { + ctx.deps.runtime?.releaseRejectedPtyRegistrationFence?.( + ctx.rejectedRegistrationCandidate.id, + ctx.rejectedRegistrationCandidate.incarnationId + ) + } + if (ctx.pendingRegistrationPtyId) { + ctx.deps.runtime?.cancelPendingPtyRegistration?.( + ctx.pendingRegistrationPtyId, + ctx.rejectedRegistrationCandidate?.incarnationId + ) + ctx.pendingRegistrationPtyId = null + } + const spawnError = normalizeNodePtySpawnError(err) + const isIdentityMismatch = + isSshPtyIdentityMismatchError(spawnError) || isSshPtyIdentityMismatchError(rawMessage) + const isExpiredSshSession = + Boolean(args.connectionId) && + (spawnError.message.includes(SSH_SESSION_EXPIRED_ERROR) || + rawMessage.includes(SSH_SESSION_EXPIRED_ERROR)) + const exitedBeforeSpawnReply = + ctx.rejectedRegistrationCandidate?.exitedBeforeSpawnReply === true + if (ctx.effectiveSessionAppId !== undefined) { + if ( + ctx.hadSessionSizeBeforeAttach && + ctx.sessionSizeBeforeAttach && + (isIdentityMismatch || (!isExpiredSshSession && !exitedBeforeSpawnReply)) + ) { + ptySizes.set(ctx.effectiveSessionAppId, ctx.sessionSizeBeforeAttach) + } else { + ptySizes.delete(ctx.effectiveSessionAppId) + } + } + if (args.connectionId && ctx.effectiveSessionRelayId !== undefined && isExpiredSshSession) { + // Why: expired remote reattach = relay already dropped the PTY; clear the lease so writes can't restore the stale binding. + if (ctx.effectiveSessionAppId !== undefined && !isIdentityMismatch) { + clearProviderPtyState(ctx.effectiveSessionAppId) + deletePtyOwnership(ctx.effectiveSessionAppId) + } + if (!isIdentityMismatch) { + ctx.deps.store?.markSshRemotePtyLease( + args.connectionId, + ctx.effectiveSessionRelayId, + 'expired' + ) + } + } + // Why: provider state buildPtyHostEnv materialized for this minted id leaks if spawn failed. + if (ctx.isMintedSessionId && ctx.effectiveSessionId !== undefined) { + clearProviderPtyState(ctx.effectiveSessionId) + } + // Why: telemetry-plan.md§agent_error — attribute the error to the renderer-threaded agent_kind, else sniff the command for `claude`; raw messages are dropped at the validator boundary. + const rendererAgentKindParse = + args.telemetry?.agent_kind !== undefined + ? agentKindSchema.safeParse(args.telemetry.agent_kind) + : null + const errorAgentKind = rendererAgentKindParse?.success + ? rendererAgentKindParse.data + : ctx.isClaudeLaunch + ? ('claude-code' as const) + : null + if (errorAgentKind) { + const classified = classifyError(spawnError) + track('agent_error', { + agent_kind: errorAgentKind, + error_class: classified.error_class, + ...getCohortAtEmit() + }) + } + throw spawnError + } finally { + if (ctx.preAllocatedHandle) { + ctx.deps.trustedTerminalHandleEnv.delete(ctx.preAllocatedHandle) + } + } +} diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts new file mode 100644 index 00000000000..ed75b989f6d --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -0,0 +1,181 @@ +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { CLAUDE_AUTH_ENV_VARS } from '../../../claude-accounts/environment' +import { LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS } from '../../../pty/legacy-terminal-shim-dir' +import { CODEX_HOME_ENV_KEYS } from '../host-env/codex-home' +import { + mergePtyEnvDeletions, + removeCodexHomeDeletionRequests, + getInheritedAgentHookEnvKeysToDelete, + getInheritedClaudeSessionStampEnvKeysToDelete +} from '../host-env/pi-agent' +import { promoteAgentTeamsShimPath, deleteRequestedEnvKeys } from '../host-env/path' +import { beginPtySpawnForWorktree } from '../host-env/fresh-spawn-routing' +import { + makePaneSpawnReservationKey, + reservePaneSpawn, + paneSpawnReservationsByOwnerKey, + pendingRuntimePaneCreatesByOwnerKey +} from '../pane/spawn-reservation' +import { ptySizes } from '../delivery/visibility-state' +import { getStartupTerminalColorQueryReplyColors } from '../../terminal-startup-color-query-replies' +import type { PtyIpcSpawnState } from './spawn-state' + +export async function buildPtyIpcSpawnOptions( + ctx: PtyIpcSpawnState +): Promise<{ isReattach: true } | null> { + const args = ctx.args + ctx.spawnEnv = ctx.preAllocatedHandle + ? { ...ctx.env, ORCA_TERMINAL_HANDLE: ctx.preAllocatedHandle } + : ctx.env + const envToDelete = ctx.claudeAuth?.stripAuthEnv + ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] + : undefined + ctx.combinedEnvToDelete = mergePtyEnvDeletions( + envToDelete, + args.envToDelete ?? [], + ctx.agentTeamsEnvToDelete ?? [], + // Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH. + ctx.isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [], + ctx.isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(ctx.spawnEnv) : [], + getInheritedClaudeSessionStampEnvKeysToDelete(ctx.spawnEnv), + ctx.skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [], + // Why: the persistent daemon compares its own merged CODEX_HOME pair; + // main cannot safely decide ownership for a process it may not parent. + ctx.stripInheritedOrcaCodexHome ? ['ORCA_CODEX_HOME'] : [] + ) + if (ctx.codexResumeHomeSelected) { + ctx.combinedEnvToDelete = removeCodexHomeDeletionRequests(ctx.combinedEnvToDelete) + } + deleteRequestedEnvKeys(ctx.spawnEnv, ctx.combinedEnvToDelete) + promoteAgentTeamsShimPath(ctx.spawnEnv, ctx.requestedAgentTeamsPath) + ctx.spawnOptions = { + cols: args.cols, + rows: args.rows, + cwd: ctx.cwd, + ...(ctx.prevalidatedCwd && !ctx.isDaemonHostSpawn + ? { prevalidatedCwd: ctx.prevalidatedCwd } + : {}), + env: ctx.spawnEnv, + historyIsolationEnabled: ctx.deps.getSettings?.()?.terminalScopeHistoryByWorktree ?? true, + ...(ctx.isMintedSessionId ? { isNewSession: true } : {}) + } + if (!args.connectionId && !ctx.isDaemonHostSpawn) { + ctx.spawnOptions.codexHomePathOverride = { value: ctx.selectedCodexHomePath } + } + if (ctx.combinedEnvToDelete) { + ctx.spawnOptions.envToDelete = ctx.combinedEnvToDelete + } + if (ctx.launchCommand !== undefined) { + ctx.spawnOptions.command = ctx.launchCommand + } + if (args.commandDelivery !== undefined) { + ctx.spawnOptions.commandDelivery = args.commandDelivery + } + if (args.startupCommandDelivery !== undefined) { + ctx.spawnOptions.startupCommandDelivery = args.startupCommandDelivery + } + if (isTuiAgent(args.launchAgent)) { + ctx.spawnOptions.launchAgent = args.launchAgent + } + if (args.worktreeId !== undefined) { + ctx.spawnOptions.worktreeId = args.worktreeId + } + if (ctx.reservationPaneKey) { + ctx.spawnOptions.paneKey = ctx.reservationPaneKey + } + if (typeof args.tabId === 'string' && args.tabId.length > 0 && args.tabId.length <= 512) { + ctx.spawnOptions.tabId = args.tabId + } + if (ctx.effectiveSessionId !== undefined) { + ctx.spawnOptions.sessionId = ctx.effectiveSessionId + } + // Why: without this, the Windows daemon path ignores the user's Default Shell preference (LocalPtyProvider already honors it via getWindowsShell()). + if (ctx.effectiveShellOverride !== undefined) { + ctx.spawnOptions.shellOverride = ctx.effectiveShellOverride + } + ctx.hadSessionSizeBeforeAttach = + ctx.effectiveSessionAppId !== undefined ? ptySizes.has(ctx.effectiveSessionAppId) : false + ctx.sessionSizeBeforeAttach = + ctx.effectiveSessionAppId !== undefined ? ptySizes.get(ctx.effectiveSessionAppId) : undefined + if (ctx.effectiveSessionId !== undefined) { + // Why: daemon PTYs can emit before spawn() resolves; set real geometry now or early bytes default to 80x24 and wrap TUIs. + ptySizes.set(ctx.effectiveSessionAppId ?? ctx.effectiveSessionId, { + cols: args.cols, + rows: args.rows + }) + } + if (process.platform === 'win32' && !args.connectionId) { + // Why: the renderer models PowerShell as one shell family; thread the implementation choice so both PTY paths resolve the same executable. + ctx.spawnOptions.terminalWindowsWslDistro = ctx.expectedWslDistro + ctx.spawnOptions.terminalWindowsPowerShellImplementation = ctx.deps.getSettings + ? (ctx.deps.getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') + : undefined + } + const startupTerminalColorQueryReplyColors = getStartupTerminalColorQueryReplyColors(args) + if (startupTerminalColorQueryReplyColors) { + ctx.spawnOptions.startupIngress = { + colors: startupTerminalColorQueryReplyColors, + deadlineMs: 5_000 + } + } + const resolvedPaneSpawnReservationKey = makePaneSpawnReservationKey( + args.worktreeId, + args.connectionId, + ctx.reservationPaneKey + ) + if ( + ctx.paneSpawnReservationKey && + resolvedPaneSpawnReservationKey !== ctx.paneSpawnReservationKey + ) { + throw new Error('terminal_pane_identity_changed') + } + if (!ctx.paneSpawnReservationKey) { + ctx.paneSpawnReservationKey = resolvedPaneSpawnReservationKey + const pendingRuntimeCreateAfterPreflight = ctx.paneSpawnReservationKey + ? pendingRuntimePaneCreatesByOwnerKey.get(ctx.paneSpawnReservationKey) + : undefined + if (pendingRuntimeCreateAfterPreflight) { + await pendingRuntimeCreateAfterPreflight.promise + } + const existingPaneSpawnAfterPreflight = ctx.paneSpawnReservationKey + ? paneSpawnReservationsByOwnerKey.get(ctx.paneSpawnReservationKey) + : undefined + if (existingPaneSpawnAfterPreflight) { + const reattach = { + ...(await existingPaneSpawnAfterPreflight.promise), + isReattach: true as const + } + // Why: discard only a distinct provisional id; the winner may have committed this id's real size. + if (ctx.effectiveSessionId !== undefined) { + const provisionalSizeKey = ctx.effectiveSessionAppId ?? ctx.effectiveSessionId + if (provisionalSizeKey !== reattach.id) { + if (ctx.hadSessionSizeBeforeAttach && ctx.sessionSizeBeforeAttach) { + ptySizes.set(provisionalSizeKey, ctx.sessionSizeBeforeAttach) + } else { + ptySizes.delete(provisionalSizeKey) + } + } + } + return reattach + } + ctx.paneSpawnReservation = ctx.paneSpawnReservationKey + ? reservePaneSpawn(ctx.paneSpawnReservationKey) + : null + } + ctx.finishTerminalInstall = beginPtySpawnForWorktree(args.worktreeId, ctx.cwd, args.connectionId) + ctx.initiallyHidden = args.initiallyHidden === true + // Why: daemon PTYs can emit before spawn() resolves, so the hidden mark must beat byte zero (terminal-query-authority.md §races); other providers are safe with the post-spawn mark below. + ctx.preSpawnHiddenMarkId = + ctx.initiallyHidden && ctx.isDaemonHostSpawn && ctx.effectiveSessionAppId !== undefined + ? ctx.effectiveSessionAppId + : null + if (ctx.preSpawnHiddenMarkId !== null) { + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, true) + } + const runtime = ctx.deps.runtime + const acquireWorktreeSpawn = runtime?.acquireWorktreeTerminalSpawn + ctx.releaseWorktreeSpawn = acquireWorktreeSpawn + ? await acquireWorktreeSpawn.call(runtime, args.worktreeId) + : undefined + return null +} diff --git a/src/main/ipc/pty/ipc/spawn-preflight.ts b/src/main/ipc/pty/ipc/spawn-preflight.ts new file mode 100644 index 00000000000..f40b46e5dd5 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-preflight.ts @@ -0,0 +1,229 @@ +import { + isWslShellName, + resolveLocalWindowsTerminalRuntimeOptions +} from '../../../../shared/local-windows-terminal-runtime' +import { isWslUncPath, toWindowsWslPath } from '../../../../shared/wsl-paths' +import { isClaudeAuthSwitchInProgress } from '../../../claude-accounts/live-pty-gate' +import { mintPtySessionId } from '../../../daemon/pty-session-id' +import { resolveWslSessionContext } from '../../../daemon/wsl-session-context' +import { LocalPtyProvider } from '../../../providers/local-pty-provider' +import { normalizeWindowsTerminalCwd } from '../../../providers/windows-shell-args' +import { wslUncDirectoryExistsAsync } from '../../../wsl' +import { getCodexSelectionTargetForPty } from '../host-env/codex-home' +import { + isClaudeLaunchCommand, + recoverFreshSpawnProviderRouting, + routesFreshSpawnsToLocalProvider +} from '../host-env/fresh-spawn-routing' +import { getAppPtyId, getProvider, getRelayPtyId } from '../provider/registry' +import type { PtyIpcSpawnState } from './spawn-state' + +export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promise { + const args = ctx.args + // Establish daemon identity before the first await so hidden delivery is gated before byte zero. + ctx.provider = getProvider(args.connectionId) + ctx.isDaemonHostSpawn = + !args.connectionId && + !(ctx.provider instanceof LocalPtyProvider) && + !routesFreshSpawnsToLocalProvider(ctx.provider) + ctx.isMintedSessionId = args.sessionId === undefined && ctx.isDaemonHostSpawn + ctx.effectiveSessionId = + args.sessionId ?? (ctx.isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined) + ctx.effectiveSessionAppId = + ctx.effectiveSessionId !== undefined + ? getAppPtyId(args.connectionId, ctx.effectiveSessionId) + : undefined + ctx.effectiveSessionRelayId = + ctx.effectiveSessionId !== undefined + ? getRelayPtyId(args.connectionId, ctx.effectiveSessionId) + : undefined + ctx.initiallyHidden = args.initiallyHidden === true + ctx.preSpawnHiddenMarkId = + ctx.initiallyHidden && ctx.isDaemonHostSpawn && ctx.effectiveSessionAppId !== undefined + ? ctx.effectiveSessionAppId + : null + if (ctx.preSpawnHiddenMarkId !== null) { + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, true) + } + if (!ctx.earlyStablePaneOwner) { + const pathUsable = ctx.deps.assertFolderWorkspacePtyPathUsable(args.worktreeId) + if (pathUsable) { + await pathUsable + } + } + ctx.spawnTiming.mark('stable_adoption_setup') + ctx.preAdoptedStablePane = + ctx.earlyStablePaneOwner && ctx.earlyWorktreeId + ? await ctx.deps.adoptStablePane({ + cols: args.cols, + rows: args.rows, + cwd: ctx.cwd, + connectionId: args.connectionId, + worktreeId: ctx.earlyWorktreeId, + tabId: ctx.earlyStablePaneOwner.tabId, + leafId: ctx.earlyStablePaneOwner.leafId, + ownsPaneSpawnReservation: true + }) + : null + ctx.spawnTiming.mark('stable_adoption') + if (ctx.earlyStablePaneOwner && !ctx.preAdoptedStablePane) { + const pathUsable = ctx.deps.assertFolderWorkspacePtyPathUsable(args.worktreeId) + if (pathUsable) { + await pathUsable + } + } + if (!ctx.preAdoptedStablePane) { + // Why: reattach needs exact cwd, SSH cannot probe locally, and successful stable-pane adoption needs no launch preflight. + const requestedMissingCwdFallback = + !args.connectionId && !args.sessionId && args.cwdFallback === 'worktree' + const isPosixStartupCwd = args.cwd?.startsWith('/') === true + const startupWorkspaceCwd = + requestedMissingCwdFallback && isPosixStartupCwd + ? ctx.deps.resolvePtySpawnStartupCwd(args.worktreeId, '.') + : undefined + const initiallyResolvedStartupCwd = + requestedMissingCwdFallback && isPosixStartupCwd ? ctx.cwd : undefined + const startupTerminalRuntimeOptions = + requestedMissingCwdFallback && process.platform === 'win32' + ? resolveLocalWindowsTerminalRuntimeOptions({ + requestedShellOverride: args.shellOverride, + settings: ctx.deps.getSettings?.(), + projectRuntime: args.projectRuntime, + fallbackHostShell: process.env.COMSPEC || 'powershell.exe' + }) + : undefined + const wslRuntimeOwnsStartupCwd = + requestedMissingCwdFallback && + isPosixStartupCwd && + (isWslShellName(startupTerminalRuntimeOptions?.shellOverride) || + isWslUncPath(startupWorkspaceCwd ?? '')) + const startupWslContext = wslRuntimeOwnsStartupCwd + ? resolveWslSessionContext({ + cwd: startupWorkspaceCwd, + shellOverride: startupTerminalRuntimeOptions?.shellOverride, + terminalWindowsWslDistro: startupTerminalRuntimeOptions?.terminalWindowsWslDistro + }) + : undefined + let wslStartupCwdExists: boolean | null = null + let wslWorkspaceCwdExists: boolean | null = null + if (startupWslContext && initiallyResolvedStartupCwd) { + const validationCwd = toWindowsWslPath(initiallyResolvedStartupCwd, startupWslContext.distro) + wslStartupCwdExists = isWslUncPath(validationCwd) + ? await wslUncDirectoryExistsAsync(validationCwd) + : ctx.deps.localStartupCwdDirectoryExists(validationCwd) + if (wslStartupCwdExists === true) { + ctx.prevalidatedCwd = validationCwd + } + if (wslStartupCwdExists === false && startupWorkspaceCwd) { + wslWorkspaceCwdExists = isWslUncPath(startupWorkspaceCwd) + ? await wslUncDirectoryExistsAsync(startupWorkspaceCwd) + : ctx.deps.localStartupCwdDirectoryExists(startupWorkspaceCwd) + if (wslWorkspaceCwdExists === true) { + ctx.prevalidatedCwd = startupWorkspaceCwd + } + } + } + const allowMissingCwdFallback = + requestedMissingCwdFallback && + (!wslRuntimeOwnsStartupCwd || + (wslStartupCwdExists === false && wslWorkspaceCwdExists === true)) + let didFallbackToWorkspaceRootCwd = false + ctx.cwd = ctx.deps.resolvePtySpawnStartupCwd( + args.worktreeId, + args.cwd, + allowMissingCwdFallback + ? { + directoryExists: (path) => + startupWslContext && + wslStartupCwdExists === false && + path === initiallyResolvedStartupCwd + ? false + : startupWslContext && path === startupWorkspaceCwd + ? wslWorkspaceCwdExists === true + : ctx.deps.localStartupCwdDirectoryExists( + process.platform === 'win32' ? normalizeWindowsTerminalCwd(path) : path + ), + onFallbackToWorkspaceRoot: () => { + didFallbackToWorkspaceRootCwd = true + } + } + : undefined + ) + if (didFallbackToWorkspaceRootCwd && wslWorkspaceCwdExists === true && ctx.cwd) { + ctx.prevalidatedCwd = ctx.cwd + } + ctx.startupCwdFallback = + didFallbackToWorkspaceRootCwd && ctx.cwd ? { kind: 'worktree', cwd: ctx.cwd } : undefined + } + ctx.spawnTiming.mark('preflight') + const freshSpawnRecovery = ctx.preAdoptedStablePane + ? undefined + : recoverFreshSpawnProviderRouting(ctx.provider, args.connectionId, args.sessionId) + if (freshSpawnRecovery) { + await freshSpawnRecovery + const previousHiddenMarkId = ctx.preSpawnHiddenMarkId + ctx.isDaemonHostSpawn = + !args.connectionId && + !(ctx.provider instanceof LocalPtyProvider) && + !routesFreshSpawnsToLocalProvider(ctx.provider) + ctx.isMintedSessionId = args.sessionId === undefined && ctx.isDaemonHostSpawn + ctx.effectiveSessionId = + args.sessionId ?? (ctx.isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined) + ctx.effectiveSessionAppId = + ctx.effectiveSessionId !== undefined + ? getAppPtyId(args.connectionId, ctx.effectiveSessionId) + : undefined + ctx.effectiveSessionRelayId = + ctx.effectiveSessionId !== undefined + ? getRelayPtyId(args.connectionId, ctx.effectiveSessionId) + : undefined + ctx.preSpawnHiddenMarkId = + ctx.initiallyHidden && ctx.isDaemonHostSpawn && ctx.effectiveSessionAppId !== undefined + ? ctx.effectiveSessionAppId + : null + if (previousHiddenMarkId !== ctx.preSpawnHiddenMarkId) { + if (previousHiddenMarkId !== null) { + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(previousHiddenMarkId, false) + } + if (ctx.preSpawnHiddenMarkId !== null) { + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, true) + } + } + } + ctx.isClaudeLaunch = + !ctx.preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) + if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { + throw new Error('A Claude account switch is in progress. Try again after it finishes.') + } + ctx.terminalRuntimeOptions = + process.platform === 'win32' && !args.connectionId + ? resolveLocalWindowsTerminalRuntimeOptions({ + requestedShellOverride: args.shellOverride, + settings: ctx.deps.getSettings?.(), + projectRuntime: args.projectRuntime, + fallbackHostShell: process.env.COMSPEC || 'powershell.exe' + }) + : { shellOverride: args.shellOverride, terminalWindowsWslDistro: null } + const initialShellOverride = ctx.terminalRuntimeOptions.shellOverride + // Why: daemon host-env setup needs a stable id BEFORE provider.spawn so buildPtyHostEnv hooks/Pi cleanup can run; daemon still honors opts.sessionId ?? mint(). + // Note: sessionId is STABLE across daemon restarts by design — do NOT simplify to a fresh UUID per spawn; that orphans reconnectable state. + // Why: only clear ids minted in THIS request on failure — a caller-supplied args.sessionId may name an existing PTY we must not clobber. + ctx.expectedWslDistro = !args.connectionId + ? (resolveWslSessionContext({ + cwd: ctx.cwd, + sessionId: ctx.effectiveSessionId, + shellOverride: ctx.terminalRuntimeOptions.shellOverride, + terminalWindowsWslDistro: ctx.terminalRuntimeOptions.terminalWindowsWslDistro + })?.distro ?? null) + : null + const initialSelectionTarget = getCodexSelectionTargetForPty( + initialShellOverride, + ctx.cwd, + ctx.expectedWslDistro + ) + ctx.claudeAuth = + ctx.isClaudeLaunch && ctx.deps.prepareClaudeAuth + ? await ctx.deps.prepareClaudeAuth(initialSelectionTarget) + : null + ctx.spawnTiming.mark('auth') +} diff --git a/src/main/ipc/pty/ipc/spawn-run.ts b/src/main/ipc/pty/ipc/spawn-run.ts new file mode 100644 index 00000000000..748eb5d8f62 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-run.ts @@ -0,0 +1,79 @@ +import { rejectPaneSpawnReservation } from '../pane/spawn-reservation' +import { ptySizes } from '../delivery/visibility-state' +import { beginPtyIpcSpawn } from './spawn-begin' +import { preparePtyIpcSpawnPreflight } from './spawn-preflight' +import { assemblePtyIpcSpawnEnv } from './spawn-env' +import { buildPtyIpcSpawnOptions } from './spawn-options' +import { executePtyIpcSpawn } from './spawn-execute' +import { commitPtyIpcSpawn } from './spawn-commit' +import { createPtyIpcSpawnState, type PtyIpcSpawnState } from './spawn-state' +import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' + +function releaseAbandonedAgentTeamsLeader(ctx: PtyIpcSpawnState): void { + if (!ctx.agentTeamsLeaderHandle) { + return + } + ctx.deps.runtime?.releaseClaudeAgentTeamsLeaderForHandle?.(ctx.agentTeamsLeaderHandle) + ctx.agentTeamsLeaderHandle = null +} + +function restoreProvisionalPtySize(ctx: PtyIpcSpawnState): void { + if (ctx.effectiveSessionId === undefined) { + return + } + const key = ctx.effectiveSessionAppId ?? ctx.effectiveSessionId + if (ctx.hadSessionSizeBeforeAttach && ctx.sessionSizeBeforeAttach) { + ptySizes.set(key, ctx.sessionSizeBeforeAttach) + } else { + ptySizes.delete(key) + } +} + +export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArgs) { + const ctx = createPtyIpcSpawnState(deps, args) + const early = await beginPtyIpcSpawn(ctx) + if (early) { + return early + } + try { + await preparePtyIpcSpawnPreflight(ctx) + await assemblePtyIpcSpawnEnv(ctx) + const earlyReserved = await buildPtyIpcSpawnOptions(ctx).catch((error: unknown) => { + restoreProvisionalPtySize(ctx) + throw error + }) + if (earlyReserved) { + // Why: this request lost the pane to the reservation winner, so its + // pre-allocated leader handle never binds to a PTY. Nothing else can + // evict the team env assembly created for it — exit/close cleanup keys + // off handleByPtyId — so every lost race would leak one team forever. + releaseAbandonedAgentTeamsLeader(ctx) + return earlyReserved + } + await executePtyIpcSpawn(ctx) + return await commitPtyIpcSpawn(ctx) + } catch (err) { + releaseAbandonedAgentTeamsLeader(ctx) + if (ctx.preSpawnHiddenMarkId !== null) { + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, false) + } + if (ctx.pendingRegistrationPtyId) { + deps.runtime?.cancelPendingPtyRegistration?.( + ctx.pendingRegistrationPtyId, + ctx.rejectedRegistrationCandidate?.incarnationId + ) + ctx.pendingRegistrationPtyId = null + } + // Why: once the reservation is created, any later throw — + // spawn failure, persist failure, or a post-spawn helper such as + // seedHeadlessTerminal/registerPty/track — must settle it. Otherwise + // it lingers in paneSpawnReservationsByOwnerKey and every future spawn + // for this pane awaits a promise that never resolves. reject is a + // no-op once the reservation has already resolved. + rejectPaneSpawnReservation(ctx.paneSpawnReservationKey, ctx.paneSpawnReservation, err) + throw err + } finally { + ctx.releaseWorktreeSpawn?.() + ctx.finishTerminalInstall() + } +} diff --git a/src/main/ipc/pty/ipc/spawn-state.ts b/src/main/ipc/pty/ipc/spawn-state.ts new file mode 100644 index 00000000000..ee9f5c33cc7 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-state.ts @@ -0,0 +1,159 @@ +import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../../../providers/types' +import type { CodexPaneHomeRoute } from '../../../codex/codex-pane-account-registry' +import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' +import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' +import type { ClaudeRuntimeAuthPreparation } from '../../../claude-accounts/runtime-auth-service' +import type { PtySpawnTiming } from '../../pty-spawn-timing' +import { createPtySpawnTiming } from '../../pty-spawn-timing' +import { noCodexResumeLaunch, type CodexResumeLaunch } from '../host-env/codex-resume' +import type { StablePaneOwner } from '../pane/stable-owner' +import type { PaneSpawnReservation } from '../pane/spawn-reservation' +import { localProvider } from '../provider/registry' +import type { AdoptStablePaneResult, PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' + +export type PtyIpcSpawnState = { + deps: PtySpawnIpcDeps + args: PtySpawnIpcArgs + spawnTiming: PtySpawnTiming + codexHomeLaunchStartedAt: Date | undefined + codexHomeLaunchStartedSequence: number | undefined + reattachedCodexHomeRoutes: Map + cwd: string | undefined + prevalidatedCwd: string | undefined + startupCwdFallback: { kind: 'worktree'; cwd: string } | undefined + earlyStablePaneOwner: StablePaneOwner | null + earlyWorktreeId: string | undefined + paneSpawnReservationKey: string | null + paneSpawnReservation: PaneSpawnReservation | null + finishTerminalInstall: () => void + result: PtySpawnResult + stablePaneOwner: StablePaneOwner | null + stablePaneBindingPersisted: boolean + rejectedRegistrationCandidate: PtySpawnResult | null + pendingRegistrationPtyId: string | null + reconciledSnapshotSeq: number | null + snapshotKittyFlagsCoverReconciledSeq: boolean + preparedProvisionalExecutionContext: boolean + releaseWorktreeSpawn: (() => void) | undefined + provider: IPtyProvider + preAdoptedStablePane: AdoptStablePaneResult | null + isClaudeLaunch: boolean + claudeAuth: ClaudeRuntimeAuthPreparation | null + terminalRuntimeOptions: { + shellOverride?: string + terminalWindowsWslDistro?: string | null + } + isDaemonHostSpawn: boolean + isMintedSessionId: boolean + effectiveSessionId: string | undefined + effectiveSessionAppId: string | undefined + effectiveSessionRelayId: string | undefined + expectedWslDistro: string | null + baseEnv: Record | undefined + effectiveLaunchConfig: SleepingAgentLaunchConfig | undefined + preAllocatedHandle: string | null + /** Leader handle this spawn allocated Agent Teams state for; must be released if the spawn is abandoned. */ + agentTeamsLeaderHandle: string | null + requestedAgentTeamsPath: string | undefined + agentTeamsEnvToDelete: string[] | undefined + stablePaneKey: string | null + verifiedLeafId: string | null + metadataLeafId: string | null + metadataPaneKey: string | null + legacySpawnPaneKey: { tabId: string; numericPaneId: string; paneKey: string } | null + migrationUnsupportedPaneKey: string | null + reservationPaneKey: string | null + validatedPaneKey: string | null + validatedLeafId: string | null + effectiveShellOverride: string | undefined + nativeWindowsConptySpawn: boolean + codexSelectionTarget: CodexAccountSelectionTarget + codexResumeLaunch: CodexResumeLaunch + launchCommand: string | undefined + env: Record | undefined + selectedCodexHomePath: string | null + spawnEnv: Record | undefined + spawnOptions: PtySpawnOptions + combinedEnvToDelete: string[] | undefined + skipCodexHomeEnv: boolean + stripInheritedOrcaCodexHome: boolean + codexResumeHomeSelected: boolean + hadSessionSizeBeforeAttach: boolean + sessionSizeBeforeAttach: { cols: number; rows: number } | undefined + initiallyHidden: boolean + preSpawnHiddenMarkId: string | null +} + +export function createPtyIpcSpawnState( + deps: PtySpawnIpcDeps, + args: PtySpawnIpcArgs +): PtyIpcSpawnState { + return { + deps, + args, + spawnTiming: createPtySpawnTiming(), + codexHomeLaunchStartedAt: undefined, + codexHomeLaunchStartedSequence: undefined, + reattachedCodexHomeRoutes: new Map(), + cwd: undefined, + prevalidatedCwd: undefined, + startupCwdFallback: undefined, + earlyStablePaneOwner: null, + earlyWorktreeId: undefined, + paneSpawnReservationKey: null, + paneSpawnReservation: null, + finishTerminalInstall: () => {}, + result: { id: '' }, + stablePaneOwner: null, + stablePaneBindingPersisted: false, + rejectedRegistrationCandidate: null, + pendingRegistrationPtyId: null, + reconciledSnapshotSeq: null, + snapshotKittyFlagsCoverReconciledSeq: true, + preparedProvisionalExecutionContext: false, + releaseWorktreeSpawn: undefined, + provider: localProvider, + preAdoptedStablePane: null, + isClaudeLaunch: false, + claudeAuth: null, + terminalRuntimeOptions: {}, + isDaemonHostSpawn: false, + isMintedSessionId: false, + effectiveSessionId: undefined, + effectiveSessionAppId: undefined, + effectiveSessionRelayId: undefined, + expectedWslDistro: null, + baseEnv: undefined, + effectiveLaunchConfig: undefined, + preAllocatedHandle: null, + agentTeamsLeaderHandle: null, + requestedAgentTeamsPath: undefined, + agentTeamsEnvToDelete: undefined, + stablePaneKey: null, + verifiedLeafId: null, + metadataLeafId: null, + metadataPaneKey: null, + legacySpawnPaneKey: null, + migrationUnsupportedPaneKey: null, + reservationPaneKey: null, + validatedPaneKey: null, + validatedLeafId: null, + effectiveShellOverride: undefined, + nativeWindowsConptySpawn: false, + codexSelectionTarget: { runtime: 'host' }, + codexResumeLaunch: noCodexResumeLaunch(undefined), + launchCommand: undefined, + env: undefined, + selectedCodexHomePath: null, + spawnEnv: undefined, + spawnOptions: { cols: args.cols, rows: args.rows }, + combinedEnvToDelete: undefined, + skipCodexHomeEnv: false, + stripInheritedOrcaCodexHome: false, + codexResumeHomeSelected: false, + hadSessionSizeBeforeAttach: false, + sessionSizeBeforeAttach: undefined, + initiallyHidden: false, + preSpawnHiddenMarkId: null + } +} diff --git a/src/main/ipc/pty/ipc/spawn-types.ts b/src/main/ipc/pty/ipc/spawn-types.ts new file mode 100644 index 00000000000..68e1cc62227 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-types.ts @@ -0,0 +1,123 @@ +import type { TuiAgent } from '../../../../shared/tui-agent' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../../../../shared/agent-session-resume' +import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' +import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { TerminalStartupCwdMissingDirFallback } from '../../../../shared/terminal-startup-cwd' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { Store } from '../../../persistence' +import type { PtySpawnResult } from '../../../providers/types' +import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' +import type { CodexSessionResumePreparation } from '../../../codex/codex-session-resume-home' +import type { + CodexHomePtySpawnedLifecycleArgs, + GetSelectedCodexHomePath, + PrepareClaudeAuth, + PrepareCodexSessionResume +} from '../host-env/types' +import type { CodexResumeLaunch, PreparedCodexResumeHome } from '../host-env/codex-resume' +import type { StablePaneOwner } from '../pane/stable-owner' + +export type PtySpawnIpcArgs = { + cols: number + rows: number + cwd?: string + // Why: fresh local spawns opt into recovering a saved cwd whose dir was deleted (#7239); reattach/remote need exact cwd, so the flag alone isn't sufficient. + cwdFallback?: 'worktree' + env?: Record + envToDelete?: string[] + command?: string + commandDelivery?: 'renderer' | 'provider' + launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata + launchToken?: unknown + launchAgent?: TuiAgent + startupCommandDelivery?: StartupCommandDelivery + connectionId?: string | null + worktreeId?: string + sessionId?: string + shellOverride?: string + projectRuntime?: ProjectExecutionRuntimeResolution + terminalColorQueryReplies?: { + foreground?: unknown + background?: unknown + } + // Why: hidden-at-spawn declaration (terminal-query-authority.md §races) — main marks hidden before byte zero so the gate owns spawn-time queries. + initiallyHidden?: boolean + // Why: closes the SIGKILL race (INVESTIGATION.md) by letting main sync-flush the binding before pty:spawn returns; only the Ctrl+T daemon-host path threads these. + tabId?: string + leafId?: string + // Why: renderer-threaded launch telemetry (telemetry-plan.md§Agent launch semantics); loosely typed because the main-side schema validator is the single enforcement point. + telemetry?: { + agent_kind?: unknown + launch_source?: unknown + request_kind?: unknown + } +} + +export type AdoptStablePaneArgs = { + cols: number + rows: number + cwd?: string + connectionId?: string | null + worktreeId: string + preAllocatedHandle?: string + tabId: string + leafId: string + ownsPaneSpawnReservation?: true +} + +export type AdoptStablePaneResult = { + result: PtySpawnResult + owner: StablePaneOwner + materialized?: true +} + +export type PtySpawnIpcDeps = { + runtime?: OrcaRuntimeService + store?: Store + getSettings?: () => GlobalSettings + getSelectedCodexHomePath?: GetSelectedCodexHomePath + prepareClaudeAuth?: PrepareClaudeAuth + options?: { + prepareCodexSessionResume?: PrepareCodexSessionResume + onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void + } + getLocalPtyStartupPromise: (connectionId?: string | null) => Promise | undefined + adoptStablePane: (args: AdoptStablePaneArgs) => Promise + assertFolderWorkspacePtyPathUsable: (worktreeId: string | undefined) => Promise | void + resolvePtySpawnStartupCwd: ( + worktreeId: string | undefined, + cwd: string | undefined, + missingDirFallback?: TerminalStartupCwdMissingDirFallback + ) => string | undefined + localStartupCwdDirectoryExists: (path: string) => boolean + prepareCodexResumeHome: (args: { + connectionId?: string | null + launchAgent?: TuiAgent + providerSession?: AgentProviderSessionMetadata + target: CodexAccountSelectionTarget + launchEnv?: NodeJS.ProcessEnv + workspacePath?: string + }) => PreparedCodexResumeHome | null + noCodexResumeLaunch: (command: string | undefined) => CodexResumeLaunch + resolveCodexResumeLaunch: ( + command: string | undefined, + prepared: PreparedCodexResumeHome + ) => Promise + reconcileSharedRuntimeResumeHome: ( + resumeHome: Extract, + resolveCurrent: () => string | null + ) => string + stripSequencedStartupResumeArgv: | undefined>( + env: T, + launch: CodexResumeLaunch + ) => T + transitionSpawnHiddenRendererPtyDeliveryState: (id: string, hidden: boolean) => void + trustedTerminalHandleEnv: Set + sendPtySpawnedToRenderer: (id: string) => void + syncPtyBackgroundedDelivery: (id: string, caller: string) => void +} diff --git a/src/main/ipc/pty/ipc/spawn.ts b/src/main/ipc/pty/ipc/spawn.ts new file mode 100644 index 00000000000..6fd5183a36d --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn.ts @@ -0,0 +1,16 @@ +import { getPtyIpc } from '../../pty-host-bindings' +import { runPtyIpcSpawn } from './spawn-run' +import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' + +export function installPtySpawnIpcHandler(deps: PtySpawnIpcDeps): void { + const ipcMain = getPtyIpc() + const { getLocalPtyStartupPromise } = deps + + ipcMain.handle('pty:spawn', async (_event, args: PtySpawnIpcArgs) => { + const startupPromise = getLocalPtyStartupPromise(args.connectionId) + if (startupPromise) { + await startupPromise + } + return runPtyIpcSpawn(deps, args) + }) +} diff --git a/src/main/ipc/pty/ipc/write-input.ts b/src/main/ipc/pty/ipc/write-input.ts new file mode 100644 index 00000000000..fbfc09d1d4d --- /dev/null +++ b/src/main/ipc/pty/ipc/write-input.ts @@ -0,0 +1,204 @@ +import type { BrowserWindow, IpcMainEvent, IpcMainInvokeEvent, WebContents } from 'electron' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { IPtyProvider } from '../../../providers/types' +import { isPtyWriteUnavailableError } from '../../../providers/pty-write-unavailable-error' +import { + isTerminalInputTooLargeWithDeferredMeasurement, + iterateTerminalInputChunks +} from '../../../../shared/terminal-input' +import { ptyOwnership } from '../provider/ownership-state' +import { tryGetProviderForPty } from '../provider/registry' +import { + interactiveOutputCharsByPty, + lastInputAtByPty, + visibleRendererPtys +} from '../delivery/visibility-state' + +export function isMainWindowPtyIpcEvent( + event: IpcMainEvent | IpcMainInvokeEvent, + mainWindow: BrowserWindow, + mainWebContents: WebContents +): boolean { + return ( + event.sender === mainWebContents && + !mainWindow.isDestroyed() && + !(typeof mainWebContents.isDestroyed === 'function' && mainWebContents.isDestroyed()) + ) +} + +export type PtyWritePayload = { id: string; data: string } +export type PtyViewportClaimPayload = { id: string; cols: number; rows: number } + +export function createPtyWriteInput(deps: { + mainWindow: BrowserWindow + runtime?: OrcaRuntimeService + clearHiddenRendererResizeOutput: (id: string) => void +}): { + writePtyInput: (args: PtyWritePayload) => boolean | Promise + writePtyInputAccepted: (args: PtyWritePayload) => boolean | Promise + isPtyWritePayload: (value: unknown) => value is PtyWritePayload + isPtyViewportClaimPayload: (value: unknown) => value is PtyViewportClaimPayload + isPtyWriteEventFromMainWindow: ( + event: IpcMainEvent | IpcMainInvokeEvent, + mainWebContents: WebContents + ) => boolean +} { + const { mainWindow, runtime, clearHiddenRendererResizeOutput } = deps + + const reportUnavailablePtyWrite = (id: string, error: unknown): void => { + if ( + !isPtyWriteUnavailableError(error) || + mainWindow.isDestroyed() || + (typeof mainWindow.webContents.isDestroyed === 'function' && + mainWindow.webContents.isDestroyed()) + ) { + return + } + mainWindow.webContents.send('pty:writeUnavailable', { id }) + } + + const writePtyProviderInputWithinLimit = ( + provider: IPtyProvider, + id: string, + data: string + ): boolean | Promise => { + const chunks = iterateTerminalInputChunks(data) + const first = chunks.next() + if (first.done) { + provider.write(id, data) + return true + } + const second = chunks.next() + if (second.done) { + provider.write(id, first.value) + return true + } + return writePtyProviderInputChunks(provider, id, chunks, first.value, second.value) + } + + const writePtyProviderInput = ( + provider: IPtyProvider, + id: string, + data: string + ): boolean | Promise => { + try { + const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data) + if (typeof tooLarge === 'boolean') { + return tooLarge ? false : writePtyProviderInputWithinLimit(provider, id, data) + } + return tooLarge + .then((result) => (result ? false : writePtyProviderInputWithinLimit(provider, id, data))) + .catch((error) => { + reportUnavailablePtyWrite(id, error) + return false + }) + } catch (error) { + reportUnavailablePtyWrite(id, error) + return false + } + } + + const writePtyProviderInputChunks = async ( + provider: IPtyProvider, + id: string, + chunks: Iterator, + firstChunk: string, + secondChunk: string + ): Promise => { + try { + let chunk: IteratorResult = { done: false, value: firstChunk } + let nextChunk: IteratorResult = { done: false, value: secondChunk } + while (!chunk.done) { + provider.write(id, chunk.value) + if (!nextChunk.done) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } + chunk = nextChunk + nextChunk = chunks.next() + } + return true + } catch (error) { + reportUnavailablePtyWrite(id, error) + return false + } + } + + const isPtyWritePayload = (value: unknown): value is PtyWritePayload => + typeof value === 'object' && + value !== null && + typeof (value as { id?: unknown }).id === 'string' && + (value as { id: string }).id.length > 0 && + typeof (value as { data?: unknown }).data === 'string' + + const isPtyViewportClaimPayload = (value: unknown): value is PtyViewportClaimPayload => + typeof value === 'object' && + value !== null && + typeof (value as { id?: unknown }).id === 'string' && + (value as { id: string }).id.length > 0 && + typeof (value as { cols?: unknown }).cols === 'number' && + Number.isFinite((value as { cols: number }).cols) && + typeof (value as { rows?: unknown }).rows === 'number' && + Number.isFinite((value as { rows: number }).rows) && + (value as { cols: number }).cols > 0 && + (value as { rows: number }).rows > 0 + + const isPtyWriteEventFromMainWindow = ( + event: IpcMainEvent | IpcMainInvokeEvent, + mainWebContents: WebContents + ): boolean => isMainWindowPtyIpcEvent(event, mainWindow, mainWebContents) + + const writePtyInput = (args: PtyWritePayload): boolean | Promise => { + // Why: mobile-presence-lock defense-in-depth — the renderer's onData guard can let one keystroke slip during the state-flip lag, so catch it server-side. See docs/mobile-presence-lock.md. + if (runtime?.getDriver(args.id).kind === 'mobile') { + return false + } + const provider = ptyOwnership.has(args.id) ? tryGetProviderForPty(args.id) : undefined + if (!provider) { + return false + } + try { + const now = performance.now() + lastInputAtByPty.set(args.id, now) + interactiveOutputCharsByPty.set(args.id, 0) + if (visibleRendererPtys.has(args.id)) { + clearHiddenRendererResizeOutput(args.id) + } + return writePtyProviderInput(provider, args.id, args.data) + } catch { + return false + } + } + + const writePtyInputAccepted = (args: PtyWritePayload): boolean | Promise => { + if (runtime?.getDriver(args.id).kind === 'mobile') { + return false + } + // Why: the ack infers Ctrl+C/Escape reached the local PTY; SSH providers are fire-and-forget relay notifications and can't truthfully acknowledge yet. + if (ptyOwnership.get(args.id) !== null) { + return false + } + const provider = tryGetProviderForPty(args.id) + if (!provider?.hasPty?.(args.id)) { + return false + } + try { + const now = performance.now() + lastInputAtByPty.set(args.id, now) + interactiveOutputCharsByPty.set(args.id, 0) + if (visibleRendererPtys.has(args.id)) { + clearHiddenRendererResizeOutput(args.id) + } + return writePtyProviderInput(provider, args.id, args.data) + } catch { + return false + } + } + + return { + writePtyInput, + writePtyInputAccepted, + isPtyWritePayload, + isPtyViewportClaimPayload, + isPtyWriteEventFromMainWindow + } +} diff --git a/src/main/ipc/pty/ipc/write.ts b/src/main/ipc/pty/ipc/write.ts new file mode 100644 index 00000000000..417f2daed70 --- /dev/null +++ b/src/main/ipc/pty/ipc/write.ts @@ -0,0 +1,74 @@ +import type { BrowserWindow } from 'electron' +import { getPtyIpc } from '../../pty-host-bindings' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import { createPtyWriteInput } from './write-input' + +export function installPtyWriteIpcHandlers(deps: { + mainWindow: BrowserWindow + runtime?: OrcaRuntimeService + clearHiddenRendererResizeOutput: (id: string) => void +}): void { + const ipcMain = getPtyIpc() + const { mainWindow, runtime } = deps + const { + writePtyInput, + writePtyInputAccepted, + isPtyWritePayload, + isPtyViewportClaimPayload, + isPtyWriteEventFromMainWindow + } = createPtyWriteInput(deps) + + const hostViewportClaimTails = new Map>() + + ipcMain.on('pty:write', (event, args: unknown) => { + if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || !isPtyWritePayload(args)) { + return + } + const claimTail = hostViewportClaimTails.get(args.id) + if (claimTail) { + void claimTail.then((claimed) => (claimed ? writePtyInput(args) : false)) + return + } + writePtyInput(args) + }) + ipcMain.handle('pty:writeAccepted', (event, args: unknown): boolean | Promise => { + if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || !isPtyWritePayload(args)) { + return false + } + const claimTail = hostViewportClaimTails.get(args.id) + return claimTail + ? claimTail.then((claimed) => (claimed ? writePtyInputAccepted(args) : false)) + : writePtyInputAccepted(args) + }) + + ipcMain.removeAllListeners('pty:claimViewport') + ipcMain.on('pty:claimViewport', (event, args: unknown) => { + if ( + !isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || + !runtime || + !isPtyViewportClaimPayload(args) + ) { + return + } + const prior = hostViewportClaimTails.get(args.id) + // Why: two panes can mirror one PTY — never let a later no-op claim replace the in-flight resize that the following host input must await. + const claim = ( + prior + ? prior.then( + () => runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows), + () => runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows) + ) + : runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows) + ).catch((error) => { + // Why: a failed claim silently discards every gated keystroke for this pane. + console.error('[pty] remote desktop host claim failed; gated input will be discarded', error) + return false + }) + hostViewportClaimTails.set(args.id, claim) + void claim.then(() => { + if (hostViewportClaimTails.get(args.id) === claim) { + hostViewportClaimTails.delete(args.id) + } + }) + }) +} diff --git a/src/main/ipc/pty/kill-all.ts b/src/main/ipc/pty/kill-all.ts new file mode 100644 index 00000000000..307bfaeabb2 --- /dev/null +++ b/src/main/ipc/pty/kill-all.ts @@ -0,0 +1,11 @@ +import { LocalPtyProvider } from '../../providers/local-pty-provider' +import { localProvider } from './provider/registry' + +/** + * Kill in-process local PTYs. Daemon-backed PTYs are preserved by daemon disconnect. + */ +export function killAllPty(): void { + if (localProvider instanceof LocalPtyProvider) { + localProvider.killAll() + } +} diff --git a/src/main/ipc/pty/pane/adopt-stable.ts b/src/main/ipc/pty/pane/adopt-stable.ts new file mode 100644 index 00000000000..7c1a7d5c818 --- /dev/null +++ b/src/main/ipc/pty/pane/adopt-stable.ts @@ -0,0 +1,86 @@ +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { Store } from '../../../persistence' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { getProvider } from '../provider/registry' +import { makePaneSpawnReservationKey, paneSpawnReservationsByOwnerKey } from './spawn-reservation' +import { + attachStablePaneOwner, + resolveStablePaneOwner, + stablePaneAdoptionsByOwnerKey +} from './stable-owner' +import type { AdoptStablePaneArgs, AdoptStablePaneResult } from '../ipc/spawn-types' + +export async function adoptStablePane( + runtime: OrcaRuntimeService | undefined, + store: Store | undefined, + args: AdoptStablePaneArgs +): Promise { + const paneKey = makePaneKey(args.tabId, args.leafId) + const ownerKey = makePaneSpawnReservationKey(args.worktreeId, args.connectionId, paneKey) + const pendingAdoption = ownerKey ? stablePaneAdoptionsByOwnerKey.get(ownerKey) : undefined + if (pendingAdoption) { + return await pendingAdoption + } + const activePaneSpawn = + ownerKey && !args.ownsPaneSpawnReservation + ? paneSpawnReservationsByOwnerKey.get(ownerKey) + : undefined + if (activePaneSpawn) { + const result = await activePaneSpawn.promise + const owner = resolveStablePaneOwner( + runtime, + store, + paneKey, + args.worktreeId, + args.connectionId + ) + if ( + !owner || + owner.ptyId !== result.id || + (owner.incarnationId !== undefined && + result.incarnationId !== undefined && + owner.incarnationId !== result.incarnationId) + ) { + throw new Error('terminal_pane_owner_changed') + } + return { + result: { + ...result, + isReattach: true, + ...(owner.incarnationId ? { incarnationId: owner.incarnationId } : {}) + }, + owner, + materialized: true as const + } + } + const owner = resolveStablePaneOwner(runtime, store, paneKey, args.worktreeId, args.connectionId) + if (!owner) { + return null + } + const adoption = attachStablePaneOwner({ + runtime, + store, + provider: getProvider(args.connectionId), + spawnOptions: { + cols: args.cols, + rows: args.rows, + cwd: args.cwd + }, + owner, + worktreeId: args.worktreeId, + connectionId: args.connectionId, + resolveOwner: () => + resolveStablePaneOwner(runtime, store, paneKey, args.worktreeId, args.connectionId) + }) + if (!ownerKey) { + return await adoption + } + stablePaneAdoptionsByOwnerKey.set(ownerKey, adoption) + try { + return await adoption + } finally { + if (stablePaneAdoptionsByOwnerKey.get(ownerKey) === adoption) { + stablePaneAdoptionsByOwnerKey.delete(ownerKey) + } + } +} diff --git a/src/main/ipc/pty/pane/agent-session-owners.ts b/src/main/ipc/pty/pane/agent-session-owners.ts new file mode 100644 index 00000000000..291eaab6462 --- /dev/null +++ b/src/main/ipc/pty/pane/agent-session-owners.ts @@ -0,0 +1,126 @@ +import type { AgentSessionOwnerBinding } from '../../../../shared/agent-session-host-authority' +import { ClaimedAgentPtyOwnerRegistry } from '../../../../shared/claimed-agent-pty-owner' +import { isPtyIncarnationId } from '../../../../shared/pty-incarnation' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { IPtyProvider, PtySpawnResult } from '../../../providers/types' +import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state' +import { + localProvider, + sshProviders, + tryGetProviderForAgentSessionOwner +} from '../provider/registry' + +// Why: one main process can route the same remote provider namespace through +// multiple SSH relays; coordinate claims above every provider boundary too. +export const agentSessionOwners = new ClaimedAgentPtyOwnerRegistry() +let agentSessionOwnerReconciliation: Promise | null = null +// Why: this reconcile gates spawn; a wedged relay listing must fail closed (rejecting +// with unknown ownership) instead of blocking the spawn path indefinitely. +const OWNER_LISTING_DEADLINE_MS = 5_000 + +// Why: restore payloads (reattach snapshot / cold-restore scrollback / relay +// replay + lastTitle) ride spawn RPC results, never onPtyData, so EVERY spawn +// choke point — renderer pty:spawn and the runtime controller — must seed the +// terminal list/read records or headless/CLI-created reattaches stay blank. +// The runtime's empty-record guard makes a second seed for the same session a +// no-op, so overlapping paths cannot double-apply history. +export function seedTerminalRestoreRecordsFromSpawnResult( + runtime: OrcaRuntimeService | undefined, + result: PtySpawnResult +): void { + const text = + typeof result.snapshot === 'string' && result.snapshot.length > 0 + ? result.snapshot + : typeof result.coldRestore?.scrollback === 'string' && + result.coldRestore.scrollback.length > 0 + ? result.coldRestore.scrollback + : typeof result.replay === 'string' && result.replay.length > 0 + ? result.replay + : undefined + const lastTitle = + typeof result.lastTitle === 'string' && result.lastTitle.length > 0 + ? result.lastTitle + : typeof result.coldRestore?.lastTitle === 'string' && result.coldRestore.lastTitle.length > 0 + ? result.coldRestore.lastTitle + : undefined + if (text !== undefined || lastTitle !== undefined) { + runtime?.seedTerminalRestoreTail?.(result.id, { + ...(text !== undefined ? { text } : {}), + ...(lastTitle !== undefined ? { lastTitle } : {}) + }) + } +} + +export function assertSpawnReplyWasLive(result: PtySpawnResult): void { + if (!result.exitedBeforeSpawnReply) { + return + } + // Why: lower owners can resolve a different canonical id, so controller-local pending ids cannot prove this exit. + throw Object.assign(new Error('agent_session_exited_during_start'), { + agentSessionOperationOutcome: 'unknown' as const + }) +} + +export async function reconcileAgentSessionOwnerListings(): Promise { + if (agentSessionOwnerReconciliation) { + return await agentSessionOwnerReconciliation + } + const reconciliation = (async () => { + const providers: { provider: IPtyProvider; connectionId: string | null }[] = [ + { provider: localProvider, connectionId: null }, + ...Array.from(sshProviders, ([connectionId, provider]) => ({ provider, connectionId })) + ] + const deadlineMs = Date.now() + OWNER_LISTING_DEADLINE_MS + const listings = await Promise.all( + providers.map(async ({ provider, connectionId }) => ({ + connectionId, + sessions: await provider.listProcesses({ deadlineMs }).catch(() => { + throw new Error('agent_session_ownership_unknown') + }) + })) + ) + const advertisedOwners: AgentSessionOwnerBinding[] = [] + const advertisedOwnerSessions: { + id: string + connectionId: string | null + incarnationId: string + }[] = [] + for (const { connectionId, sessions } of listings) { + for (const session of sessions) { + const incarnationId = session.incarnationId + let hasAdvertisedOwner = false + for (const owner of session.agentSessionOwners ?? []) { + if (owner.ptyId !== session.id || !isPtyIncarnationId(incarnationId)) { + // Why: a recovered claim without process-incarnation proof cannot safely reject a delayed exit. + throw new Error('agent_session_ownership_unknown') + } + advertisedOwners.push(owner) + hasAdvertisedOwner = true + } + if (hasAdvertisedOwner && isPtyIncarnationId(incarnationId)) { + advertisedOwnerSessions.push({ id: session.id, connectionId, incarnationId }) + } + } + } + agentSessionOwners.reconcileAuthoritative(advertisedOwners, { + // Why: an unregistered relay can still own a live PTY during reconnect; + // only providers that serialize claims may make listing absence authoritative. + isInAuthoritativeScope: (owner) => { + const provider = tryGetProviderForAgentSessionOwner(owner.ptyId) + return provider?.providesAgentSessionOwnerListings?.(owner.ptyId) === true + } + }) + for (const session of advertisedOwnerSessions) { + ptyOwnership.set(session.id, session.connectionId) + ptyIncarnationById.set(session.id, session.incarnationId) + } + })() + agentSessionOwnerReconciliation = reconciliation + try { + await reconciliation + } finally { + if (agentSessionOwnerReconciliation === reconciliation) { + agentSessionOwnerReconciliation = null + } + } +} diff --git a/src/main/ipc/pty/pane/key-state.ts b/src/main/ipc/pty/pane/key-state.ts new file mode 100644 index 00000000000..0a7881140b6 --- /dev/null +++ b/src/main/ipc/pty/pane/key-state.ts @@ -0,0 +1,47 @@ +import { parsePaneKey } from '../../../../shared/stable-pane-id' + +// Why: track spawn-time paneKey so teardown can clear the agent-hooks server's per-paneKey cache, which otherwise grows unbounded as panes come and go. +export const ptyPaneKey = new Map() +// Why: reverse of ptyPaneKey — callers with a paneKey from outside the PTY lifecycle (e.g. agent-hook status routing) need the ptyId; kept in lock-step via the same sites. +export const paneKeyPtyId = new Map() + +export function getPtyIdForPaneKey(paneKey: string): string | undefined { + return paneKeyPtyId.get(paneKey) +} + +// Why: let consumers tear down paneKey-scoped state on PTY exit so their timers can't leak; a callback registry keeps the cross-module dependency narrow. +export type PaneKeyTeardownListener = (paneKey: string) => void +export const paneKeyTeardownListeners = new Set() + +export function registerPaneKeyTeardownListener(listener: PaneKeyTeardownListener): () => void { + paneKeyTeardownListeners.add(listener) + return () => paneKeyTeardownListeners.delete(listener) +} + +export function parseValidPaneKey(paneKey: unknown): ReturnType { + if (typeof paneKey !== 'string' || paneKey.length > 256) { + return null + } + return parsePaneKey(paneKey) +} + +export function isValidPaneKey(paneKey: unknown): paneKey is string { + return parseValidPaneKey(paneKey) !== null +} + +export function rememberPaneKeyForPty(ptyId: string, paneKey: unknown): string | null { + const normalizedPaneKey = typeof paneKey === 'string' ? paneKey.trim() : '' + if (!isValidPaneKey(normalizedPaneKey)) { + return null + } + // Why: a re-registered ptyId would otherwise leave its old paneKey pointing here. + const previousPaneKey = ptyPaneKey.get(ptyId) + if (previousPaneKey && previousPaneKey !== normalizedPaneKey) { + if (paneKeyPtyId.get(previousPaneKey) === ptyId) { + paneKeyPtyId.delete(previousPaneKey) + } + } + ptyPaneKey.set(ptyId, normalizedPaneKey) + paneKeyPtyId.set(normalizedPaneKey, ptyId) + return normalizedPaneKey +} diff --git a/src/main/ipc/pty/pane/launch-authority.ts b/src/main/ipc/pty/pane/launch-authority.ts new file mode 100644 index 00000000000..93f3061f0b3 --- /dev/null +++ b/src/main/ipc/pty/pane/launch-authority.ts @@ -0,0 +1,41 @@ +import type { TuiAgent } from '../../../../shared/tui-agent' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' +import { isPtyIncarnationId } from '../../../../shared/pty-incarnation' + +const AGENT_LAUNCH_TOKEN_MAX_LENGTH = 128 + +export function admitRendererAgentLaunchAuthority(args: { + launchToken: unknown + spawnEnv: Record | undefined + launchAgent: unknown + launchConfig: SleepingAgentLaunchConfig | undefined + isReattach: boolean + hasStablePaneOwner: boolean + incarnationId: unknown +}): { launchToken: string; launchAgent: TuiAgent } | null { + if ( + args.isReattach || + args.hasStablePaneOwner || + !args.launchConfig || + !isTuiAgent(args.launchAgent) || + !isPtyIncarnationId(args.incarnationId) || + typeof args.launchToken !== 'string' || + args.launchToken.length === 0 || + args.launchToken.length > AGENT_LAUNCH_TOKEN_MAX_LENGTH || + args.spawnEnv?.ORCA_AGENT_LAUNCH_TOKEN !== args.launchToken + ) { + return null + } + return { launchToken: args.launchToken, launchAgent: args.launchAgent } +} + +export function shouldRefreshNativeClaudeAgentTeamsEnv(args: { + command?: string + launchConfig?: SleepingAgentLaunchConfig +}): boolean { + const capturedCommand = args.launchConfig?.agentCommand?.trim() || args.command?.trim() || '' + const capturedArgs = args.launchConfig?.agentArgs?.trim() ?? '' + const capturedLaunch = `${capturedCommand} ${capturedArgs}`.trim() + return /(^|\s)--teammate-mode(?:=|\s+)auto(?:\s|$)/.test(capturedLaunch) +} diff --git a/src/main/ipc/pty/pane/serializer-state.ts b/src/main/ipc/pty/pane/serializer-state.ts new file mode 100644 index 00000000000..ec3f8dacbc4 --- /dev/null +++ b/src/main/ipc/pty/pane/serializer-state.ts @@ -0,0 +1,71 @@ +import type { WebContents } from 'electron' +import { RendererTerminalSerializerReadiness } from '../../renderer-terminal-serializer-readiness' +import { isValidPaneKey, paneKeyPtyId } from './key-state' + +// Why: renderer pre-declares serializer ownership before pty:spawn to suppress the daemon-snapshot seed; gen tokens prevent paneKey-reuse races on teardown. See docs/mobile-prefer-renderer-scrollback.md. +export let pendingSerializerGenSeq = 0 +export const pendingByPaneKey = new Map< + string, + { gen: number; ownerWebContentsId: number | null } +>() +export const pendingPaneSerializerCleanupRegistered = new Set() +// Why: bind the declaration generation directly to its spawn result. PTY ids +// are reusable and teardown callbacks carry no incarnation token, so teardown +// must never guess which pending renderer generation it owns. +export const pendingPtyIdBySerializerGeneration = new Map() +// Why: hasRendererSerializer probe needs a ptyId-keyed signal; a later spawn starts a fresh incarnation, subscription abort owns waiter cleanup. +export const rendererSerializerReadiness = new RendererTerminalSerializerReadiness() + +export function cleanupPendingPaneSerializersForSender(ownerWebContentsId: number): void { + pendingPaneSerializerCleanupRegistered.delete(ownerWebContentsId) + for (const [paneKey, pending] of pendingByPaneKey) { + if (pending.ownerWebContentsId === ownerWebContentsId) { + pendingByPaneKey.delete(paneKey) + pendingPtyIdBySerializerGeneration.delete(pending.gen) + } + } +} + +export function registerPendingPaneSerializerCleanup(sender: WebContents | undefined): void { + if (!sender || pendingPaneSerializerCleanupRegistered.has(sender.id)) { + return + } + pendingPaneSerializerCleanupRegistered.add(sender.id) + sender.once('destroyed', () => cleanupPendingPaneSerializersForSender(sender.id)) +} + +export function declarePendingPaneSerializer( + paneKey: string, + sender: WebContents | undefined +): number { + const gen = ++pendingSerializerGenSeq + // Why: a sender destroyed before the declaration never fires 'destroyed' again, + // so its entry would strand in pendingByPaneKey and suppress the daemon seed forever. + if (sender?.isDestroyed()) { + cleanupPendingPaneSerializersForSender(sender.id) + return gen + } + registerPendingPaneSerializerCleanup(sender) + const replaced = pendingByPaneKey.get(paneKey) + if (replaced) { + pendingPtyIdBySerializerGeneration.delete(replaced.gen) + } + pendingByPaneKey.set(paneKey, { gen, ownerWebContentsId: sender?.id ?? null }) + const existingPtyId = paneKeyPtyId.get(paneKey) + if (existingPtyId) { + pendingPtyIdBySerializerGeneration.set(gen, existingPtyId) + } + return gen +} + +export function settlePendingPaneSerializer(paneKey: string, gen: number): boolean { + if (pendingByPaneKey.get(paneKey)?.gen !== gen) { + return false + } + pendingByPaneKey.delete(paneKey) + return true +} + +export function hasPendingRendererSerializerForPaneKey(paneKey: string): boolean { + return isValidPaneKey(paneKey) && pendingByPaneKey.has(paneKey) +} diff --git a/src/main/ipc/pty/pane/spawn-reservation.ts b/src/main/ipc/pty/pane/spawn-reservation.ts new file mode 100644 index 00000000000..47c968b9b5a --- /dev/null +++ b/src/main/ipc/pty/pane/spawn-reservation.ts @@ -0,0 +1,115 @@ +import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' +import type { PtySpawnResult } from '../../../providers/types' + +export type PaneSpawnReservation = { + promise: Promise + resolve: (result: PaneSpawnReservationResult) => void + reject: (error: unknown) => void +} +export type PaneSpawnReservationResult = { + id: string + launchConfig?: SleepingAgentLaunchConfig + stablePaneOwner?: { + handle: string + tabId: string + leafId: string + } +} & Partial + +// Why: identical pane coordinates on different worktrees or hosts are independent owners. +export const paneSpawnReservationsByOwnerKey = new Map() +export type PendingRuntimePaneCreate = { + count: number + promise: Promise + resolve: () => void +} +export const pendingRuntimePaneCreatesByOwnerKey = new Map() + +export function reservePaneSpawn(paneKey: string): PaneSpawnReservation { + let resolve!: (result: PaneSpawnReservationResult) => void + let reject!: (error: unknown) => void + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve + reject = promiseReject + }) + promise.catch(() => {}) + const reservation = { promise, resolve, reject } + paneSpawnReservationsByOwnerKey.set(paneKey, reservation) + return reservation +} + +export function clearPaneSpawnReservation( + paneKey: string, + reservation: PaneSpawnReservation +): void { + if (paneSpawnReservationsByOwnerKey.get(paneKey) === reservation) { + paneSpawnReservationsByOwnerKey.delete(paneKey) + } +} + +export function makePaneSpawnReservationKey( + worktreeId: string | undefined, + connectionId: string | null | undefined, + paneKey: string | null | undefined +): string | null { + return paneKey ? JSON.stringify([connectionId ?? null, worktreeId ?? null, paneKey]) : null +} + +export function claimRuntimePaneCreate(ownerKey: string): () => void { + const existing = pendingRuntimePaneCreatesByOwnerKey.get(ownerKey) + if (existing) { + existing.count += 1 + return () => releaseRuntimePaneCreate(ownerKey, existing) + } + let resolve!: () => void + const claim = { + count: 1, + promise: new Promise((done) => { + resolve = done + }), + resolve: () => resolve() + } + pendingRuntimePaneCreatesByOwnerKey.set(ownerKey, claim) + return () => releaseRuntimePaneCreate(ownerKey, claim) +} + +export function releaseRuntimePaneCreate(ownerKey: string, claim: PendingRuntimePaneCreate): void { + if (pendingRuntimePaneCreatesByOwnerKey.get(ownerKey) !== claim) { + return + } + claim.count -= 1 + if (claim.count > 0) { + return + } + pendingRuntimePaneCreatesByOwnerKey.delete(ownerKey) + claim.resolve() +} + +export function rejectPaneSpawnReservation( + paneKey: string | null | undefined, + reservation: PaneSpawnReservation | null | undefined, + error: unknown +): void { + if (!reservation) { + return + } + reservation.reject(error) + if (paneKey) { + clearPaneSpawnReservation(paneKey, reservation) + } +} + +export function resolvePaneSpawnReservation( + paneKey: string | null | undefined, + reservation: PaneSpawnReservation | null | undefined, + response: T +): T { + if (!reservation) { + return response + } + reservation.resolve(response) + if (paneKey) { + clearPaneSpawnReservation(paneKey, reservation) + } + return response +} diff --git a/src/main/ipc/pty/pane/stable-owner.ts b/src/main/ipc/pty/pane/stable-owner.ts new file mode 100644 index 00000000000..9442e914e1f --- /dev/null +++ b/src/main/ipc/pty/pane/stable-owner.ts @@ -0,0 +1,293 @@ +import { toSshExecutionHostId } from '../../../../shared/execution-host' +import { makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' +import type { Store } from '../../../persistence' +import { retireTerminalSurfaceFromPersistence } from '../../../runtime/mobile-session-terminal-persistence-retirement' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../../../providers/types' +import { parseAppSshPtyId } from '../../../providers/ssh-pty-id' +import { + isDaemonEndpointGoneError, + TerminalHostGoneError, + TerminalSessionOwnerUnverifiedError +} from '../../../daemon/daemon-errors' +import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state' +import { isPtyAlreadyGoneError } from '../provider/liveness' +import { clearProviderPtyState } from '../provider/state-cleanup' + +export type StablePaneOwner = { + handle?: string + tabId: string + leafId: string + ptyId: string + incarnationId?: string + hasPersistedBinding?: true + persistedIncarnationId?: string + runtimeIncarnationId?: string +} +export type StablePaneAdoption = { + result: PtySpawnResult + owner: StablePaneOwner + materialized?: true +} | null +export const stablePaneAdoptionsByOwnerKey = new Map>() + +export function resolvePersistedStablePaneOwner( + store: Store | undefined, + paneKey: string, + worktreeId: string, + connectionId: string | null | undefined +): Pick | null { + if (!store || typeof store.getWorkspaceSession !== 'function') { + return null + } + const parsed = parsePaneKey(paneKey) + if (!parsed) { + return null + } + const session = store.getWorkspaceSession( + connectionId ? toSshExecutionHostId(connectionId) : undefined + ) + const tab = session.tabsByWorktree?.[worktreeId]?.find( + (candidate) => candidate.id === parsed.tabId && candidate.worktreeId === worktreeId + ) + const ptyId = session.terminalLayoutsByTabId?.[parsed.tabId]?.ptyIdsByLeafId?.[parsed.leafId] + if (!tab || typeof ptyId !== 'string' || ptyId.length === 0) { + return null + } + const incarnationId = session.terminalPtyIncarnationsByPaneKey?.[paneKey] + return { + tabId: parsed.tabId, + leafId: parsed.leafId, + ptyId, + ...(incarnationId ? { incarnationId } : {}) + } +} + +export function resolveStablePaneOwner( + runtime: OrcaRuntimeService | undefined, + store: Store | undefined, + paneKey: string | null | undefined, + worktreeId: string | undefined, + connectionId: string | null | undefined +): StablePaneOwner | null { + if (!paneKey || !worktreeId) { + return null + } + let resolved: ReturnType | null = null + let resolvedHandleCandidate: ReturnType | null = null + if (runtime && typeof runtime.resolveTerminalPane === 'function') { + try { + const candidate = runtime.resolveTerminalPane(paneKey, worktreeId) + resolvedHandleCandidate = candidate + resolved = candidate.connected === false ? null : candidate + } catch (error) { + if (!(error instanceof Error && error.message === 'terminal_not_found')) { + throw error + } + } + } + const persisted = resolvePersistedStablePaneOwner(store, paneKey, worktreeId, connectionId) + if (resolved?.ptyId && persisted && resolved.ptyId !== persisted.ptyId) { + throw new Error('terminal_pane_owner_conflict') + } + const ptyId = resolved?.ptyId ?? persisted?.ptyId + if (!ptyId) { + return null + } + const registeredConnectionId = ptyOwnership.get(ptyId) + const parsedSshId = registeredConnectionId === undefined ? parseAppSshPtyId(ptyId) : null + const ownerConnectionId = registeredConnectionId ?? parsedSshId?.connectionId ?? null + if (ownerConnectionId !== (connectionId ?? null)) { + throw new Error('terminal_pane_owner_host_mismatch') + } + const runtimeIncarnationId = ptyIncarnationById.get(ptyId) + const parsed = parsePaneKey(paneKey) + if (!parsed) { + return null + } + return { + ...(resolvedHandleCandidate?.ptyId === ptyId ? { handle: resolvedHandleCandidate.handle } : {}), + tabId: resolved?.tabId || persisted?.tabId || parsed.tabId, + leafId: resolved?.leafId || persisted?.leafId || parsed.leafId, + ptyId, + ...(runtimeIncarnationId || persisted?.incarnationId + ? { incarnationId: runtimeIncarnationId ?? persisted?.incarnationId } + : {}), + ...(persisted ? { hasPersistedBinding: true as const } : {}), + ...(persisted?.incarnationId ? { persistedIncarnationId: persisted.incarnationId } : {}), + ...(runtimeIncarnationId ? { runtimeIncarnationId } : {}) + } +} + +export function retirePersistedStablePaneOwner( + store: Store | undefined, + owner: StablePaneOwner, + worktreeId: string, + connectionId: string | null | undefined +): boolean { + if (!store) { + return false + } + const paneKey = makePaneKey(owner.tabId, owner.leafId) + const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined + const current = resolvePersistedStablePaneOwner(store, paneKey, worktreeId, connectionId) + if (!current) { + // Why: persistence already dropped this pane binding (an earlier stop retired it while the + // runtime kept history), so there is nothing left to clear — that is a completed retirement, + // not a competing owner. Reporting failure here strands the pane after its PTY is proven dead. + return true + } + if (current.ptyId !== owner.ptyId || current.incarnationId !== owner.persistedIncarnationId) { + return false + } + const session = store.getWorkspaceSession(hostId) + const retired = retireTerminalSurfaceFromPersistence(session, { + worktreeId, + parentTabId: owner.tabId, + leafId: owner.leafId, + ptyId: owner.ptyId, + ...(current.incarnationId ? { incarnationId: current.incarnationId } : {}) + }) + if (retired === session) { + return false + } + store.setWorkspaceSession(retired, hostId) + store.flushOrThrow() + return true +} + +export type StablePaneSpawnContext = { + runtime: OrcaRuntimeService | undefined + store?: Store + provider: IPtyProvider + spawnOptions: PtySpawnOptions + owner: StablePaneOwner | null + worktreeId?: string + connectionId?: string | null + resolveOwner?: () => StablePaneOwner | null + onFreshSpawn?: (result: PtySpawnResult) => void +} + +export function stablePanePersistenceFence( + owner: StablePaneOwner | null +): { ptyId: string; incarnationId?: string } | undefined { + return owner?.hasPersistedBinding + ? { + ptyId: owner.ptyId, + ...(owner.persistedIncarnationId ? { incarnationId: owner.persistedIncarnationId } : {}) + } + : undefined +} + +export function persistAdmittedStablePaneBinding(args: { + store: Store | undefined + owner: StablePaneOwner | null + result: PtySpawnResult + worktreeId: string | undefined + startupCwd: string | undefined + connectionId: string | null | undefined +}): boolean { + const expectedBinding = stablePanePersistenceFence(args.owner) + if (!args.store || !args.owner || !args.worktreeId || !expectedBinding) { + return false + } + const persisted = args.store.persistPtyBinding( + { + worktreeId: args.worktreeId, + tabId: args.owner.tabId, + leafId: args.owner.leafId, + ptyId: args.result.id, + ...(args.result.incarnationId ? { incarnationId: args.result.incarnationId } : {}), + ...(args.startupCwd ? { startupCwd: args.startupCwd } : {}), + expectedBinding + }, + args.connectionId ? toSshExecutionHostId(args.connectionId) : undefined + ) + if (persisted === false) { + throw new Error('terminal_pane_owner_changed') + } + return true +} + +export async function attachStablePaneOwner( + args: StablePaneSpawnContext & { owner: StablePaneOwner } +): Promise<{ result: PtySpawnResult; owner: StablePaneOwner } | null> { + const { owner, provider, runtime, spawnOptions } = args + let result: PtySpawnResult + try { + result = await provider.spawn({ + ...spawnOptions, + sessionId: owner.ptyId, + attachOnly: true, + expectedIncarnationId: owner.runtimeIncarnationId ?? owner.persistedIncarnationId, + expectedIncarnationIsAuthoritative: owner.runtimeIncarnationId !== undefined, + isNewSession: undefined, + command: undefined, + commandDelivery: undefined, + startupCommandDelivery: undefined, + launchAgent: undefined, + startupIngress: undefined, + agentSessionEnsure: undefined, + agentSessionCreateOperationId: undefined, + onPtySpawnCommitted: undefined + }) + } catch (error) { + if (error instanceof TerminalSessionOwnerUnverifiedError) { + throw new Error('terminal_pane_owner_unverified') + } + // Why: translate before paired-runtime RPC strips the socket error's code and syscall. + if (isDaemonEndpointGoneError(error)) { + throw new TerminalHostGoneError() + } + if (!isPtyAlreadyGoneError(error)) { + throw error + } + const ownerBeforeRetire = args.resolveOwner?.() + if ( + ownerBeforeRetire && + (ownerBeforeRetire.ptyId !== owner.ptyId || + ownerBeforeRetire.runtimeIncarnationId !== owner.runtimeIncarnationId || + ownerBeforeRetire.hasPersistedBinding !== owner.hasPersistedBinding || + ownerBeforeRetire.persistedIncarnationId !== owner.persistedIncarnationId) + ) { + throw new Error('terminal_pane_owner_changed') + } + runtime?.onPtyExit(owner.ptyId, 0, owner.incarnationId) + clearProviderPtyState(owner.ptyId) + ptyOwnership.delete(owner.ptyId) + if ( + args.worktreeId && + !retirePersistedStablePaneOwner(args.store, owner, args.worktreeId, args.connectionId) + ) { + throw new Error('terminal_pane_owner_changed') + } + if (args.resolveOwner?.()) { + throw new Error('terminal_pane_owner_changed') + } + return null + } + if ( + result.id !== owner.ptyId || + result.isReattach !== true || + (owner.runtimeIncarnationId !== undefined && + result.incarnationId !== owner.runtimeIncarnationId) || + (result.incarnationId === undefined && owner.incarnationId !== undefined) + ) { + throw new Error('terminal_pane_owner_changed') + } + return { result, owner } +} + +export async function spawnForStablePane( + args: StablePaneSpawnContext +): Promise<{ result: PtySpawnResult; owner: StablePaneOwner | null }> { + if (args.owner) { + const attached = await attachStablePaneOwner({ ...args, owner: args.owner }) + if (attached) { + return attached + } + } + const result = await args.provider.spawn(args.spawnOptions) + args.onFreshSpawn?.(result) + return { result, owner: null } +} diff --git a/src/main/ipc/pty/provider/bind-listeners.ts b/src/main/ipc/pty/provider/bind-listeners.ts new file mode 100644 index 00000000000..8febd1e3534 --- /dev/null +++ b/src/main/ipc/pty/provider/bind-listeners.ts @@ -0,0 +1,111 @@ +import { markClaudePtyExited } from '../../../claude-accounts/live-pty-gate' +import { LocalPtyProvider } from '../../../providers/local-pty-provider' +import { isCurrentPtyExit, ptyOwnership } from './ownership-state' +import { + localBackgroundStreamUnsub, + localDataUnsub, + localExitUnsub, + localWriteUnavailableUnsub, + setLocalBackgroundStreamUnsub, + setLocalDataUnsub, + setLocalExitUnsub, + setLocalWriteUnavailableUnsub +} from './listener-lifecycle' +import { localProvider } from './registry' +import { clearProviderPtyState } from './state-cleanup' +import { providerSnapshotRequiredPtys } from '../delivery/visibility-state' +import type { PtyIpcSession } from '../session' + +export function bindProviderListeners(session: PtyIpcSession): void { + localDataUnsub?.() + localExitUnsub?.() + localBackgroundStreamUnsub?.() + localWriteUnavailableUnsub?.() + + // Why: a daemon death takes down every session at once. The provider signals + // each affected pane here so background panes remount + re-attach too, not + // just the pane whose write happened to detect the dead endpoint (STA-2373). + setLocalWriteUnavailableUnsub( + localProvider.onWriteUnavailable?.((payload) => { + if ( + session.mainWindow.isDestroyed() || + (typeof session.mainWindow.webContents.isDestroyed === 'function' && + session.mainWindow.webContents.isDestroyed()) + ) { + return + } + session.mainWindow.webContents.send('pty:writeUnavailable', { id: payload.id }) + }) ?? null + ) + + // Daemon keep-tail thinning facts, in byte order with onData: markers flip transient-fact scan authority; a gap forces renderer restore from the snapshot. + setLocalBackgroundStreamUnsub( + localProvider.onBackgroundStreamEvent?.((payload) => { + if (payload.kind === 'backgroundMarker') { + session.runtime?.setPtyTransientFactDelegation( + payload.id, + payload.background, + payload.scanSeedAnsi, + payload.mode2031PendingSubscribe + ) + return + } + if (payload.kind === 'dataGap') { + providerSnapshotRequiredPtys.add(payload.id) + session.runtime?.notePtyDataGap(payload.id, payload.sequenceChars ?? payload.droppedChars) + session.sendModelRestoreNeededMarker( + payload.id, + 'hidden-drop', + session.runtime?.getPtyOutputSequence(payload.id) + ) + return + } + session.runtime?.emitDaemonPtyTransientFact(payload.id, payload.fact) + }) ?? null + ) + + // Why: daemon providers lack configure().onData, so feed the runtime here or their tail buffer (terminal.read, agent-detection, mobile stream) stays empty. + const isLocalProvider = localProvider instanceof LocalPtyProvider + + setLocalDataUnsub( + localProvider.onData((payload) => { + const rawLength = payload.sequenceChars ?? payload.data.length + const outputSeq = isLocalProvider + ? session.runtime?.getPtyOutputSequence(payload.id) + : session.runtime?.onPtyData( + payload.id, + payload.data, + Date.now(), + rawLength, + payload.transformed + ) + session.acceptPtyDataForRenderer(payload, outputSeq) + }) + ) + setLocalExitUnsub( + localProvider.onExit((payload) => { + if (!isCurrentPtyExit(payload)) { + return + } + if (session.consumeSyntheticKillExit(payload.id)) { + return + } + if (!isLocalProvider) { + clearProviderPtyState(payload.id) + ptyOwnership.delete(payload.id) + markClaudePtyExited(payload.id) + session.runtime?.onPtyExit(payload.id, payload.code, payload.incarnationId, { + providerExitObserved: true, + ...(payload.cause ? { cause: payload.cause } : {}) + }) + } + // Why not the whole payload: the exit cause is a main-process fact for the + // runtime's records; the renderer's pty:exit contract stays as it was. + session.sendPtyExitToRenderer({ + id: payload.id, + code: payload.code, + ...(payload.incarnationId ? { incarnationId: payload.incarnationId } : {}) + }) + }) + ) +} diff --git a/src/main/ipc/pty/provider/listener-lifecycle.ts b/src/main/ipc/pty/provider/listener-lifecycle.ts new file mode 100644 index 00000000000..d6cb383c9a3 --- /dev/null +++ b/src/main/ipc/pty/provider/listener-lifecycle.ts @@ -0,0 +1,106 @@ +import type { WebContents } from 'electron' + +// Why: localProvider.onData/onExit return unsubscribe functions. Without +// storing and calling these on re-registration, macOS app re-activation +// creates a new BrowserWindow and re-calls registerPtyHandlers, leaking +// duplicate listeners that forward every event twice. +export let localDataUnsub: (() => void) | null = null +export let localExitUnsub: (() => void) | null = null +export let localBackgroundStreamUnsub: (() => void) | null = null +export let localWriteUnavailableUnsub: (() => void) | null = null +export let didFinishLoadHandler: (() => void) | null = null +export let didFinishLoadWebContents: WebContents | null = null +export let rendererLifecycleResetWebContents: WebContents | null = null +export let rendererLifecycleResetHandler: (() => void) | null = null +// Why: the hidden-delivery gate registries mirror renderer state; a reload/crash destroys owners without unregistering, so they reset when the renderer is replaced (drop memory preserved). +export let rendererGateResetLoadHandler: (() => void) | null = null +export let rendererGateResetGoneHandler: (() => void) | null = null +export let rendererGateResetWebContents: WebContents | null = null +// Why: the backgrounded-delivery dedupe map lives in the registerPtyHandlers closure but teardown funnels through module-scope clearProviderPtyState. +export let clearBackgroundedDeliverySyncForPty: (id: string) => void = () => {} + +export type RendererNavigationDetails = { + isMainFrame: boolean + isSameDocument: boolean +} + +// Why: navigation details identify the triggering frame; querying aggregate load state can misclassify an overlapping subframe load. +export let rendererDidStartNavigationHandler: + | ((details: RendererNavigationDetails) => void) + | null = null + +// Why: Restart daemon must re-bind provider→renderer listeners after replaceDaemonProvider swaps localProvider, else subscribers stay bound to the disposed adapter and new PTY data silently drops. +export let rebindProviderListeners: (() => void) | null = null +export let sshOutputIntakeCleanup: (() => void) | null = null + +export function rebindLocalProviderListeners(): void { + rebindProviderListeners?.() +} + +export function setRebindProviderListeners(fn: (() => void) | null): void { + rebindProviderListeners = fn +} + +export function setClearBackgroundedDeliverySyncForPty(fn: (id: string) => void): void { + clearBackgroundedDeliverySyncForPty = fn +} + +export function setSshOutputIntakeCleanup(fn: (() => void) | null): void { + sshOutputIntakeCleanup = fn +} + +export function setLocalDataUnsub(fn: (() => void) | null): void { + localDataUnsub = fn +} + +export function setLocalExitUnsub(fn: (() => void) | null): void { + localExitUnsub = fn +} + +export function setLocalBackgroundStreamUnsub(fn: (() => void) | null): void { + localBackgroundStreamUnsub = fn +} + +export function setLocalWriteUnavailableUnsub(fn: (() => void) | null): void { + localWriteUnavailableUnsub = fn +} + +export function setDidFinishLoadHandler( + handler: (() => void) | null, + contents: WebContents | null +): void { + didFinishLoadHandler = handler + didFinishLoadWebContents = contents +} + +export function setRendererLifecycleResetState(args: { + contents: WebContents | null + handler: (() => void) | null + navigation: ((details: RendererNavigationDetails) => void) | null +}): void { + rendererLifecycleResetWebContents = args.contents + rendererLifecycleResetHandler = args.handler + rendererDidStartNavigationHandler = args.navigation +} + +export function setRendererGateResetState(args: { + contents: WebContents | null + load: (() => void) | null + gone: (() => void) | null +}): void { + rendererGateResetWebContents = args.contents + rendererGateResetLoadHandler = args.load + rendererGateResetGoneHandler = args.gone +} + +// Why: Restart daemon must detach listeners AFTER synthetic pty:exit events fan out but BEFORE replaceDaemonProvider swaps the adapter; this export narrows that window to the caller. +export function unbindLocalProviderListeners(): void { + localDataUnsub?.() + localExitUnsub?.() + localBackgroundStreamUnsub?.() + localWriteUnavailableUnsub?.() + localDataUnsub = null + localExitUnsub = null + localBackgroundStreamUnsub = null + localWriteUnavailableUnsub = null +} diff --git a/src/main/ipc/pty/provider/liveness.ts b/src/main/ipc/pty/provider/liveness.ts new file mode 100644 index 00000000000..a8c005cce06 --- /dev/null +++ b/src/main/ipc/pty/provider/liveness.ts @@ -0,0 +1,142 @@ +import { isRemoteAgentHooksEnabled } from '../../../../shared/agent-hook-relay' +import type { AgentSessionOwnerBinding } from '../../../../shared/agent-session-host-authority' +import { agentSessionOwnerBindingsEqual } from '../../../../shared/claimed-agent-pty-owner' +import { addNodePtyRecoveryHint } from '../../../daemon/node-pty-error-hints' +import type { Store } from '../../../persistence' +import { isSshPtyNotFoundError } from '../../../providers/ssh-pty-errors' +import type { IPtyProvider } from '../../../providers/types' +import { markClaudePtyExited } from '../../../claude-accounts/live-pty-gate' +import { ptyIncarnationById, ptyOwnership } from './ownership-state' +import { getRelayPtyId } from './registry' +import { + KEEP_HISTORY_STOP_POLL_MS, + KEEP_HISTORY_STOP_SETTLE_MS +} from '../delivery/visibility-state' +import { clearProviderPtyState } from './state-cleanup' + +export function stripRemotePaneEnvWhenHooksDisabled( + connectionId: string | null | undefined, + env: Record | undefined +): Record | undefined { + if (!connectionId || isRemoteAgentHooksEnabled()) { + return env + } + if ( + !env || + (!('ORCA_PANE_KEY' in env) && + !('ORCA_TAB_ID' in env) && + !('ORCA_WORKTREE_ID' in env) && + !('ORCA_AGENT_LAUNCH_TOKEN' in env)) + ) { + return env + } + const stripped = { ...env } + delete stripped.ORCA_PANE_KEY + delete stripped.ORCA_TAB_ID + delete stripped.ORCA_WORKTREE_ID + delete stripped.ORCA_AGENT_LAUNCH_TOKEN + return stripped +} + +export function normalizeNodePtySpawnError(err: unknown): Error { + const rawMessage = err instanceof Error ? err.message : String(err) + const hintedMessage = addNodePtyRecoveryHint(rawMessage) + if (hintedMessage === rawMessage && err instanceof Error) { + return err + } + if (err instanceof Error) { + // Why: preserve the original stack/name/custom fields while adding the same recovery hint as the pty:spawn path. + err.message = hintedMessage + return err + } + return new Error(hintedMessage) +} + +export function isPtyAlreadyGoneError(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err) + return isSshPtyNotFoundError(err) || /Session not found/i.test(message) +} + +export function delay(ms: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms) + if (typeof timer.unref === 'function') { + timer.unref() + } + }) +} + +export async function isProviderPtyLive( + provider: IPtyProvider, + ptyId: string, + deadlineMs?: number +): Promise { + // Why: bound the liveness list RPC by the teardown deadline so a wedged daemon + // fails fast; undefined keeps the provider default for all other callers. + return (await provider.listProcesses(deadlineMs !== undefined ? { deadlineMs } : undefined)).some( + (session) => session.id === ptyId + ) +} + +export async function isProviderAgentSessionOwnerLive( + provider: IPtyProvider, + owner: AgentSessionOwnerBinding +): Promise { + const session = (await provider.listProcesses()).find((candidate) => candidate.id === owner.ptyId) + if (!session) { + return false + } + if (provider.providesAgentSessionOwnerListings?.(owner.ptyId) !== true) { + // Why: in-process local owners cannot serialize the controller claim; exact incarnation + // liveness keeps that claim authoritative until the normal PTY exit releases it. + const expectedIncarnation = ptyIncarnationById.get(owner.ptyId) + return expectedIncarnation !== undefined && session.incarnationId === expectedIncarnation + } + return Boolean( + session.agentSessionOwners?.some((candidate) => + agentSessionOwnerBindingsEqual(candidate, owner) + ) + ) +} + +export async function verifyPtyStopped( + provider: IPtyProvider, + ptyId: string, + opts: { keepHistory?: boolean; deadlineMs?: number } | undefined +): Promise { + if (await isProviderPtyLive(provider, ptyId, opts?.deadlineMs)) { + return false + } + if (!opts?.keepHistory) { + return true + } + const settleDeadline = Date.now() + KEEP_HISTORY_STOP_SETTLE_MS + // Why: deadlineMs is absolute, so the settle poll must not outlive the caller's teardown budget. + const deadline = + opts.deadlineMs !== undefined ? Math.min(settleDeadline, opts.deadlineMs) : settleDeadline + while (Date.now() < deadline) { + await delay(Math.min(KEEP_HISTORY_STOP_POLL_MS, deadline - Date.now())) + if (Date.now() >= deadline) { + break + } + if (await isProviderPtyLive(provider, ptyId, deadline)) { + return false + } + } + return true +} + +export function finishPtyShutdown( + id: string, + connectionId: string | null | undefined, + store: Store | undefined +): string | undefined { + const incarnationId = ptyIncarnationById.get(id) + clearProviderPtyState(id) + if (connectionId) { + store?.markSshRemotePtyLease(connectionId, getRelayPtyId(connectionId, id), 'terminated') + } + ptyOwnership.delete(id) + markClaudePtyExited(id) + return incarnationId +} diff --git a/src/main/ipc/pty/provider/local-configure.ts b/src/main/ipc/pty/provider/local-configure.ts new file mode 100644 index 00000000000..2051d937c94 --- /dev/null +++ b/src/main/ipc/pty/provider/local-configure.ts @@ -0,0 +1,115 @@ +import { getAppEnvironment } from '../../../../shared/app-environment' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { isAgentStatusHooksEnabled } from '../../../agent-hooks/managed-agent-hook-controls' +import { isPwshAvailableAsync } from '../../../pwsh' +import { LocalPtyProvider } from '../../../providers/local-pty-provider' +import { + addOrcaWslInteropEnv, + stampWslOrchestrationCompatibilityHost +} from '../../../pty/wsl-orca-env' +import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' +import { markClaudePtyExited } from '../../../claude-accounts/live-pty-gate' +import { buildPtyHostEnv } from '../host-env/assembly' +import { + getCompatibleSelectedCodexHomePath, + isCodexStatusHooksEnabled, + shouldStripInheritedOrcaCodexHome +} from '../host-env/codex-home' +import type { GetSelectedCodexHomePath } from '../host-env/types' +import { isCurrentPtyExit, ptyOwnership } from './ownership-state' +import { localProvider } from './registry' +import { clearProviderPtyState } from './state-cleanup' + +export function configureLocalPtyProvider(args: { + runtime?: OrcaRuntimeService + getSettings?: () => GlobalSettings + getSelectedCodexHomePath?: GetSelectedCodexHomePath + trustedTerminalHandleEnv: Set +}): void { + // Why: only LocalPtyProvider needs main-process hook injection; daemon-backed providers spawn subprocesses internally. + if (!(localProvider instanceof LocalPtyProvider)) { + return + } + const { runtime, getSettings, getSelectedCodexHomePath, trustedTerminalHandleEnv } = args + localProvider.configure({ + isHistoryEnabled: () => getSettings?.()?.terminalScopeHistoryByWorktree ?? true, + getWindowsShell: () => getSettings?.()?.terminalWindowsShell, + getWindowsPowerShellImplementation: () => + getSettings ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') : undefined, + pwshAvailable: () => isPwshAvailableAsync(), + buildSpawnEnv: (id, baseEnv, ctx) => { + const codexSelectionTarget: CodexAccountSelectionTarget = + ctx?.isWsl === true + ? { runtime: 'wsl', wslDistro: ctx.wslDistro ?? null } + : { runtime: 'host' } + const selectedCodexHomePath = getCompatibleSelectedCodexHomePath( + codexSelectionTarget, + ctx?.codexHomePathOverride + ? ctx.codexHomePathOverride.value + : (getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { + workspacePath: ctx?.cwd, + launchAgent: ctx?.launchAgent + }) ?? null) + ) + const skipCodexHomeEnv = ctx?.isWsl === true && !selectedCodexHomePath + const ptySettings = getSettings?.() + const env = buildPtyHostEnv(id, baseEnv, { + isPackaged: getAppEnvironment().isPackaged(), + resourcesPath: process.resourcesPath, + userDataPath: getAppEnvironment().getPath('userData'), + selectedCodexHomePath, + skipCodexHomeEnv, + stripInheritedOrcaCodexHome: shouldStripInheritedOrcaCodexHome({ + target: codexSelectionTarget, + selectedCodexHomePath, + skipCodexHomeEnv, + settings: ptySettings + }), + launchCommand: ctx?.command, + launchAgent: ctx?.launchAgent, + isWsl: ctx?.isWsl, + wslDistro: ctx?.wslDistro ?? null, + agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings), + codexStatusHooksEnabled: isCodexStatusHooksEnabled(ptySettings), + networkProxySettings: ptySettings + }) + // Why: agents need their terminal handle at process start to self-identify in orchestration messages without an extra RPC. + const requestedHandle = baseEnv.ORCA_TERMINAL_HANDLE + const preAllocatedHandle = + requestedHandle && trustedTerminalHandleEnv.has(requestedHandle) + ? requestedHandle + : runtime?.preAllocateHandleForPty(id) + if (requestedHandle && requestedHandle !== preAllocatedHandle) { + delete env.ORCA_TERMINAL_HANDLE + } + if (preAllocatedHandle) { + env.ORCA_TERMINAL_HANDLE = preAllocatedHandle + } + stampWslOrchestrationCompatibilityHost( + env, + runtime?.getOrchestrationCompatibilityHostId?.(), + ctx?.isWsl === true ? ctx.wslDistro : null + ) + if (ctx?.isWsl === true) { + addOrcaWslInteropEnv(env) + } + return env + }, + onSpawned: (id, incarnationId) => runtime?.onPtySpawned(id, incarnationId), + onExit: (id, code, incarnationId, cause) => { + if (!isCurrentPtyExit({ id, incarnationId })) { + return + } + clearProviderPtyState(id) + ptyOwnership.delete(id) + markClaudePtyExited(id) + runtime?.onPtyExit(id, code, incarnationId, { + providerExitObserved: true, + ...(cause ? { cause } : {}) + }) + }, + onData: (id, data, timestamp, sequenceChars, transformed) => + runtime?.onPtyData(id, data, timestamp, sequenceChars ?? data.length, transformed) + }) +} diff --git a/src/main/ipc/pty/provider/ownership-state.ts b/src/main/ipc/pty/provider/ownership-state.ts new file mode 100644 index 00000000000..7d5ef3dcbb7 --- /dev/null +++ b/src/main/ipc/pty/provider/ownership-state.ts @@ -0,0 +1,35 @@ +import { isPtyIncarnationId } from '../../../../shared/pty-incarnation' + +// Why: post-spawn write/resize/kill calls carry only the PTY ID; map it to its connectionId so ops route to the right provider. +export const ptyOwnership = new Map() +export const ptyIncarnationById = new Map() + +export function isCurrentPtyExit(payload: { id: string; incarnationId?: string }): boolean { + const current = ptyIncarnationById.get(payload.id) + return !current || payload.incarnationId === current +} + +export function deletePtyOwnership(id: string): void { + ptyOwnership.delete(id) +} + +export function setPtyOwnership(id: string, connectionId: string | null): void { + ptyOwnership.set(id, connectionId) +} + +export function restorePtyIncarnation(id: string, incarnationId: string): void { + if (!isPtyIncarnationId(incarnationId)) { + throw new Error('Invalid PTY incarnation') + } + ptyIncarnationById.set(id, incarnationId) +} + +export function getPtyIdsForConnection(connectionId: string): string[] { + const ids: string[] = [] + for (const [ptyId, connId] of ptyOwnership) { + if (connId === connectionId) { + ids.push(ptyId) + } + } + return ids +} diff --git a/src/main/ipc/pty/provider/registry.ts b/src/main/ipc/pty/provider/registry.ts new file mode 100644 index 00000000000..85c8a3514db --- /dev/null +++ b/src/main/ipc/pty/provider/registry.ts @@ -0,0 +1,131 @@ +import { LocalPtyProvider } from '../../../providers/local-pty-provider' +import type { IPtyProvider } from '../../../providers/types' +import { parseAppSshPtyId, toAppSshPtyId, toRelaySshPtyId } from '../../../providers/ssh-pty-id' +import { ptyOwnership } from './ownership-state' + +// ─── Provider Registry ────────────────────────────────────────────── +// Routes PTY operations by connectionId (null = local provider). + +export let localProvider: IPtyProvider = new LocalPtyProvider() +export const sshProviders = new Map() +export const sshProvidersByGeneration = new Map() + +export type RegisteredPtyProvider = { + provider: IPtyProvider + connectionId: string | null +} + +export function registeredPtyProviders(): RegisteredPtyProvider[] { + return [ + { provider: localProvider, connectionId: null }, + ...Array.from(sshProviders, ([connectionId, provider]) => ({ provider, connectionId })) + ] +} + +export function getProvider(connectionId: string | null | undefined): IPtyProvider { + if (!connectionId) { + return localProvider + } + const provider = sshProviders.get(connectionId) + if (!provider) { + throw new Error(`No PTY provider for connection "${connectionId}"`) + } + return provider +} + +export function getProviderForPty(ptyId: string): IPtyProvider { + const connectionId = ptyOwnership.get(ptyId) + if (connectionId === undefined) { + const parsedSshId = parseAppSshPtyId(ptyId) + if (parsedSshId) { + // Why: disconnected SSH PTYs retain their encoded owner and must never fall through to the HUB-local provider. + return getProvider(parsedSshId.connectionId) + } + return localProvider + } + return getProvider(connectionId) +} + +export function hasPtyProviderForInspection(ptyId: string): boolean { + // Why: process inspection is background polling; disconnected SSH hosts should read as idle, not raise repeated IPC errors. + const connectionId = ptyOwnership.get(ptyId) + if (connectionId === undefined) { + // Why: mirror getProviderForPty — an unowned id still routes by its encoded SSH owner. + const parsedSshId = parseAppSshPtyId(ptyId) + return !parsedSshId || sshProviders.has(parsedSshId.connectionId) + } + return connectionId === null || sshProviders.has(connectionId) +} + +export function getAppPtyId(connectionId: string | null | undefined, ptyId: string): string { + return connectionId ? toAppSshPtyId(connectionId, ptyId) : ptyId +} + +export function getRelayPtyId(connectionId: string | null | undefined, ptyId: string): string { + return connectionId ? toRelaySshPtyId(connectionId, ptyId) : ptyId +} + +export function tryGetProviderForPty(ptyId: string): IPtyProvider | undefined { + try { + return getProviderForPty(ptyId) + } catch { + return undefined + } +} + +export function closeStartupQueryAuthorityForPty(ptyId: string): void { + try { + void Promise.resolve(tryGetProviderForPty(ptyId)?.closeStartupQueryAuthority?.(ptyId)).catch( + () => {} + ) + } catch { + /* Best-effort handoff; the bounded source deadline remains the fallback. */ + } +} + +export function tryGetProviderForAgentSessionOwner(ptyId: string): IPtyProvider | undefined { + const ownedConnectionId = ptyOwnership.get(ptyId) + const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(ptyId) : null + try { + return getProvider(parsedSshId?.connectionId ?? ownedConnectionId) + } catch { + return undefined + } +} + +/** Register an SSH PTY provider for a connection. */ +export function registerSshPtyProvider(connectionId: string, provider: IPtyProvider): void { + sshProviders.set(connectionId, provider) + const generation = (provider as { providerGeneration?: number }).providerGeneration + if (Number.isSafeInteger(generation) && generation! > 0) { + sshProvidersByGeneration.set(generation!, provider) + } +} + +/** Remove an SSH PTY provider when a connection is closed. */ +export function unregisterSshPtyProvider(connectionId: string): void { + const provider = sshProviders.get(connectionId) + const generation = (provider as { providerGeneration?: number } | undefined)?.providerGeneration + if (generation !== undefined && sshProvidersByGeneration.get(generation) === provider) { + sshProvidersByGeneration.delete(generation) + } + sshProviders.delete(connectionId) +} + +/** Get the SSH PTY provider for a connection (for dispose on cleanup). */ +export function getSshPtyProvider(connectionId: string): IPtyProvider | undefined { + return sshProviders.get(connectionId) +} + +/** Get the installed PTY provider (for direct access in tests/runtime). + * After daemon init this may be a DaemonPtyAdapter/DaemonPtyRouter, not LocalPtyProvider; + * callers needing LocalPtyProvider-specific methods must type-narrow or import the class. */ +export function getLocalPtyProvider(): IPtyProvider { + return localProvider +} + +/** Replace the local PTY provider with a daemon-backed one. + * Call before registerPtyHandlers so the IPC layer routes through the daemon. */ +export function setLocalPtyProvider(provider: IPtyProvider): void { + localProvider = provider +} diff --git a/src/main/ipc/pty/provider/shutdown-detect.ts b/src/main/ipc/pty/provider/shutdown-detect.ts new file mode 100644 index 00000000000..3972f94880e --- /dev/null +++ b/src/main/ipc/pty/provider/shutdown-detect.ts @@ -0,0 +1,25 @@ +import type { IPtyProvider } from '../../../providers/types' +import { ptyIncarnationById } from './ownership-state' + +export async function shutdownProviderAndDetectExit( + provider: IPtyProvider, + id: string, + opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } +): Promise { + let providerExitObserved = false + const expectedIncarnationId = ptyIncarnationById.get(id) + const unsubscribe = provider.onExit((payload) => { + if ( + payload.id === id && + (!expectedIncarnationId || payload.incarnationId === expectedIncarnationId) + ) { + providerExitObserved = true + } + }) + try { + await provider.shutdown(id, opts) + } finally { + unsubscribe() + } + return providerExitObserved +} diff --git a/src/main/ipc/pty/provider/state-cleanup.ts b/src/main/ipc/pty/provider/state-cleanup.ts new file mode 100644 index 00000000000..72286eb0a4d --- /dev/null +++ b/src/main/ipc/pty/provider/state-cleanup.ts @@ -0,0 +1,124 @@ +import { advertisedUrlWatcher } from '../../../ports/advertised-url-watcher' +import { unregisterPty } from '../../../memory/pty-registry' +import { markClaudePtyExited } from '../../../claude-accounts/live-pty-gate' +import { forgetCodexPaneAccount } from '../../../codex/codex-pane-account-registry' +import { openCodeHookService } from '../../../opencode/hook-service' +import { piTitlebarExtensionService } from '../../../pi/titlebar-extension-service' +import { agentHookServer } from '../../../agent-hooks/server' +import { clearMigrationUnsupportedPty } from '../../../agent-hooks/migration-unsupported-pty-state' +import { clearNativeWindowsConptyPty } from '../../../runtime/terminal-model-query-authority' +import { + clearHiddenRendererPtyDeliveryState, + isHiddenRendererPty +} from '../../pty-hidden-delivery-gate' +import { agentSessionOwners } from '../pane/agent-session-owners' +import { paneKeyPtyId, paneKeyTeardownListeners, ptyPaneKey } from '../pane/key-state' +import { ptyIncarnationById, ptyOwnership } from './ownership-state' +import { clearBackgroundedDeliverySyncForPty } from './listener-lifecycle' +import { + activeRendererPtys, + deliveredHiddenRendererResizeOutputPtys, + interactiveOutputCharsByPty, + invalidatePendingPtyDrainPolicy, + invalidatePendingPtyDrainPriority, + lastInputAtByPty, + pendingHiddenRendererResizeOutputPtys, + providerSnapshotRequiredPtys, + ptySizes, + rendererVisibilityKnownPtys, + visibleRendererPtys +} from '../delivery/visibility-state' + +/** + * Tear down per-PTY provider-scoped state. + * + * Claimed agent owners remain fenced because the relay process may survive and + * prove the exact same generation after reconnect. + */ +export function clearProviderPtyState( + id: string, + opts: { preserveAgentSessionOwners?: boolean } = {} +): void { + if (!opts.preserveAgentSessionOwners) { + agentSessionOwners.release(id) + // Why: the launch-account record outlives the app, so only a real teardown + // may drop it — a disconnect that can reconnect is not a death, and a reused + // id must never inherit a dead pane's Codex account. + forgetCodexPaneAccount(id) + } + // Why: OpenCode and Pi both allocate PTY-scoped runtime state outside the + // node-pty process table. Centralizing provider cleanup avoids drift where a + // new teardown path forgets to remove one provider's overlay/hook state. + openCodeHookService.clearPty(id) + piTitlebarExtensionService.clearPty(id) + // Why: SSH exit/teardown paths bypass pty.ts's local onExit but still must release Claude account-switch guards. + markClaudePtyExited(id) + ptySizes.delete(id) + ptyIncarnationById.delete(id) + lastInputAtByPty.delete(id) + interactiveOutputCharsByPty.delete(id) + const activeChanged = activeRendererPtys.delete(id) + visibleRendererPtys.delete(id) + rendererVisibilityKnownPtys.delete(id) + pendingHiddenRendererResizeOutputPtys.delete(id) + deliveredHiddenRendererResizeOutputPtys.delete(id) + // Why: every teardown path funnels through here — hidden/interest gate bits must not outlive the PTY or a reused map entry could silently gate a new one. + const deliveryPolicyChanged = isHiddenRendererPty(id) + clearHiddenRendererPtyDeliveryState(id) + if (activeChanged) { + invalidatePendingPtyDrainPriority(id, false) + } + if (deliveryPolicyChanged) { + invalidatePendingPtyDrainPolicy(id, false) + } + clearBackgroundedDeliverySyncForPty(id) + providerSnapshotRequiredPtys.delete(id) + // Why: the Phase-5 ConPTY DA1 spawn record must not leak onto a reused id. + clearNativeWindowsConptyPty(id) + const paneKey = ptyPaneKey.get(id) + const stillOwnsPaneKey = paneKey ? paneKeyPtyId.get(paneKey) === id : false + // Why: drop the memory-collector registration so a dead PTY doesn't resolve its dead pid on every snapshot; no-op for never-registered (SSH-owned) PTYs. + unregisterPty(id) + // Why: cover paths that bypass runtime.onPtyExit (SSH reattach/shutdown, daemon spawn-failure) — else the watcher's per-PTY buffer and worktree binding outlive the PTY. + advertisedUrlWatcher.unbindPty(id) + clearMigrationUnsupportedPty(id) + agentHookServer.clearPaneKeyAliasesForPty(id, { + shouldClearStablePaneKey: (stablePaneKey) => { + // Why: when this PTY never rebuilt ptyPaneKey after restart, alias ownership is our only proof — don't erase a newer PTY that now owns the same stable paneKey. + const stablePaneOwner = paneKeyPtyId.get(stablePaneKey) + if (stablePaneOwner && stablePaneOwner !== id) { + return false + } + return !paneKey || (stillOwnsPaneKey && stablePaneKey === paneKey) + } + }) + // Why: clear the hook server's per-paneKey caches (via the spawn-time paneKey mapping, its only ptyId→paneKey correlation) so dead panes don't accumulate over process lifetime. + if (paneKey) { + if (stillOwnsPaneKey) { + agentHookServer.clearPaneState(paneKey) + paneKeyPtyId.delete(paneKey) + } + ptyPaneKey.delete(id) + if (stillOwnsPaneKey) { + // Why: notify AFTER dropping the paneKey↔ptyId entries so a listener re-reading the map sees post-teardown state; wrap each so one throw can't block the rest. + for (const listener of paneKeyTeardownListeners) { + try { + listener(paneKey) + } catch (err) { + console.error('[pty] paneKey teardown listener threw', err) + } + } + } + } +} + +export function clearPtyOwnershipForConnection(connectionId: string): void { + for (const [ptyId, connId] of ptyOwnership) { + if (connId === connectionId) { + // Why: pane-scoped caches cannot route while disconnected, but claimed + // ownership must survive until reconnect makes absence authoritative. + clearProviderPtyState(ptyId, { preserveAgentSessionOwners: true }) + ptyOwnership.delete(ptyId) + } + } +} diff --git a/src/main/ipc/pty/register-handlers.ts b/src/main/ipc/pty/register-handlers.ts new file mode 100644 index 00000000000..82bd55bc854 --- /dev/null +++ b/src/main/ipc/pty/register-handlers.ts @@ -0,0 +1,267 @@ +import type { BrowserWindow } from 'electron' +import type { OrcaRuntimeService } from '../../runtime/orca-runtime' +import type { Store } from '../../persistence' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import { LocalPtyProvider } from '../../providers/local-pty-provider' +import type { TerminalStartupCwdMissingDirFallback } from '../../../shared/terminal-startup-cwd' +import { + getHiddenRendererPtyDeliveryDebug, + resetRendererScopedHiddenPtyDeliveryState +} from '../pty-hidden-delivery-gate' +import { localProvider } from './provider/registry' +import { finishPtyShutdown } from './provider/liveness' +import type { GetSelectedCodexHomePath, PrepareClaudeAuth } from './host-env/types' +import { installPtyInspectIpcHandlers } from './ipc/inspect' +import { installPtyWriteIpcHandlers } from './ipc/write' +import { installPtySpawnIpcHandler } from './ipc/spawn' +import { installPtyRuntimeController } from './runtime/controller' +import { installPtySnapshotIpcHandlers } from './ipc/snapshot' +import { + assertFolderWorkspacePtyPathUsable as assertFolderWorkspacePtyPathUsableImpl, + localStartupCwdDirectoryExists, + resolvePtySpawnStartupCwd as resolvePtySpawnStartupCwdImpl +} from './host-env/spawn-cwd' +import { + setInvalidatePendingPtyDrainPolicy, + setInvalidatePendingPtyDrainPriority, + invalidatePendingPtyDrainPolicy +} from './delivery/visibility-state' +import { + setRebindProviderListeners, + setDidFinishLoadHandler, + setRendererGateResetState +} from './provider/listener-lifecycle' +import { + resetRendererDeliveryAccountingForLifecycleReset, + setResetRendererDeliveryAccountingForLifecycleReset, + clearRendererDispatcherReadyWatchdog +} from './delivery/debug' +import { + registerRendererLifecycleResetHandlers, + clearRendererGateResetHandlers, + clearDidFinishLoadHandler +} from './delivery/lifecycle-reset' +import { createPtyIpcSession, type PtyIpcSessionOptions } from './session' +import { wirePtyIpcSession } from './delivery/wire-session' +import { configureLocalPtyProvider } from './provider/local-configure' +import { bindProviderListeners } from './provider/bind-listeners' +import { installSessionSshOutputIntake } from './delivery/ssh-intake' +import { installPtySerializeBufferIpc } from './ipc/serialize-buffer' +import { installPtyResizeVisibilityIpc } from './ipc/resize-visibility' +import { adoptStablePane } from './pane/adopt-stable' +import { getPtyIpc } from '../pty-host-bindings' +import { + noCodexResumeLaunch, + prepareCodexResumeHome, + reconcileSharedRuntimeResumeHome, + resolveCodexResumeLaunch, + stripSequencedStartupResumeArgv +} from './host-env/codex-resume' + +export function registerPtyHandlers( + mainWindow: BrowserWindow, + runtime?: OrcaRuntimeService, + getSelectedCodexHomePath?: GetSelectedCodexHomePath, + getSettings?: () => GlobalSettings, + prepareClaudeAuth?: PrepareClaudeAuth, + store?: Store, + options?: PtyIpcSessionOptions +): void { + const ipcMain = getPtyIpc() + // Why first: the outgoing session owns the producer pauses, so its real reset must run + // before the bridge is neutralized or a PTY paused during re-registration stays paused. + resetRendererDeliveryAccountingForLifecycleReset() + // Why: a re-registration means a new window owns delivery — cancel the prior closure's watchdog and neutralize its bridged reset so mark-hidden below can't arm a timer against the dead closure. + clearRendererDispatcherReadyWatchdog() + setResetRendererDeliveryAccountingForLifecycleReset(() => {}) + setInvalidatePendingPtyDrainPriority(() => {}) + setInvalidatePendingPtyDrainPolicy(() => {}) + // Why: neutralize rebind at the same moment as drain so a daemon replace in this window cannot attach the old accept/exit closures. + setRebindProviderListeners(() => {}) + registerRendererLifecycleResetHandlers(mainWindow.webContents) + + const getLocalPtyStartupPromise = (connectionId?: string | null): Promise | undefined => { + if (connectionId) { + return undefined + } + // Why: during cold start the daemon provider swap overlaps first paint, so local spawns must wait; SSH/headless don't use the desktop daemon. + return options?.awaitLocalPtyStartup?.() + } + + const getLocalPtyProviderStartupPromise = ( + connectionId?: string | null + ): Promise | undefined => { + if (connectionId) { + return undefined + } + return options?.awaitLocalPtyProviderStartup?.() ?? options?.awaitLocalPtyStartup?.() + } + + // Remove prior handlers so re-registration (e.g. macOS re-activate creating a new window) doesn't double-register. + ipcMain.removeHandler('pty:spawn') + ipcMain.removeHandler('pty:kill') + ipcMain.removeHandler('pty:listSessions') + ipcMain.removeHandler('pty:hasPty') + ipcMain.removeHandler('pty:hasChildProcesses') + ipcMain.removeHandler('pty:getForegroundProcess') + ipcMain.removeHandler('pty:inspectProcess') + ipcMain.removeHandler('pty:confirmForegroundProcess') + ipcMain.removeHandler('pty:getCwd') + ipcMain.removeHandler('pty:getSize') + ipcMain.removeHandler('pty:getAuthoritativeBufferSnapshotCapabilities') + ipcMain.removeHandler('pty:declarePendingPaneSerializer') + ipcMain.removeHandler('pty:settlePaneSerializer') + ipcMain.removeHandler('pty:clearPendingPaneSerializer') + ipcMain.removeHandler('pty:reportRendererSerializerReady') + ipcMain.removeHandler('pty:getMainBufferSnapshot') + ipcMain.removeHandler('pty:sideEffectSnapshot') + ipcMain.removeHandler('pty:getRendererDeliveryDebugSnapshot') + ipcMain.removeHandler('pty:resetRendererDeliveryDebug') + ipcMain.removeHandler('pty:reportRendererDeliveryState') + ipcMain.removeHandler('pty:writeAccepted') + ipcMain.removeAllListeners('pty:write') + ipcMain.removeAllListeners('pty:ackColdRestore') + ipcMain.removeAllListeners('pty:ackData') + ipcMain.removeAllListeners('pty:deliveryResyncResponse') + ipcMain.removeAllListeners('pty:serializeBuffer:response') + + const session = createPtyIpcSession({ + mainWindow, + runtime, + store, + getSettings, + options + }) + wirePtyIpcSession(session) + configureLocalPtyProvider({ + runtime, + getSettings, + getSelectedCodexHomePath, + trustedTerminalHandleEnv: session.trustedTerminalHandleEnv + }) + installSessionSshOutputIntake(session) + bindProviderListeners(session) + setRebindProviderListeners(() => bindProviderListeners(session)) + installPtySerializeBufferIpc(session) + + // Why: reload/crash orphans delivery-interest holds and hidden marks; reset so surviving PTYs aren't stuck force-fed or gated — each pane's first sync re-marks. + clearRendererGateResetHandlers() + const resetRendererPtyDeliveryGateState = (): void => { + const gateDebug = getHiddenRendererPtyDeliveryDebug() + resetRendererScopedHiddenPtyDeliveryState() + if (gateDebug.hiddenDeliveryGatedPtyCount > 0 || gateDebug.deliveryInterestPtyCount > 0) { + invalidatePendingPtyDrainPolicy() + } + // Why: the daemon pacer must not keep throttling ptys whose hidden marks died with the renderer; the fresh renderer's sync re-marks the still-hidden ones. + session.resyncBackgroundedDeliveriesAfterGateReset() + } + setRendererGateResetState({ + contents: mainWindow.webContents, + load: resetRendererPtyDeliveryGateState, + gone: resetRendererPtyDeliveryGateState + }) + mainWindow.webContents.on('did-finish-load', resetRendererPtyDeliveryGateState) + mainWindow.webContents.on('render-process-gone', resetRendererPtyDeliveryGateState) + + // Why: only LocalPtyProvider PTYs (main-process) can be orphaned on reload; daemon sessions survive by design and cleanup would kill them. + clearDidFinishLoadHandler() + if (localProvider instanceof LocalPtyProvider) { + const lp = localProvider + const finishLoadHandler = () => { + // Why: always advance to keep the generation monotonic, but skip the sweep on crash/freeze-recovery reload — it would kill live local PTYs before session restore (#5787). + const generation = lp.advanceGeneration() + if (options?.isRecoveryReloadInFlight?.(mainWindow.webContents.id)) { + return + } + // Why: the retained provider onExit callback is the only physical-exit proof; it clears ownership after the OS reaps it. + lp.killOrphanedPtys(generation - 1) + } + setDidFinishLoadHandler(finishLoadHandler, mainWindow.webContents) + mainWindow.webContents.on('did-finish-load', finishLoadHandler) + } + + const assertFolderWorkspacePtyPathUsable = ( + worktreeId: string | undefined + ): Promise | void => assertFolderWorkspacePtyPathUsableImpl(store, worktreeId) + const resolvePtySpawnStartupCwd = ( + worktreeId: string | undefined, + cwd: string | undefined, + missingDirFallback?: TerminalStartupCwdMissingDirFallback + ): string | undefined => resolvePtySpawnStartupCwdImpl(store, worktreeId, cwd, missingDirFallback) + const prepareCodexResumeHomeBound = ( + args: Parameters[1] + ): ReturnType => + prepareCodexResumeHome(options?.prepareCodexSessionResume, args) + const adoptStablePaneBound = (args: Parameters[2]) => + adoptStablePane(runtime, store, args) + + // Why: route through getProviderForPty() so CLI commands work for remote PTYs too; localProvider would silently fail for them. + installPtyRuntimeController({ + runtime, + store, + adoptStablePane: adoptStablePaneBound, + getLocalPtyStartupPromise, + getLocalPtyProviderStartupPromise, + prepareCodexResumeHome: prepareCodexResumeHomeBound, + resolveCodexResumeLaunch, + noCodexResumeLaunch, + reconcileSharedRuntimeResumeHome, + stripSequencedStartupResumeArgv, + assertFolderWorkspacePtyPathUsable, + resolvePtySpawnStartupCwd, + requestSerializedBuffer: session.requestSerializedBuffer, + shutdownProviderAndDetectExit: session.shutdownProviderAndDetectExit, + rememberSyntheticKillExit: session.rememberSyntheticKillExit, + rememberRetiredRejectedPty: session.rememberRetiredRejectedPty, + sendPtyExitToRenderer: session.sendPtyExitToRenderer, + sendPtySpawnedToRenderer: session.sendPtySpawnedToRenderer, + finishPtyShutdown, + getSettings, + getSelectedCodexHomePath, + prepareClaudeAuth, + options, + trustedTerminalHandleEnv: session.trustedTerminalHandleEnv, + retiredRejectedPtyIds: session.retiredRejectedPtyIds, + reversibleStopOwnersByPtyId: session.reversibleStopOwnersByPtyId, + mainWindow + }) + + installPtySnapshotIpcHandlers({ runtime, pendingData: session.pendingData }) + installPtySpawnIpcHandler({ + runtime, + store, + getSettings, + getSelectedCodexHomePath, + prepareClaudeAuth, + options, + getLocalPtyStartupPromise, + adoptStablePane: adoptStablePaneBound, + assertFolderWorkspacePtyPathUsable, + resolvePtySpawnStartupCwd, + localStartupCwdDirectoryExists, + prepareCodexResumeHome: prepareCodexResumeHomeBound, + noCodexResumeLaunch, + resolveCodexResumeLaunch, + reconcileSharedRuntimeResumeHome, + stripSequencedStartupResumeArgv, + transitionSpawnHiddenRendererPtyDeliveryState: + session.transitionSpawnHiddenRendererPtyDeliveryState, + trustedTerminalHandleEnv: session.trustedTerminalHandleEnv, + sendPtySpawnedToRenderer: session.sendPtySpawnedToRenderer, + syncPtyBackgroundedDelivery: session.syncPtyBackgroundedDelivery + }) + installPtyWriteIpcHandlers({ + mainWindow, + runtime, + clearHiddenRendererResizeOutput: session.clearHiddenRendererResizeOutput + }) + installPtyResizeVisibilityIpc(session) + installPtyInspectIpcHandlers({ + store, + runtime, + getLocalPtyProviderStartupPromise, + shutdownProviderAndDetectExit: session.shutdownProviderAndDetectExit, + rememberSyntheticKillExit: session.rememberSyntheticKillExit, + sendPtyExitToRenderer: session.sendPtyExitToRenderer + }) +} diff --git a/src/main/ipc/pty/register-headless-runtime.ts b/src/main/ipc/pty/register-headless-runtime.ts new file mode 100644 index 00000000000..f380482a079 --- /dev/null +++ b/src/main/ipc/pty/register-headless-runtime.ts @@ -0,0 +1,47 @@ +import type { BrowserWindow } from 'electron' +import type { OrcaRuntimeService } from '../../runtime/orca-runtime' +import type { Store } from '../../persistence' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { + CodexHomePtySpawnedLifecycleArgs, + GetSelectedCodexHomePath, + PrepareClaudeAuth, + PrepareCodexSessionResume +} from './host-env/types' +import { registerPtyHandlers } from './register-handlers' + +export function registerHeadlessPtyRuntime( + runtime: OrcaRuntimeService, + getSelectedCodexHomePath?: GetSelectedCodexHomePath, + getSettings?: () => GlobalSettings, + prepareClaudeAuth?: PrepareClaudeAuth, + store?: Store, + prepareCodexSessionResume?: PrepareCodexSessionResume, + lifecycle?: { + onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void + onPtyExit?: (id: string, exitSequence: number) => void + } +): void { + // Why: headless `orca serve` has no renderer window but still needs the same PTY handlers so remote clients can drive terminals. + // Why a fake rather than null: `registerPtyHandlers` takes a non-null BrowserWindow. `isDestroyed: () => true` + // is what makes that safe — every renderer-liveness guard reads it and skips, so no send is ever attempted. + // Keep `webContents.isDestroyed` in step with it: guards check both, and a missing method reads as "alive". + const headlessWindow = { + isDestroyed: () => true, + webContents: { + isDestroyed: () => true, + send: () => {}, + on: () => {}, + removeListener: () => {} + } + } as unknown as BrowserWindow + registerPtyHandlers( + headlessWindow, + runtime, + getSelectedCodexHomePath, + getSettings, + prepareClaudeAuth, + store, + { prepareCodexSessionResume, ...lifecycle } + ) +} diff --git a/src/main/ipc/pty/runtime/controller-deps.ts b/src/main/ipc/pty/runtime/controller-deps.ts new file mode 100644 index 00000000000..4abebc3b28c --- /dev/null +++ b/src/main/ipc/pty/runtime/controller-deps.ts @@ -0,0 +1,89 @@ +import type { BrowserWindow } from 'electron' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { Store } from '../../../persistence' +import type { IPtyProvider } from '../../../providers/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { TuiAgent } from '../../../../shared/tui-agent' +import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' +import type { TerminalStartupCwdMissingDirFallback } from '../../../../shared/terminal-startup-cwd' +import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' +import type { CodexSessionResumePreparation } from '../../../codex/codex-session-resume-home' +import type { + CodexHomePtySpawnedLifecycleArgs, + GetSelectedCodexHomePath, + PrepareClaudeAuth, + PrepareCodexSessionResume +} from '../host-env/types' +import type { CodexResumeLaunch, PreparedCodexResumeHome } from '../host-env/codex-resume' +import type { StablePaneOwner } from '../pane/stable-owner' +import type { AdoptStablePaneArgs, AdoptStablePaneResult } from '../ipc/spawn-types' +import type { finishPtyShutdown } from '../provider/liveness' + +export type PtyRuntimeControllerDeps = { + runtime?: OrcaRuntimeService + store?: Store + adoptStablePane: (args: AdoptStablePaneArgs) => Promise + getLocalPtyStartupPromise: (connectionId?: string | null) => Promise | undefined + getLocalPtyProviderStartupPromise: (connectionId?: string | null) => Promise | undefined + prepareCodexResumeHome: (args: { + connectionId?: string | null + launchAgent?: TuiAgent + providerSession?: AgentProviderSessionMetadata + target: CodexAccountSelectionTarget + launchEnv?: NodeJS.ProcessEnv + workspacePath?: string + }) => PreparedCodexResumeHome | null + resolveCodexResumeLaunch: ( + command: string | undefined, + prepared: PreparedCodexResumeHome + ) => Promise + noCodexResumeLaunch: (command: string | undefined) => CodexResumeLaunch + reconcileSharedRuntimeResumeHome: ( + resumeHome: Extract, + resolveCurrent: () => string | null + ) => string + stripSequencedStartupResumeArgv: | undefined>( + env: T, + launch: CodexResumeLaunch + ) => T + assertFolderWorkspacePtyPathUsable: (worktreeId: string | undefined) => Promise | void + resolvePtySpawnStartupCwd: ( + worktreeId: string | undefined, + cwd: string | undefined, + missingDirFallback?: TerminalStartupCwdMissingDirFallback + ) => string | undefined + requestSerializedBuffer: ( + ptyId: string, + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + ) => Promise<{ + data: string + cols: number + rows: number + seq?: number + lastTitle?: string + kittyKeyboardFlags?: number + } | null> + shutdownProviderAndDetectExit: ( + provider: IPtyProvider, + id: string, + opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } + ) => Promise + rememberSyntheticKillExit: (id: string) => void + rememberRetiredRejectedPty: (id: string) => void + sendPtyExitToRenderer: (payload: { id: string; code: number; incarnationId?: string }) => void + sendPtySpawnedToRenderer: (id: string) => void + finishPtyShutdown: typeof finishPtyShutdown + getSettings?: () => GlobalSettings | undefined + getSelectedCodexHomePath?: GetSelectedCodexHomePath + prepareClaudeAuth?: PrepareClaudeAuth + options?: { + onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void + prepareCodexSessionResume?: PrepareCodexSessionResume + } + trustedTerminalHandleEnv: Set + retiredRejectedPtyIds: Map + reversibleStopOwnersByPtyId: Map + mainWindow: BrowserWindow +} + +export type { StablePaneOwner } diff --git a/src/main/ipc/pty/runtime/controller.ts b/src/main/ipc/pty/runtime/controller.ts new file mode 100644 index 00000000000..4fce4853bc4 --- /dev/null +++ b/src/main/ipc/pty/runtime/controller.ts @@ -0,0 +1,80 @@ +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { claimRuntimePaneCreate, makePaneSpawnReservationKey } from '../pane/spawn-reservation' +import type { PtyRuntimeControllerDeps } from './controller-deps' +import { spawnPtyFromRuntimeController } from './spawn' +import { + killPtyFromRuntimeController, + markReversibleStopsFromRuntimeController, + retireRejectedPtyFromRuntimeController, + stopAndWaitPtyFromRuntimeController +} from './kill' +import { + attachPtyFromRuntimeController, + clearBufferFromRuntimeController, + confirmForegroundProcessFromRuntimeController, + getCwdFromRuntimeController, + getForegroundProcessFromRuntimeController, + getRendererSerializerGenerationFromRuntimeController, + getSizeFromRuntimeController, + hasChildProcessesFromRuntimeController, + hasPtyFromRuntimeController, + hasRendererSerializerFromRuntimeController, + inspectProcessFromRuntimeController, + listProcessesFromRuntimeController, + listProcessesWithHostScopeFromRuntimeController, + probePtyLivenessFromRuntimeController, + resizePtyFromRuntimeController, + serializeProviderBufferFromRuntimeController, + waitForRendererSerializerFromRuntimeController, + writePtyFromRuntimeController +} from './operations' + +export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): void { + const { runtime, adoptStablePane, requestSerializedBuffer } = deps + + runtime?.setPtyController({ + claimStablePaneCreate: (args) => { + const paneKey = makePaneKey(args.tabId, args.leafId) + const ownerKey = makePaneSpawnReservationKey(args.worktreeId, args.connectionId, paneKey) + return ownerKey ? claimRuntimePaneCreate(ownerKey) : () => {} + }, + adoptStablePane, + spawn: async (args) => spawnPtyFromRuntimeController(deps, args), + write: (ptyId, data) => writePtyFromRuntimeController(ptyId, data), + probePtyLiveness: (ptyId) => probePtyLivenessFromRuntimeController(deps, ptyId), + // Why: subscriber-driven ingestion for daemon sessions no renderer pane + // ever attached. Local daemon sessions only — SSH panes have their own + // lease machinery, and the in-process local provider streams without + // attach. Attach-only and false-on-doubt: never creates or resizes. + attach: (ptyId) => attachPtyFromRuntimeController(deps, ptyId), + kill: (ptyId) => killPtyFromRuntimeController(deps, ptyId), + retireRejectedPty: (ptyId, stopConfirmed) => + retireRejectedPtyFromRuntimeController(deps, ptyId, stopConfirmed), + markReversibleStops: (ptyIds) => markReversibleStopsFromRuntimeController(deps, ptyIds), + stopAndWait: (ptyId, opts) => stopAndWaitPtyFromRuntimeController(deps, ptyId, opts), + getForegroundProcess: (ptyId) => getForegroundProcessFromRuntimeController(ptyId), + inspectProcess: (ptyId) => inspectProcessFromRuntimeController(ptyId), + confirmForegroundProcess: (ptyId) => confirmForegroundProcessFromRuntimeController(ptyId), + getCwd: (ptyId) => getCwdFromRuntimeController(ptyId), + hasChildProcesses: (ptyId) => hasChildProcessesFromRuntimeController(ptyId), + clearBuffer: (ptyId) => clearBufferFromRuntimeController(deps, ptyId), + hasPty: (ptyId) => hasPtyFromRuntimeController(ptyId), + listProcesses: (connectionId, opts) => + listProcessesFromRuntimeController(deps, connectionId, opts), + listProcessesWithHostScope: (opts) => + listProcessesWithHostScopeFromRuntimeController(deps, opts), + serializeBuffer: (ptyId, opts) => { + // Why: mobile xterm must start from the desktop's exact screen state/dimensions before live TUI chunks render correctly. + return requestSerializedBuffer(ptyId, opts) + }, + serializeProviderBuffer: (ptyId, opts) => + serializeProviderBufferFromRuntimeController(ptyId, opts), + hasRendererSerializer: (ptyId) => hasRendererSerializerFromRuntimeController(ptyId), + getRendererSerializerGeneration: (ptyId) => + getRendererSerializerGenerationFromRuntimeController(ptyId), + waitForRendererSerializer: (ptyId, afterGeneration, timeoutMs, signal) => + waitForRendererSerializerFromRuntimeController(ptyId, afterGeneration, timeoutMs, signal), + getSize: (ptyId) => getSizeFromRuntimeController(ptyId), + resize: (ptyId, cols, rows) => resizePtyFromRuntimeController(ptyId, cols, rows) + }) +} diff --git a/src/main/ipc/pty/runtime/kill.ts b/src/main/ipc/pty/runtime/kill.ts new file mode 100644 index 00000000000..3f38ac339dd --- /dev/null +++ b/src/main/ipc/pty/runtime/kill.ts @@ -0,0 +1,311 @@ +import type { IPtyProvider } from '../../../providers/types' +import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../../../shared/pty-liveness-verdict' +import { parseAppSshPtyId } from '../../../providers/ssh-pty-id' +import { ptyOwnership, ptyIncarnationById } from '../provider/ownership-state' +import { getProvider, getProviderForPty } from '../provider/registry' +import { isPtyAlreadyGoneError, delay, verifyPtyStopped } from '../provider/liveness' +import type { PtyRuntimeControllerDeps } from './controller-deps' + +export function killPtyFromRuntimeController( + deps: PtyRuntimeControllerDeps, + ptyId: string +): boolean { + const { + runtime, + store, + getLocalPtyProviderStartupPromise, + shutdownProviderAndDetectExit, + rememberSyntheticKillExit, + sendPtyExitToRenderer, + finishPtyShutdown, + retiredRejectedPtyIds + } = deps + runtime?.markPtyStopRequested?.(ptyId) + let connectionId: string | null | undefined = ptyOwnership.get(ptyId) + const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null + connectionId ??= parsedSshId?.connectionId + const killWithCurrentProvider = (): boolean => { + let provider: IPtyProvider + try { + provider = connectionId ? getProvider(connectionId) : getProviderForPty(ptyId) + } catch { + if (connectionId) { + // Why: runtime/CLI close can target a detached SSH PTY after its + // provider was unregistered. Tombstone the lease so reconnect does + // not revive a terminal the user explicitly closed. + const incarnationId = finishPtyShutdown(ptyId, connectionId, store) + runtime?.onPtyExit(ptyId, -1, incarnationId) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ + id: ptyId, + code: -1, + ...(incarnationId ? { incarnationId } : {}) + }) + runtime?.markPtyLivenessUnverifiable?.(ptyId, SSH_PROVIDER_UNREGISTERED_REASON) + return false + } + return false + } + // Why: controller is synchronous, but keep ownership until async shutdown proves whether the provider emitted an exit. + void shutdownProviderAndDetectExit(provider, ptyId, { immediate: false }) + .then((providerExitObserved) => { + const retired = retiredRejectedPtyIds.has(ptyId) + const incarnationId = finishPtyShutdown(ptyId, connectionId, store) + if (!providerExitObserved && !retired) { + runtime?.onPtyExit(ptyId, -1, incarnationId) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ + id: ptyId, + code: -1, + ...(incarnationId ? { incarnationId } : {}) + }) + } + }) + .catch((err) => { + const retired = retiredRejectedPtyIds.has(ptyId) + if (isPtyAlreadyGoneError(err)) { + const incarnationId = finishPtyShutdown(ptyId, connectionId, store) + if (!retired) { + runtime?.onPtyExit(ptyId, -1, incarnationId) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ + id: ptyId, + code: -1, + ...(incarnationId ? { incarnationId } : {}) + }) + } + return + } + console.warn( + `[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}` + ) + // Why: close runtime tails without clearing provider ownership, so + // a retry can still target a PTY that survived the failed shutdown. + if (!retired) { + if (connectionId) { + runtime?.markPtyLivenessUnverifiable?.( + ptyId, + err instanceof Error ? err.message : String(err) + ) + } + runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId)) + } + }) + return true + } + const startupPromise = getLocalPtyProviderStartupPromise(connectionId) + if (startupPromise) { + // Why: select the provider after the daemon swap; the fallback first can report success while orphaning a daemon PTY. + void startupPromise.then(killWithCurrentProvider).catch((err) => { + console.warn( + `[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}` + ) + if (!retiredRejectedPtyIds.has(ptyId)) { + if (connectionId) { + runtime?.markPtyLivenessUnverifiable?.( + ptyId, + err instanceof Error ? err.message : String(err) + ) + } + runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId)) + } + }) + return true + } + return killWithCurrentProvider() +} + +export function retireRejectedPtyFromRuntimeController( + deps: PtyRuntimeControllerDeps, + ptyId: string, + stopConfirmed: boolean +): void { + const { + runtime, + store, + rememberRetiredRejectedPty, + rememberSyntheticKillExit, + sendPtyExitToRenderer, + finishPtyShutdown + } = deps + rememberRetiredRejectedPty(ptyId) + if (!stopConfirmed) { + runtime?.markPtyLivenessUnverifiable?.( + ptyId, + 'a follow-up stop was issued but its outcome could not be verified' + ) + if (!ptyOwnership.has(ptyId)) { + return + } + runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId)) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ + id: ptyId, + code: -1, + ...(ptyIncarnationById.get(ptyId) ? { incarnationId: ptyIncarnationById.get(ptyId) } : {}) + }) + return + } + // Why: a completed stop already cleared provider state, tombstoned the lease and told the + // renderer; repeating that double-fires the exit IPC. The runtime still needs code 0 so an + // SSH pane retires for good instead of staying preserved by the stop's negative exit. + if (!ptyOwnership.has(ptyId)) { + runtime?.onPtyExit(ptyId, 0, ptyIncarnationById.get(ptyId)) + return + } + let connectionId: string | null | undefined = ptyOwnership.get(ptyId) + const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null + connectionId ??= parsedSshId?.connectionId + const incarnationId = finishPtyShutdown(ptyId, connectionId, store) + runtime?.onPtyExit(ptyId, 0, incarnationId) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ + id: ptyId, + code: 0, + ...(incarnationId ? { incarnationId } : {}) + }) +} + +export function markReversibleStopsFromRuntimeController( + deps: PtyRuntimeControllerDeps, + ptyIds: readonly string[] +): () => void { + const { reversibleStopOwnersByPtyId } = deps + for (const ptyId of ptyIds) { + reversibleStopOwnersByPtyId.set(ptyId, (reversibleStopOwnersByPtyId.get(ptyId) ?? 0) + 1) + } + let released = false + return () => { + if (released) { + return + } + released = true + for (const ptyId of ptyIds) { + const owners = (reversibleStopOwnersByPtyId.get(ptyId) ?? 0) - 1 + if (owners > 0) { + reversibleStopOwnersByPtyId.set(ptyId, owners) + } else { + reversibleStopOwnersByPtyId.delete(ptyId) + } + } + } +} + +export async function stopAndWaitPtyFromRuntimeController( + deps: PtyRuntimeControllerDeps, + ptyId: string, + opts?: { keepHistory?: boolean; deadlineMs?: number } +): Promise { + const { + runtime, + store, + getLocalPtyProviderStartupPromise, + shutdownProviderAndDetectExit, + rememberSyntheticKillExit, + sendPtyExitToRenderer, + finishPtyShutdown + } = deps + runtime?.markPtyStopRequested?.(ptyId) + let connectionId: string | null | undefined = ptyOwnership.get(ptyId) + const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null + connectionId ??= parsedSshId?.connectionId + // Why: destructive teardown threads one absolute deadline through every await + // below; each RPC leaf converts it to the remaining time when it issues, so + // sequential RPCs share the budget and cannot overrun the sweep deadline. + const deadlineMs = opts?.deadlineMs + const startupPromise = getLocalPtyProviderStartupPromise(connectionId) + if (startupPromise) { + // Why: exact-stop must resolve the provider after daemon startup just + // like renderer kills, or the fallback can falsely confirm teardown. + if (deadlineMs !== undefined) { + // Why: bound the cold-start await by the teardown deadline instead of the + // 60s startup fail-open cap; fail closed so the sweep records the miss. + const won = await Promise.race([ + // Why: () => false on rejection both fails closed on a startup error and + // keeps the losing branch's rejection from surfacing as unhandled. + startupPromise.then( + () => true, + () => false + ), + delay(Math.max(1, deadlineMs - Date.now())).then(() => false) + ]) + if (!won) { + return false + } + } else { + await startupPromise + } + } + let provider: IPtyProvider + try { + provider = connectionId ? getProvider(connectionId) : getProviderForPty(ptyId) + } catch { + if (connectionId) { + // Why: an absent SSH provider means there is no live target left to + // await, but the relay lease must still be tombstoned. + const incarnationId = finishPtyShutdown(ptyId, connectionId, store) + runtime?.onPtyExit(ptyId, -1, incarnationId) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ + id: ptyId, + code: -1, + ...(incarnationId ? { incarnationId } : {}) + }) + runtime?.markPtyLivenessUnverifiable?.(ptyId, SSH_PROVIDER_UNREGISTERED_REASON) + } + return false + } + let providerExitObserved = false + try { + providerExitObserved = await shutdownProviderAndDetectExit(provider, ptyId, { + immediate: true, + keepHistory: opts?.keepHistory ?? false, + deadlineMs + }) + } catch (err) { + if (!isPtyAlreadyGoneError(err)) { + if (connectionId) { + runtime?.markPtyLivenessUnverifiable?.( + ptyId, + err instanceof Error ? err.message : String(err) + ) + } + console.warn( + `[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}` + ) + return false + } + } + try { + if (!(await verifyPtyStopped(provider, ptyId, opts))) { + runtime?.markPtyLivenessLive?.(ptyId) + return false + } + } catch (err) { + if (connectionId) { + runtime?.markPtyLivenessUnverifiable?.( + ptyId, + err instanceof Error ? err.message : String(err) + ) + } + console.warn( + `[pty] Failed to verify PTY ${ptyId} stopped: ${ + err instanceof Error ? err.message : String(err) + }` + ) + return false + } + const incarnationId = finishPtyShutdown(ptyId, connectionId, store) + if (!providerExitObserved) { + // The owning provider's fresh inventory observed absence, so this is a + // death certificate even when its exit event was missed. + runtime?.onPtyExit(ptyId, 0, incarnationId) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ + id: ptyId, + code: 0, + ...(incarnationId ? { incarnationId } : {}) + }) + } + return true +} diff --git a/src/main/ipc/pty/runtime/operations.ts b/src/main/ipc/pty/runtime/operations.ts new file mode 100644 index 00000000000..745f225312a --- /dev/null +++ b/src/main/ipc/pty/runtime/operations.ts @@ -0,0 +1,259 @@ +import type { IPtyProvider } from '../../../providers/types' +import { LocalPtyProvider } from '../../../providers/local-pty-provider' +import type { PtyProcessInfo } from '../../../providers/pty-process-info' +import { parseAppSshPtyId } from '../../../providers/ssh-pty-id' +import { + LOCAL_EXECUTION_HOST_ID, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' +import { ptyOwnership } from '../provider/ownership-state' +import { ptySizes } from '../delivery/visibility-state' +import { rendererSerializerReadiness } from '../pane/serializer-state' +import { + getProvider, + getProviderForPty, + localProvider, + registeredPtyProviders +} from '../provider/registry' +import { inspectPtyProviderProcess } from '../../../providers/pty-process-inspection' +import type { PtyRuntimeControllerDeps } from './controller-deps' + +export function writePtyFromRuntimeController(ptyId: string, data: string): boolean { + try { + getProviderForPty(ptyId).write(ptyId, data) + return true + } catch { + return false + } +} + +export async function probePtyLivenessFromRuntimeController( + deps: PtyRuntimeControllerDeps, + ptyId: string +): Promise { + try { + // Why: no locally routed provider can authoritatively answer for a + // remote host's PTY, so remote-scoped ids stay unknown, never absent. + if (ptyId.startsWith('remote:')) { + return null + } + const connectionId = ptyOwnership.get(ptyId) ?? parseAppSshPtyId(ptyId)?.connectionId + // Why: during cold start the daemon swap is in flight; the pre-swap + // fallback would answer absent for every daemon-owned id. + const startupPromise = deps.getLocalPtyProviderStartupPromise(connectionId) + if (startupPromise) { + await startupPromise + } + const provider = getProviderForPty(ptyId) + if (provider.probePtyLiveness) { + return await provider.probePtyLiveness(ptyId) + } + // Why: the in-process provider is its own sole owner (#12393), so its + // refusal is authoritative; every other probe-less provider is doubt. + if (provider instanceof LocalPtyProvider) { + return provider.hasPty(ptyId) + } + return null + } catch { + return null + } +} + +export async function attachPtyFromRuntimeController( + deps: PtyRuntimeControllerDeps, + ptyId: string +): Promise { + if (ptyOwnership.get(ptyId) != null || parseAppSshPtyId(ptyId)) { + return false + } + let provider: IPtyProvider + try { + provider = getProviderForPty(ptyId) + } catch { + return false + } + if (provider !== localProvider || provider instanceof LocalPtyProvider) { + return false + } + try { + const sequenceBeforeProviderAttach = deps.runtime?.getPtyOutputSequence?.(ptyId) ?? 0 + const attachResult = await provider.attach(ptyId) + if (attachResult?.providerSequence) { + deps.runtime?.synchronizePtyOutputSequenceFromProvider?.( + ptyId, + attachResult.providerSequence, + sequenceBeforeProviderAttach + ) + } + return true + } catch { + return false + } +} + +export async function getForegroundProcessFromRuntimeController(ptyId: string) { + try { + return await getProviderForPty(ptyId).getForegroundProcess(ptyId) + } catch { + return null + } +} + +export async function inspectProcessFromRuntimeController(ptyId: string) { + return inspectPtyProviderProcess(getProviderForPty(ptyId), ptyId) +} + +export async function confirmForegroundProcessFromRuntimeController(ptyId: string) { + try { + const provider = getProviderForPty(ptyId) + // Why: cached foreground evidence cannot resolve a fresh shell conflict. + return (await provider.confirmForegroundProcess?.(ptyId)) ?? null + } catch { + return null + } +} + +export async function getCwdFromRuntimeController(ptyId: string) { + try { + const cwd = await getProviderForPty(ptyId).getCwd(ptyId) + return cwd || null + } catch { + return null + } +} + +export async function hasChildProcessesFromRuntimeController(ptyId: string) { + try { + return await getProviderForPty(ptyId).hasChildProcesses(ptyId) + } catch { + return false + } +} + +export async function clearBufferFromRuntimeController( + deps: PtyRuntimeControllerDeps, + ptyId: string +): Promise { + // Why: desktop xterm and daemon/SSH providers hold separate buffers; clear both so mobile resubscribe can't resurrect cleared history. + deps.mainWindow.webContents.send('pty:clearBuffer:request', { ptyId }) + try { + await getProviderForPty(ptyId).clearBuffer(ptyId) + } catch { + /* best effort: renderer clear still handles local PTYs */ + } +} + +export function hasPtyFromRuntimeController(ptyId: string): boolean | null { + try { + return getProviderForPty(ptyId).hasPty?.(ptyId) ?? null + } catch { + return null + } +} + +function markSshInventoryUnverifiable( + runtime: PtyRuntimeControllerDeps['runtime'], + connectionId: string, + error: unknown +): void { + const reason = error instanceof Error ? error.message : String(error) + for (const [ptyId, ownerConnectionId] of ptyOwnership) { + if (ownerConnectionId === connectionId) { + runtime?.markPtyLivenessUnverifiable?.(ptyId, reason) + } + } +} + +export async function listProcessesWithHostScopeFromRuntimeController( + deps: PtyRuntimeControllerDeps, + opts?: { deadlineMs?: number } +): Promise<{ processes: PtyProcessInfo[]; hostIds: ExecutionHostId[] }> { + const providerSessions = await Promise.all( + registeredPtyProviders().map(async ({ provider, connectionId }) => { + const hostId: ExecutionHostId = connectionId + ? toSshExecutionHostId(connectionId) + : LOCAL_EXECUTION_HOST_ID + try { + return { + processes: await (connectionId ? provider.listProcesses(opts) : provider.listProcesses()), + hostId + } + } catch (error) { + if (!connectionId) { + throw error + } + markSshInventoryUnverifiable(deps.runtime, connectionId, error) + return null + } + }) + ) + const respondingSessions = providerSessions.filter((session) => session !== null) + return { + processes: respondingSessions.flatMap((session) => session.processes), + hostIds: respondingSessions.map((session) => session.hostId) + } +} + +export async function listProcessesFromRuntimeController( + deps: PtyRuntimeControllerDeps, + connectionId?: string | null, + opts?: { deadlineMs?: number } +) { + if (connectionId === null) { + return localProvider.listProcesses() + } + if (connectionId !== undefined) { + try { + return await getProvider(connectionId).listProcesses(opts) + } catch (error) { + markSshInventoryUnverifiable(deps.runtime, connectionId, error) + throw error + } + } + return (await listProcessesWithHostScopeFromRuntimeController(deps, opts)).processes +} + +export function resizePtyFromRuntimeController(ptyId: string, cols: number, rows: number): boolean { + try { + getProviderForPty(ptyId).resize(ptyId, cols, rows) + ptySizes.set(ptyId, { cols, rows }) + return true + } catch { + return false + } +} + +export function hasRendererSerializerFromRuntimeController(ptyId: string): boolean { + // Why: a synchronous probe lets the runtime decide whether to skip the daemon-snapshot seed (renderer will hydrate) or run it (no renderer authoritative). + return rendererSerializerReadiness.has(ptyId) +} + +export function getRendererSerializerGenerationFromRuntimeController(ptyId: string) { + return rendererSerializerReadiness.generation(ptyId) +} + +export function waitForRendererSerializerFromRuntimeController( + ptyId: string, + afterGeneration = 0, + timeoutMs?: number, + signal?: AbortSignal +) { + return rendererSerializerReadiness.wait(ptyId, afterGeneration ?? 0, timeoutMs, signal) +} + +export function getSizeFromRuntimeController(ptyId: string) { + return ptySizes.get(ptyId) ?? null +} + +export async function serializeProviderBufferFromRuntimeController( + ptyId: string, + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } +) { + try { + // Why: restored daemon PTYs can be live while their desktop pane is unmounted; query the provider model so phone-local navigation works. + return (await getProviderForPty(ptyId).getBufferSnapshot?.(ptyId, opts)) ?? null + } catch { + return null + } +} diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts new file mode 100644 index 00000000000..ecf4f4ebcd4 --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -0,0 +1,307 @@ +import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' +import { isTerminalLeafId } from '../../../../shared/stable-pane-id' +import { ptyOwnership, ptyIncarnationById, deletePtyOwnership } from '../provider/ownership-state' +import { ptySizes } from '../delivery/visibility-state' +import { getRelayPtyId } from '../provider/registry' +import { + shouldSkipCodexHomeEnvForWindowsShell, + recordCodexPaneAccountForSpawn, + codexReattachedHomeRouteField +} from '../host-env/codex-home' +import { markClaudePtySpawned } from '../../../claude-accounts/live-pty-gate' +import { registerPty } from '../../../memory/pty-registry' +import { rememberPaneKeyForPty } from '../pane/key-state' +import { + pendingByPaneKey, + pendingPtyIdBySerializerGeneration, + rendererSerializerReadiness +} from '../pane/serializer-state' +import { seedTerminalRestoreRecordsFromSpawnResult } from '../pane/agent-session-owners' +import { track } from '../../../telemetry/client' +import { getCohortAtEmit } from '../../../telemetry/cohort-classifier' +import { + agentKindSchema, + launchSourceSchema, + requestKindSchema +} from '../../../../shared/telemetry-events' +import { persistAdmittedStablePaneBinding } from '../pane/stable-owner' +import { + isNativeWindowsLocalPtySpawn, + markNativeWindowsConptyPty +} from '../../../runtime/terminal-model-query-authority' +import { toSshExecutionHostId } from '../../../../shared/execution-host' +import { createTerminalSessionStateSaveFailureMessage } from '../../../../shared/terminal-session-state-save-failure' +import { clearProviderPtyState } from '../provider/state-cleanup' +import { resolvePaneSpawnReservation } from '../pane/spawn-reservation' +import type { RuntimePtySpawnState } from './spawn-state' + +export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { + const args = ctx.args + try { + ctx.stablePaneBindingPersisted = persistAdmittedStablePaneBinding({ + store: ctx.hostSessionBinding?.store, + owner: ctx.stablePaneOwner, + result: ctx.result, + worktreeId: ctx.hostSessionBinding?.worktreeId, + startupCwd: ctx.cwd, + connectionId: args.connectionId + }) + } catch (error) { + if (error instanceof Error && error.message === 'terminal_pane_owner_changed') { + throw error + } + console.error('[pty] failed to persist runtime PTY binding after attach:', error) + throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { + agentSessionOperationOutcome: 'unknown' as const + }) + } + if (ctx.result.agentSessionEnsure?.disposition === 'adopted') { + const owner = ctx.result.agentSessionEnsure.owner + ptyOwnership.set(ctx.result.id, args.connectionId ?? ptyOwnership.get(ctx.result.id) ?? null) + ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, owner.surface.terminalHandle) + if (ctx.result.incarnationId) { + ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) + } + ctx.deps.runtime?.registerPty( + ctx.result.id, + owner.surface.worktreeId, + args.connectionId ?? null, + { + tabId: owner.surface.tabId, + leafId: owner.surface.leafId, + ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}) + } + ) + if (!args.connectionId) { + ctx.deps.options?.onCodexHomePtySpawned?.({ + id: ctx.result.id, + codexHomePath: ctx.selectedCodexHomePath, + reattached: true, + startedAt: ctx.codexHomeLaunchStartedAt, + startedSequence: ctx.codexHomeLaunchStartedSequence, + ...codexReattachedHomeRouteField(ctx.reattachedCodexHomeRoutes, ctx.result.id, true), + ...(ctx.env ? { launchEnv: ctx.env } : {}) + }) + } + // Why: the adopted branch returns before the normal settle site, so the + // reservation must be resolved here or every later spawn for this pane + // awaits a promise that never settles. + resolvePaneSpawnReservation(ctx.paneSpawnReservationKey, ctx.paneSpawnReservation, { + ...ctx.result, + isReattach: true + }) + return { + id: ctx.result.id, + ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), + agentSessionEnsure: ctx.result.agentSessionEnsure + } + } + ptyOwnership.set(ctx.result.id, args.connectionId ?? null) + if (ctx.result.incarnationId) { + ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) + } + // Why: record the native-Windows-local-PTY determination before any byte reaches the emulator, so its ConPTY DA1 override exists from byte zero. + if ( + isNativeWindowsLocalPtySpawn({ + connectionId: args.connectionId, + cwd: args.cwd, + shellOverride: ctx.daemonShellOverride + }) + ) { + markNativeWindowsConptyPty(ctx.result.id) + } + const relayResultId = getRelayPtyId(args.connectionId, ctx.result.id) + const persistSshLease = (): void => { + if (!ctx.deps.store || !args.connectionId) { + return + } + // Why: SSH leases keep relay ids for remote reconciliation, while session bindings keep app-facing ids for hydration. + ctx.deps.store.upsertSshRemotePtyLease({ + targetId: args.connectionId, + ptyId: relayResultId, + ...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}), + ...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}), + ...(typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) + ? { leafId: args.leafId } + : {}), + state: 'attached', + lastAttachedAt: Date.now() + }) + } + if (!ctx.hostSessionBinding) { + persistSshLease() + } + ptySizes.set(ctx.result.id, { cols: args.cols, rows: args.rows }) + if (ctx.effectiveSessionAppId !== undefined && ctx.effectiveSessionAppId !== ctx.result.id) { + ptySizes.delete(ctx.effectiveSessionAppId) + } + recordCodexPaneAccountForSpawn({ + ptyId: ctx.result.id, + isDaemonHostSpawn: ctx.isDaemonHostSpawn, + isReattach: ctx.result.isReattach === true, + pinnedByResume: ctx.codexResumeHomeSelected, + launchCodexHomePath: ctx.selectedCodexHomePath, + launchEnv: args.env, + target: ctx.codexSelectionTarget, + settings: ctx.deps.getSettings?.() + }) + if (ctx.hostSessionBinding && !ctx.stablePaneBindingPersisted) { + try { + const binding = { + worktreeId: ctx.hostSessionBinding.worktreeId, + tabId: ctx.hostSessionBinding.tabId, + leafId: ctx.hostSessionBinding.leafId, + ptyId: ctx.result.id, + hostAdmittedMembership: true, + ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), + ...(ctx.cwd ? { startupCwd: ctx.cwd } : {}), + ...(ctx.hostSessionBinding.expectedSourceBinding + ? { expectedSourceBinding: ctx.hostSessionBinding.expectedSourceBinding } + : {}) + } + const persisted = args.connectionId + ? ctx.hostSessionBinding.store.persistPtyBinding( + binding, + toSshExecutionHostId(args.connectionId) + ) + : ctx.hostSessionBinding.store.persistPtyBinding(binding) + if (persisted === false) { + throw new Error('terminal_split_source_not_found') + } + } catch (err) { + console.error('[pty] failed to persist runtime PTY binding after spawn:', err) + if (!ctx.result.isReattach) { + deletePtyOwnership(ctx.result.id) + try { + await ctx.provider.shutdown(ctx.result.id, { immediate: true }) + } catch (shutdownErr) { + console.warn('[pty] failed to clean up PTY after persistence failure:', shutdownErr) + } + clearProviderPtyState(ctx.result.id) + } + if (err instanceof Error && err.message === 'terminal_split_source_not_found') { + throw err + } + throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { + agentSessionOperationOutcome: 'unknown' as const + }) + } + persistSshLease() + } + if (args.preAllocatedHandle && !ctx.stablePaneOwner?.handle) { + ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, args.preAllocatedHandle) + } + if (args.worktreeId) { + ctx.deps.runtime?.registerPty( + ctx.result.id, + args.worktreeId, + args.connectionId ?? null, + // Why: thread validated pane identity so main can back a pending mobile create even if graph-sync stalls (#7587). + typeof args.tabId === 'string' && + isValidTerminalTabId(args.tabId) && + args.tabId.length <= 512 && + ctx.metadataLeafId !== null + ? { + tabId: args.tabId, + leafId: ctx.metadataLeafId, + ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}) + } + : undefined, + !args.connectionId + ? shouldSkipCodexHomeEnvForWindowsShell(ctx.daemonShellOverride, ctx.cwd) + : undefined + ) + } else { + // Why: non-worktree PTYs have no later surface-registration phase to clear admission intent. + ctx.deps.runtime?.cancelPendingPtyRegistration?.(ctx.result.id, ctx.result.incarnationId) + } + // Why: runtime-controller creates (headless serve, CLI, splits) adopt surviving daemon sessions too; without this seed their records stay blank. + seedTerminalRestoreRecordsFromSpawnResult(ctx.deps.runtime, ctx.result) + // Why: arms main's per-PTY Command Code output detector from the launch command (renderer startupCommand parity). + if (!ctx.stablePaneOwner) { + ctx.deps.runtime?.noteTerminalSpawnCommand?.(ctx.result.id, ctx.launchCommand ?? null) + } + if (ctx.isClaudeLaunch && !ctx.stablePaneOwner) { + markClaudePtySpawned(ctx.result.id) + } + if (args.telemetry && !ctx.stablePaneOwner) { + const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind) + const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source) + const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind) + if (agentKindParse.success && launchSourceParse.success && requestKindParse.success) { + track('agent_started', { + agent_kind: agentKindParse.data, + launch_source: launchSourceParse.data, + request_kind: requestKindParse.data, + ...getCohortAtEmit() + }) + } + } + // Why: runtime-owned CLI PTYs bypass the renderer pty:spawn handler; record paneKey here too since hook titles and cache cleanup need this reverse lookup. + const paneKey = rememberPaneKeyForPty(ctx.result.id, ctx.env?.ORCA_PANE_KEY) + const pendingSerializer = paneKey ? pendingByPaneKey.get(paneKey) : undefined + const inheritRendererReadiness = + ctx.result.isReattach === true && + !pendingSerializer && + rendererSerializerReadiness.has(ctx.result.id) + rendererSerializerReadiness.beginIncarnation(ctx.result.id, inheritRendererReadiness) + if (paneKey && pendingSerializer) { + pendingPtyIdBySerializerGeneration.set(pendingSerializer.gen, ctx.result.id) + } + if (!args.connectionId) { + registerPty({ + ptyId: ctx.result.id, + worktreeId: args.worktreeId ?? null, + sessionId: ctx.sessionId ?? null, + paneKey, + pid: + typeof ctx.result.pid === 'number' && Number.isFinite(ctx.result.pid) && ctx.result.pid > 0 + ? ctx.result.pid + : null + }) + } + // Why: runtime-owned/background spawns bypass mounted-pane state, so inventory consumers need an explicit signal. + ctx.deps.sendPtySpawnedToRenderer(ctx.result.id) + if (!args.connectionId) { + ctx.deps.options?.onCodexHomePtySpawned?.({ + id: ctx.result.id, + codexHomePath: ctx.selectedCodexHomePath, + startedAt: ctx.codexHomeLaunchStartedAt, + startedSequence: ctx.codexHomeLaunchStartedSequence, + ...codexReattachedHomeRouteField( + ctx.reattachedCodexHomeRoutes, + ctx.result.id, + ctx.result.isReattach === true + ), + ...(ctx.result.isReattach === true + ? { reattached: true } + : ctx.env + ? { launchEnv: ctx.env } + : {}) + }) + } + const response = { + id: ctx.result.id, + ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), + ...(ctx.stablePaneOwner && (ctx.stablePaneOwner.handle || args.preAllocatedHandle) + ? { + stablePaneOwner: { + handle: ctx.stablePaneOwner.handle ?? args.preAllocatedHandle!, + tabId: ctx.stablePaneOwner.tabId, + leafId: ctx.stablePaneOwner.leafId + } + } + : {}), + ...(ctx.result.agentSessionEnsure ? { agentSessionEnsure: ctx.result.agentSessionEnsure } : {}) + } + resolvePaneSpawnReservation(ctx.paneSpawnReservationKey, ctx.paneSpawnReservation, { + ...ctx.result, + ...(typeof ctx.result.snapshotKittyKeyboardFlags === 'number' && + ctx.reconciledSnapshotSeq !== null && + ctx.snapshotKittyFlagsCoverReconciledSeq + ? { snapshotSeq: ctx.reconciledSnapshotSeq } + : { snapshotKittyKeyboardFlags: undefined }), + isReattach: true + }) + return response +} diff --git a/src/main/ipc/pty/runtime/spawn-early.ts b/src/main/ipc/pty/runtime/spawn-early.ts new file mode 100644 index 00000000000..575f89b22e7 --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-early.ts @@ -0,0 +1,71 @@ +import type { PtySpawnResult } from '../../../providers/types' +import { getAppPtyId } from '../provider/registry' +import { allocatePtyLifecycleSequence } from '../host-env/types' +import { snapshotCodexPaneHomeRoutes, codexReattachedHomeRouteField } from '../host-env/codex-home' +import { ensureWslHookRelayForReattach } from '../../../agent-hooks/wsl-hook-relay-reattach' +import type { CodexPaneHomeRoute } from '../../../codex/codex-pane-account-registry' +import type { RuntimePtySpawnState } from './spawn-state' + +export function adoptMaterializedRuntimePtySpawn( + ctx: RuntimePtySpawnState, + startupAlreadyAwaited = false +): Promise | PtySpawnResult | null { + const args = ctx.args + // Why: the re-entry after the startup await must not re-mint the lifecycle + // sequence or re-snapshot routes taken before the barrier. + if (!startupAlreadyAwaited) { + ctx.codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined + ctx.codexHomeLaunchStartedSequence = !args.connectionId + ? allocatePtyLifecycleSequence() + : undefined + ctx.preAdoptedStablePane = args.adoptedStablePane ?? null + ctx.reattachedCodexHomeRoutes = !args.connectionId + ? new Map( + snapshotCodexPaneHomeRoutes([ + ctx.preAdoptedStablePane?.result.id, + args.sessionId ? getAppPtyId(args.connectionId, args.sessionId) : undefined + ]) + ) + : new Map() + } + const startupPromise = ctx.deps.getLocalPtyStartupPromise(args.connectionId) + if (startupPromise && !startupAlreadyAwaited) { + return startupPromise.then(() => adoptMaterializedRuntimePtySpawn(ctx, true)) + } + if (!ctx.preAdoptedStablePane?.materialized) { + return null + } + const handle = ctx.preAdoptedStablePane.owner.handle ?? args.preAllocatedHandle + if (!handle) { + throw new Error('terminal_pane_owner_unknown') + } + ctx.result = { + id: ctx.preAdoptedStablePane.result.id, + ...(ctx.preAdoptedStablePane.result.incarnationId + ? { incarnationId: ctx.preAdoptedStablePane.result.incarnationId } + : {}), + ...(typeof ctx.preAdoptedStablePane.result.wslDistro === 'string' + ? { wslDistro: ctx.preAdoptedStablePane.result.wslDistro } + : {}), + stablePaneOwner: { + handle, + tabId: ctx.preAdoptedStablePane.owner.tabId, + leafId: ctx.preAdoptedStablePane.owner.leafId + } + } + ensureWslHookRelayForReattach( + { isReattach: true, wslDistro: ctx.preAdoptedStablePane.result.wslDistro }, + args.connectionId + ) + if (!args.connectionId) { + ctx.deps.options?.onCodexHomePtySpawned?.({ + id: ctx.result.id, + codexHomePath: null, + reattached: true, + startedAt: ctx.codexHomeLaunchStartedAt, + startedSequence: ctx.codexHomeLaunchStartedSequence, + ...codexReattachedHomeRouteField(ctx.reattachedCodexHomeRoutes, ctx.result.id, true) + }) + } + return ctx.result +} diff --git a/src/main/ipc/pty/runtime/spawn-execute.ts b/src/main/ipc/pty/runtime/spawn-execute.ts new file mode 100644 index 00000000000..99829f119bb --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-execute.ts @@ -0,0 +1,262 @@ +import type { PtySpawnResult } from '../../../providers/types' +import { ptyIncarnationById, deletePtyOwnership } from '../provider/ownership-state' +import { ptySizes } from '../delivery/visibility-state' +import { tryGetProviderForAgentSessionOwner } from '../provider/registry' +import { ensureWslHookRelayForReattach } from '../../../agent-hooks/wsl-hook-relay-reattach' +import { + agentSessionOwners, + assertSpawnReplyWasLive, + reconcileAgentSessionOwnerListings +} from '../pane/agent-session-owners' +import { spawnForStablePane, resolveStablePaneOwner } from '../pane/stable-owner' +import { clearProviderPtyState } from '../provider/state-cleanup' +import { isProviderAgentSessionOwnerLive, normalizeNodePtySpawnError } from '../provider/liveness' +import { + SSH_SESSION_EXPIRED_ERROR, + isSshPtyIdentityMismatchError +} from '../../../providers/ssh-pty-errors' +import type { RuntimePtySpawnState } from './spawn-state' + +export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise { + const args = ctx.args + const runtime = ctx.deps.runtime + const acquireWorktreeSpawn = runtime?.acquireWorktreeTerminalSpawn + ctx.releaseWorktreeSpawn = acquireWorktreeSpawn + ? await acquireWorktreeSpawn.call(runtime, args.worktreeId) + : undefined + try { + if (args.preAllocatedHandle) { + ctx.deps.trustedTerminalHandleEnv.add(args.preAllocatedHandle) + } + const stablePaneOwnerCandidate = ctx.preAdoptedStablePane + ? ctx.preAdoptedStablePane.owner + : args.agentSessionEnsure + ? null + : resolveStablePaneOwner( + ctx.deps.runtime, + ctx.deps.store, + ctx.spawnIdentityPaneKey, + args.worktreeId, + args.connectionId + ) + const expectedPtyId = + stablePaneOwnerCandidate?.ptyId ?? ctx.effectiveSessionAppId ?? ctx.sessionId + if (expectedPtyId) { + ctx.deps.runtime?.beginPtyRegistration?.(expectedPtyId) + ctx.pendingRegistrationPtyId = expectedPtyId + } + if (ctx.isDaemonHostSpawn && expectedPtyId) { + ctx.preparedProvisionalExecutionContext = + ctx.deps.runtime?.preparePtyExecutionContext?.(expectedPtyId, ctx.expectedWslDistro, { + resetIncarnation: ctx.isNewDaemonSession && !stablePaneOwnerCandidate, + preserveExisting: !ctx.isNewDaemonSession || Boolean(stablePaneOwnerCandidate) + }) ?? false + } + const sequenceBeforeProviderSpawn = expectedPtyId + ? (ctx.deps.runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0) + : 0 + const assertClientStillConnected = (): void => { + if (args.signal?.aborted) { + throw new Error('client_disconnected') + } + } + if (args.agentSessionEnsure && !ctx.preAdoptedStablePane) { + // Why: daemon-backed claims can outlive this controller; import all + // proven owners before deciding that an identity is absent. + await reconcileAgentSessionOwnerListings() + const recoveredOwner = agentSessionOwners.find(args.agentSessionEnsure.claim) + if (recoveredOwner && ctx.pendingRegistrationPtyId !== recoveredOwner.ptyId) { + if (ctx.pendingRegistrationPtyId) { + ctx.deps.runtime?.cancelPendingPtyRegistration?.(ctx.pendingRegistrationPtyId) + } + ctx.deps.runtime?.beginPtyRegistration?.( + recoveredOwner.ptyId, + ptyIncarnationById.get(recoveredOwner.ptyId) + ) + ctx.pendingRegistrationPtyId = recoveredOwner.ptyId + } + let providerResult: PtySpawnResult | null = null + const ensured = await agentSessionOwners.ensure({ + claim: args.agentSessionEnsure.claim, + surface: args.agentSessionEnsure.surface, + spawn: async () => { + assertClientStillConnected() + providerResult = await ctx.provider.spawn(ctx.spawnOptions) + ctx.rejectedRegistrationCandidate = providerResult + // Why: a successful lower-owner return proves physical work committed even if admission sees an early exit. + ctx.reportPtySpawnCommitted() + assertSpawnReplyWasLive(providerResult) + ctx.deps.runtime?.assertPtyRegistrationAllowed?.( + providerResult.id, + providerResult.incarnationId + ) + if (providerResult.incarnationId) { + // Why: local providers cannot serialize controller claims, so liveness proof + // needs the exact incarnation before the registry promotes the new owner. + ptyIncarnationById.set(providerResult.id, providerResult.incarnationId) + } + const providerEnsure = providerResult.agentSessionEnsure + return { + ptyId: providerResult.id, + ...(providerEnsure + ? { + owner: providerEnsure.owner, + disposition: providerEnsure.disposition + } + : {}) + } + }, + isLive: async (owner) => { + const ownerProvider = tryGetProviderForAgentSessionOwner(owner.ptyId) + if (!ownerProvider) { + // Why: a disconnected relay may keep its PTY alive during the + // grace window; missing transport is unknown, never absence. + throw new Error('execution_owner_unavailable') + } + return await isProviderAgentSessionOwnerLive(ownerProvider, owner) + } + }) + ctx.result = providerResult ?? { + id: ensured.owner.ptyId, + isReattach: true, + // Why: adoption from an authoritative listing must preserve the + // incarnation proof used to reject a delayed exit from an older process. + incarnationId: ptyIncarnationById.get(ensured.owner.ptyId) + } + ctx.result.agentSessionEnsure = ensured + } else { + assertClientStillConnected() + const stablePaneSpawn = ctx.preAdoptedStablePane + ? ctx.preAdoptedStablePane + : await spawnForStablePane({ + runtime: ctx.deps.runtime, + store: ctx.deps.store, + provider: ctx.provider, + spawnOptions: ctx.spawnOptions, + owner: stablePaneOwnerCandidate, + worktreeId: args.worktreeId, + connectionId: args.connectionId, + resolveOwner: () => + resolveStablePaneOwner( + ctx.deps.runtime, + ctx.deps.store, + ctx.spawnIdentityPaneKey, + args.worktreeId, + args.connectionId + ), + onFreshSpawn: ctx.reportPtySpawnCommitted + }) + ctx.result = stablePaneSpawn.result + ctx.stablePaneOwner = stablePaneSpawn.owner + if ( + ctx.stablePaneOwner && + ctx.isNewDaemonSession && + ctx.effectiveSessionAppId && + ctx.effectiveSessionAppId !== ctx.result.id + ) { + clearProviderPtyState(ctx.effectiveSessionAppId) + } + ctx.rejectedRegistrationCandidate = ctx.result + assertSpawnReplyWasLive(ctx.result) + } + ctx.rejectedRegistrationCandidate ??= ctx.result + if (ctx.pendingRegistrationPtyId !== ctx.result.id) { + if (ctx.pendingRegistrationPtyId) { + ctx.deps.runtime?.cancelPendingPtyRegistration?.(ctx.pendingRegistrationPtyId) + } + ctx.deps.runtime?.beginPtyRegistration?.(ctx.result.id, ctx.result.incarnationId) + ctx.pendingRegistrationPtyId = ctx.result.id + } + // Why: admission precedes sequence/context state and every durable publication below. + ctx.deps.runtime?.assertPtyRegistrationAllowed?.(ctx.result.id, ctx.result.incarnationId) + if (ctx.result.providerSequence) { + const runtimeSequenceBeforeReconcile = + ctx.deps.runtime?.getPtyOutputSequence?.(ctx.result.id) ?? 0 + // Why kept: this is the reattach boundary in the RENDERER's sequence + // domain, and the daemon snapshot's kitty flags mean nothing without + // the boundary they were proven at. + ctx.reconciledSnapshotSeq = + ctx.deps.runtime?.synchronizePtyOutputSequenceFromProvider?.( + ctx.result.id, + ctx.result.providerSequence, + sequenceBeforeProviderSpawn + ) ?? null + if (runtimeSequenceBeforeReconcile > sequenceBeforeProviderSpawn) { + ctx.snapshotKittyFlagsCoverReconciledSeq = false + } + } + ensureWslHookRelayForReattach(ctx.result, args.connectionId) + ctx.deps.runtime?.preparePtyExecutionContext?.( + ctx.result.id, + args.connectionId + ? null + : ctx.result.wslDistro === undefined + ? ctx.expectedWslDistro + : ctx.result.wslDistro + ) + } catch (err) { + if ( + (ctx.isNewDaemonSession || ctx.preparedProvisionalExecutionContext) && + ctx.effectiveSessionAppId + ) { + ctx.deps.runtime?.preparePtyExecutionContext?.(ctx.effectiveSessionAppId, null, { + resetIncarnation: true + }) + } + const rawMessage = err instanceof Error ? err.message : String(err) + if (rawMessage === 'agent_session_exited_during_start' && ctx.rejectedRegistrationCandidate) { + ctx.deps.runtime?.releaseRejectedPtyRegistrationFence?.( + ctx.rejectedRegistrationCandidate.id, + ctx.rejectedRegistrationCandidate.incarnationId + ) + } + if (ctx.pendingRegistrationPtyId) { + ctx.deps.runtime?.cancelPendingPtyRegistration?.( + ctx.pendingRegistrationPtyId, + ctx.rejectedRegistrationCandidate?.incarnationId + ) + ctx.pendingRegistrationPtyId = null + } + const spawnError = normalizeNodePtySpawnError(err) + const isIdentityMismatch = + isSshPtyIdentityMismatchError(spawnError) || isSshPtyIdentityMismatchError(rawMessage) + const isExpiredSshSession = + Boolean(args.connectionId) && + (spawnError.message.includes(SSH_SESSION_EXPIRED_ERROR) || + rawMessage.includes(SSH_SESSION_EXPIRED_ERROR)) + const exitedBeforeSpawnReply = + ctx.rejectedRegistrationCandidate?.exitedBeforeSpawnReply === true + if (ctx.effectiveSessionAppId !== undefined) { + if ( + ctx.hadSessionSizeBeforeAttach && + ctx.sessionSizeBeforeAttach && + (isIdentityMismatch || (!isExpiredSshSession && !exitedBeforeSpawnReply)) + ) { + ptySizes.set(ctx.effectiveSessionAppId, ctx.sessionSizeBeforeAttach) + } else { + ptySizes.delete(ctx.effectiveSessionAppId) + } + } + if (args.connectionId && ctx.effectiveSessionRelayId !== undefined && isExpiredSshSession) { + if (ctx.effectiveSessionAppId !== undefined && !isIdentityMismatch) { + clearProviderPtyState(ctx.effectiveSessionAppId) + deletePtyOwnership(ctx.effectiveSessionAppId) + } + if (!isIdentityMismatch) { + ctx.deps.store?.markSshRemotePtyLease( + args.connectionId, + ctx.effectiveSessionRelayId, + 'expired' + ) + } + } + if (ctx.isNewDaemonSession && ctx.sessionId !== undefined) { + clearProviderPtyState(ctx.sessionId) + } + throw spawnError + } finally { + if (args.preAllocatedHandle) { + ctx.deps.trustedTerminalHandleEnv.delete(args.preAllocatedHandle) + } + } +} diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts new file mode 100644 index 00000000000..fcba7770a39 --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -0,0 +1,250 @@ +import type { IPtyProvider, PtySpawnResult } from '../../../providers/types' +import { LocalPtyProvider } from '../../../providers/local-pty-provider' +import { makePaneKey, isTerminalLeafId } from '../../../../shared/stable-pane-id' +import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' +import { ptySizes } from '../delivery/visibility-state' +import { CODEX_HOME_ENV_KEYS } from '../host-env/codex-home' +import { + mergePtyEnvDeletions, + removeCodexHomeDeletionRequests, + getInheritedAgentHookEnvKeysToDelete, + getInheritedClaudeSessionStampEnvKeysToDelete +} from '../host-env/pi-agent' +import { promoteAgentTeamsShimPath, deleteRequestedEnvKeys } from '../host-env/path' +import { + routesFreshSpawnsToLocalProvider, + beginPtySpawnForWorktree +} from '../host-env/fresh-spawn-routing' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { CLAUDE_AUTH_ENV_VARS } from '../../../claude-accounts/environment' +import { LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS } from '../../../pty/legacy-terminal-shim-dir' +import { resolveStablePaneOwner } from '../pane/stable-owner' +import { getStartupTerminalColorQueryReplyColors } from '../../terminal-startup-color-query-replies' +import { + makePaneSpawnReservationKey, + reservePaneSpawn, + paneSpawnReservationsByOwnerKey +} from '../pane/spawn-reservation' +import type { RuntimePtySpawnState } from './spawn-state' + +export async function buildRuntimePtySpawnOptions( + ctx: RuntimePtySpawnState +): Promise< + (PtySpawnResult & { stablePaneOwner?: { handle: string; tabId: string; leafId: string } }) | null +> { + const args = ctx.args + + const authEnvToDelete = ctx.claudeAuth?.stripAuthEnv + ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] + : undefined + ctx.spawnOptions = { + cols: args.cols, + rows: args.rows, + cwd: ctx.cwd, + env: ctx.env, + historyIsolationEnabled: ctx.deps.getSettings?.()?.terminalScopeHistoryByWorktree ?? true, + ...(ctx.isNewDaemonSession ? { isNewSession: true } : {}) + } + if (!args.connectionId && !ctx.isDaemonHostSpawn) { + ctx.spawnOptions.codexHomePathOverride = { value: ctx.selectedCodexHomePath } + } + const startupTerminalColorQueryReplyColors = getStartupTerminalColorQueryReplyColors(args) + if (startupTerminalColorQueryReplyColors) { + ctx.spawnOptions.startupIngress = { + colors: startupTerminalColorQueryReplyColors, + deadlineMs: 5_000 + } + } + let ptySpawnCommitReported = false + ctx.reportPtySpawnCommitted = (): void => { + if (ptySpawnCommitReported) { + return + } + ptySpawnCommitReported = true + args.onPtySpawnCommitted?.() + } + ctx.spawnOptions.envToDelete = mergePtyEnvDeletions( + authEnvToDelete, + args.envToDelete ?? [], + // Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH. + ctx.isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [], + ctx.isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(ctx.env) : [], + // Why: ungated, unlike the agent-hook keys — the local provider and the relay host also spread their own process.env into every spawn. + getInheritedClaudeSessionStampEnvKeysToDelete(ctx.env) + ) + if (ctx.skipCodexHomeEnv) { + ctx.spawnOptions.envToDelete = mergePtyEnvDeletions( + ctx.spawnOptions.envToDelete, + CODEX_HOME_ENV_KEYS + ) + } else if (ctx.stripInheritedOrcaCodexHome) { + // Why: the daemon owns a persistent inherited environment that may + // differ from main. ORCA_CODEX_HOME asks it to compare/delete the pair. + ctx.spawnOptions.envToDelete = mergePtyEnvDeletions(ctx.spawnOptions.envToDelete, [ + 'ORCA_CODEX_HOME' + ]) + } + if (ctx.codexResumeHomeSelected) { + ctx.spawnOptions.envToDelete = removeCodexHomeDeletionRequests(ctx.spawnOptions.envToDelete) + } + deleteRequestedEnvKeys(ctx.env, ctx.spawnOptions.envToDelete) + promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath) + if (ctx.launchCommand !== undefined) { + ctx.spawnOptions.command = ctx.launchCommand + } + if (args.commandDelivery !== undefined) { + ctx.spawnOptions.commandDelivery = args.commandDelivery + } + if (args.startupCommandDelivery !== undefined) { + ctx.spawnOptions.startupCommandDelivery = args.startupCommandDelivery + } + if (isTuiAgent(args.launchAgent)) { + ctx.spawnOptions.launchAgent = args.launchAgent + } + if (args.worktreeId !== undefined) { + ctx.spawnOptions.worktreeId = args.worktreeId + } + ctx.hadSessionSizeBeforeAttach = + ctx.effectiveSessionAppId !== undefined ? ptySizes.has(ctx.effectiveSessionAppId) : false + ctx.sessionSizeBeforeAttach = + ctx.effectiveSessionAppId !== undefined ? ptySizes.get(ctx.effectiveSessionAppId) : undefined + if (ctx.sessionId !== undefined) { + ctx.spawnOptions.sessionId = ctx.sessionId + ptySizes.set(ctx.effectiveSessionAppId ?? ctx.sessionId, { cols: args.cols, rows: args.rows }) + } + ctx.materializedPaneKey = ctx.hostSessionBinding + ? makePaneKey(ctx.hostSessionBinding.tabId, ctx.hostSessionBinding.leafId) + : null + ctx.metadataLeafId = + typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null + ctx.metadataPaneKey = + typeof args.tabId === 'string' && + isValidTerminalTabId(args.tabId) && + args.tabId.length <= 512 && + ctx.metadataLeafId + ? makePaneKey(args.tabId, ctx.metadataLeafId) + : null + ctx.spawnIdentityPaneKey = ctx.materializedPaneKey ?? ctx.metadataPaneKey + if (ctx.spawnIdentityPaneKey) { + ctx.spawnOptions.paneKey = ctx.spawnIdentityPaneKey + } + if (typeof args.tabId === 'string' && args.tabId.length > 0 && args.tabId.length <= 512) { + ctx.spawnOptions.tabId = args.tabId + } + if (process.platform === 'win32' && !args.connectionId) { + ctx.spawnOptions.shellOverride = ctx.terminalRuntimeOptions.shellOverride + ctx.spawnOptions.terminalWindowsWslDistro = ctx.expectedWslDistro + ctx.spawnOptions.terminalWindowsPowerShellImplementation = ctx.deps.getSettings + ? (ctx.deps.getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') + : undefined + } + if ( + !ctx.preAdoptedStablePane && + args.agentSessionEnsure && + (await (ctx.provider as IPtyProvider).supportsAgentSessionClaims?.()) === false + ) { + // Why: runtime routing must select legacy before dispatch; never downgrade here after it began. + throw new Error('agent_session_claim_unavailable') + } + if ( + !ctx.preAdoptedStablePane && + args.agentSessionCreateOperationId && + (await (ctx.provider as IPtyProvider).supportsAgentSessionCreateOperations?.()) === false + ) { + throw new Error('execution_owner_unavailable') + } + if (!ctx.preAdoptedStablePane && args.agentSessionEnsure) { + ctx.spawnOptions.agentSessionEnsure = args.agentSessionEnsure + } + if (!ctx.preAdoptedStablePane && args.agentSessionCreateOperationId) { + ctx.spawnOptions.agentSessionCreateOperationId = args.agentSessionCreateOperationId + } + if (args.signal) { + ctx.spawnOptions.signal = args.signal + } + if ( + args.onPtySpawnCommitted && + (ctx.provider instanceof LocalPtyProvider || routesFreshSpawnsToLocalProvider(ctx.provider)) + ) { + // Why: local fallback has no lower operation ledger, so commit must be reported at native spawn. + ctx.spawnOptions.onPtySpawnCommitted = ctx.reportPtySpawnCommitted + } + + const resolvedPaneSpawnReservationKey = makePaneSpawnReservationKey( + args.worktreeId, + args.connectionId, + ctx.spawnIdentityPaneKey + ) + if ( + ctx.paneSpawnReservationKey && + ctx.paneSpawnReservationKey !== resolvedPaneSpawnReservationKey + ) { + throw new Error('terminal_pane_identity_changed') + } + if (!ctx.paneSpawnReservationKey) { + ctx.paneSpawnReservationKey = resolvedPaneSpawnReservationKey + const existingPaneSpawn = ctx.spawnIdentityPaneKey + ? paneSpawnReservationsByOwnerKey.get(resolvedPaneSpawnReservationKey!) + : undefined + if (existingPaneSpawn) { + const concurrentResult = await existingPaneSpawn.promise + const concurrentOwner = resolveStablePaneOwner( + ctx.deps.runtime, + ctx.deps.store, + ctx.spawnIdentityPaneKey, + args.worktreeId, + args.connectionId + ) + if ( + !concurrentOwner?.handle || + concurrentOwner.ptyId !== concurrentResult.id || + (concurrentOwner.incarnationId !== undefined && + concurrentResult.incarnationId !== undefined && + concurrentOwner.incarnationId !== concurrentResult.incarnationId) + ) { + throw new Error('terminal_pane_owner_unknown') + } + const reattach = { + id: concurrentOwner.ptyId, + ...(concurrentOwner.incarnationId ? { incarnationId: concurrentOwner.incarnationId } : {}), + stablePaneOwner: { + handle: concurrentOwner.handle, + tabId: concurrentOwner.tabId, + leafId: concurrentOwner.leafId + } + } + // Why: the winner owns its id's size; only a distinct losing session id remains provisional. + if (ctx.sessionId !== undefined) { + const provisionalSizeKey = ctx.effectiveSessionAppId ?? ctx.sessionId + if (provisionalSizeKey !== reattach.id) { + if (ctx.hadSessionSizeBeforeAttach && ctx.sessionSizeBeforeAttach) { + ptySizes.set(provisionalSizeKey, ctx.sessionSizeBeforeAttach) + } else { + ptySizes.delete(provisionalSizeKey) + } + } + } + return reattach + } + } + ctx.finishTerminalInstall = beginPtySpawnForWorktree(args.worktreeId, ctx.cwd, args.connectionId) + ctx.paneSpawnReservation ??= ctx.paneSpawnReservationKey + ? reservePaneSpawn(ctx.paneSpawnReservationKey) + : null + ctx.stablePaneOwner = null + ctx.stablePaneBindingPersisted = false + ctx.rejectedRegistrationCandidate = null + ctx.pendingRegistrationPtyId = null + // Why hoisted to the reply scope: main reconciles the provider sequence + // deep inside the spawn path, but the pane needs that renderer-domain + // boundary beside the daemon snapshot's kitty flags. + ctx.reconciledSnapshotSeq = null + // False when bytes crossed the data socket during the spawn RPC: the + // reconciled boundary covers them, but the daemon proved its kitty flags + // before they existed, so the claim must not erase what the pane may + // have scanned from those bytes live. + ctx.snapshotKittyFlagsCoverReconciledSeq = true + ctx.preparedProvisionalExecutionContext = false + + return null +} diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts new file mode 100644 index 00000000000..45c3ea1172f --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -0,0 +1,281 @@ +import { getAppEnvironment } from '../../../../shared/app-environment' +import type { PtySpawnResult } from '../../../providers/types' +import { LocalPtyProvider } from '../../../providers/local-pty-provider' +import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' +import { isTerminalLeafId } from '../../../../shared/stable-pane-id' +import { getAppPtyId, getProvider, getRelayPtyId } from '../provider/registry' +import { buildPtyHostEnv } from '../host-env/assembly' +import { + getCompatibleSelectedCodexHomePath, + getCodexSelectionTargetForPty, + resolveCodexHomeAfterManagedAuthReadiness, + shouldSkipCodexHomeEnvForWindowsShell, + shouldStripInheritedOrcaCodexHome, + isCodexStatusHooksEnabled, + codexHomePathsEqual +} from '../host-env/codex-home' +import { promoteAgentTeamsShimPath } from '../host-env/path' +import { + isClaudeLaunchCommand, + recoverFreshSpawnProviderRouting, + routesFreshSpawnsToLocalProvider +} from '../host-env/fresh-spawn-routing' +import { stripRemotePaneEnvWhenHooksDisabled } from '../provider/liveness' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { isClaudeAuthSwitchInProgress } from '../../../claude-accounts/live-pty-gate' +import { hasClaudeAuthEnvConflict } from '../../../claude-accounts/environment' +import { + isSafePtySessionId, + mintPtySessionId, + ptySessionIdForAgentCreateOperation +} from '../../../daemon/pty-session-id' +import { resolveWslSessionContext } from '../../../daemon/wsl-session-context' +import { isAgentStatusHooksEnabled } from '../../../agent-hooks/managed-agent-hook-controls' +import { resolveLocalWindowsTerminalRuntimeOptions } from '../../../../shared/local-windows-terminal-runtime' +import { resolveLocalProjectRuntimeForWorktreeId } from '../../../local-project-runtime-resolution' +import { resolvePathEnvKey } from '../../../pty/windows-environment-path' +import { stampWslOrchestrationCompatibilityHost } from '../../../pty/wsl-orca-env' +import { ensureCodexStateDbBackfillRecoveryStarted } from '../../../codex/codex-state-db-backfill-recovery' +import { clearProviderPtyState } from '../provider/state-cleanup' +import type { RuntimePtySpawnState } from './spawn-state' + +export async function prepareRuntimePtySpawn( + ctx: RuntimePtySpawnState +): Promise { + const args = ctx.args + if (!ctx.preAdoptedStablePane) { + const pathUsable = ctx.deps.assertFolderWorkspacePtyPathUsable(args.worktreeId) + if (pathUsable) { + await pathUsable + } + } + ctx.cwd = ctx.deps.resolvePtySpawnStartupCwd(args.worktreeId, args.cwd) + ctx.provider = getProvider(args.connectionId) + const freshSpawnRecovery = ctx.preAdoptedStablePane + ? undefined + : recoverFreshSpawnProviderRouting( + ctx.provider, + args.connectionId, + args.sessionId, + args.isNewSession + ) + if (freshSpawnRecovery) { + await freshSpawnRecovery + } + ctx.isClaudeLaunch = + !ctx.preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) + if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { + throw new Error('A Claude account switch is in progress. Try again after it finishes.') + } + // Why: runtime-created terminals carry no renderer-computed projectRuntime; resolve from worktreeId to honor the project's Windows runtime. + ctx.terminalRuntimeOptions = + process.platform === 'win32' && !args.connectionId + ? resolveLocalWindowsTerminalRuntimeOptions({ + requestedShellOverride: undefined, + settings: ctx.deps.getSettings?.(), + projectRuntime: resolveLocalProjectRuntimeForWorktreeId(ctx.deps.store, args.worktreeId), + fallbackHostShell: process.env.COMSPEC || 'powershell.exe' + }) + : { shellOverride: undefined, terminalWindowsWslDistro: null } + ctx.daemonShellOverride = ctx.terminalRuntimeOptions.shellOverride + ctx.isDaemonHostSpawn = + !args.connectionId && + !(ctx.provider instanceof LocalPtyProvider) && + !routesFreshSpawnsToLocalProvider(ctx.provider) + ctx.callerRequestedSessionId = args.sessionId?.trim() + ctx.requestedSessionId = + ctx.callerRequestedSessionId ?? + (ctx.isDaemonHostSpawn && args.agentSessionCreateOperationId + ? ptySessionIdForAgentCreateOperation(args.worktreeId, args.agentSessionCreateOperationId) + : undefined) + ctx.sessionId = + ctx.requestedSessionId ?? + (ctx.isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined) + ctx.effectiveSessionRelayId = + ctx.sessionId !== undefined ? getRelayPtyId(args.connectionId, ctx.sessionId) : undefined + ctx.effectiveSessionAppId = + ctx.sessionId !== undefined ? getAppPtyId(args.connectionId, ctx.sessionId) : undefined + ctx.isNewDaemonSession = + !ctx.preAdoptedStablePane && + ctx.isDaemonHostSpawn && + (ctx.callerRequestedSessionId === undefined || args.isNewSession === true) + ctx.expectedWslDistro = !args.connectionId + ? (resolveWslSessionContext({ + cwd: ctx.cwd, + sessionId: ctx.sessionId, + shellOverride: ctx.terminalRuntimeOptions.shellOverride, + terminalWindowsWslDistro: ctx.terminalRuntimeOptions.terminalWindowsWslDistro + })?.distro ?? null) + : null + ctx.codexSelectionTarget = getCodexSelectionTargetForPty( + ctx.daemonShellOverride, + ctx.cwd, + ctx.expectedWslDistro + ) + const codexResumePreparation = ctx.preAdoptedStablePane + ? null + : ctx.deps.prepareCodexResumeHome({ + connectionId: args.connectionId, + launchAgent: args.launchAgent, + providerSession: args.resumeProviderSession, + target: ctx.codexSelectionTarget, + launchEnv: args.env, + workspacePath: ctx.cwd + }) + const codexResumeLaunch = codexResumePreparation + ? await ctx.deps.resolveCodexResumeLaunch(args.command, codexResumePreparation) + : ctx.deps.noCodexResumeLaunch(ctx.preAdoptedStablePane ? undefined : args.command) + const codexResumeHome = codexResumeLaunch.codexResumeHome + // Why: the drop still applies here, but this controller's result has no field for + // notifyResumeUnavailable — runtime/relay panes start fresh without the notice. + ctx.launchCommand = codexResumeLaunch.command + ctx.claudeAuth = + ctx.isClaudeLaunch && ctx.deps.prepareClaudeAuth + ? await ctx.deps.prepareClaudeAuth(ctx.codexSelectionTarget) + : null + if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { + throw new Error('A Claude account switch is in progress. Try again after it finishes.') + } + if (ctx.claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) { + throw new Error( + 'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.' + ) + } + + ctx.shouldPersistHostSessionBinding = args.persistHostSessionBinding === true + if (ctx.shouldPersistHostSessionBinding) { + if ( + !ctx.deps.store || + typeof args.worktreeId !== 'string' || + typeof args.tabId !== 'string' || + !isValidTerminalTabId(args.tabId) || + typeof args.leafId !== 'string' || + !isTerminalLeafId(args.leafId) + ) { + throw new Error('Cannot persist runtime PTY binding without worktreeId, tabId, and leafId') + } + ctx.hostSessionBinding = { + store: ctx.deps.store, + worktreeId: args.worktreeId, + tabId: args.tabId, + leafId: args.leafId, + ...(args.expectedSourceBinding ? { expectedSourceBinding: args.expectedSourceBinding } : {}) + } + } + const sshScopedEnv = stripRemotePaneEnvWhenHooksDisabled(args.connectionId, args.env) + ctx.env = ctx.claudeAuth ? { ...sshScopedEnv, ...ctx.claudeAuth.envPatch } : sshScopedEnv + ctx.requestedAgentTeamsPath = ctx.env?.ORCA_AGENT_TEAMS_TEAM_ID + ? ctx.env[resolvePathEnvKey(ctx.env, process.platform)] + : undefined + ctx.env = ctx.deps.stripSequencedStartupResumeArgv(ctx.env, codexResumeLaunch) + if (args.preAllocatedHandle) { + ctx.env = { ...ctx.env, ORCA_TERMINAL_HANDLE: args.preAllocatedHandle } + } + ctx.selectedCodexHomePath = + !ctx.preAdoptedStablePane && !args.connectionId + ? getCompatibleSelectedCodexHomePath( + ctx.codexSelectionTarget, + codexResumeHome + ? ctx.deps.reconcileSharedRuntimeResumeHome(codexResumeHome, () => + getCompatibleSelectedCodexHomePath( + ctx.codexSelectionTarget, + ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env, { + workspacePath: ctx.cwd, + launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined + }) ?? null + ) + ) + : (ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env, { + workspacePath: ctx.cwd, + launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined + }) ?? null) + ) + : null + if ( + !ctx.preAdoptedStablePane && + args.launchAgent === 'codex' && + ctx.callerRequestedSessionId === undefined + ) { + const resolution = resolveCodexHomeAfterManagedAuthReadiness({ + selectedCodexHomePath: ctx.selectedCodexHomePath, + getSettings: () => ctx.deps.getSettings?.(), + requiredCodexHomePath: codexResumeHome?.codexHomePath, + target: ctx.codexSelectionTarget, + resolveCurrent: () => + getCompatibleSelectedCodexHomePath( + ctx.codexSelectionTarget, + ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env, { + workspacePath: ctx.cwd, + launchAgent: 'codex' + }) ?? null + ), + resolveAfterUnavailable: (unavailableManagedHomePath) => + getCompatibleSelectedCodexHomePath( + ctx.codexSelectionTarget, + ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env, { + workspacePath: ctx.cwd, + launchAgent: 'codex', + unavailableManagedHomePath + }) ?? null + ) + }) + ctx.selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution + } + if (args.launchAgent === 'codex' && ctx.selectedCodexHomePath) { + await ensureCodexStateDbBackfillRecoveryStarted(ctx.selectedCodexHomePath) + } + ctx.codexResumeHomeSelected = Boolean( + codexResumeHome && codexHomePathsEqual(ctx.selectedCodexHomePath, codexResumeHome.codexHomePath) + ) + ctx.skipCodexHomeEnv = + ctx.isDaemonHostSpawn && + shouldSkipCodexHomeEnvForWindowsShell(ctx.daemonShellOverride, ctx.cwd) && + !ctx.selectedCodexHomePath + const ptySettings = ctx.isDaemonHostSpawn ? ctx.deps.getSettings?.() : undefined + ctx.stripInheritedOrcaCodexHome = + ctx.isDaemonHostSpawn && + shouldStripInheritedOrcaCodexHome({ + target: ctx.codexSelectionTarget, + selectedCodexHomePath: ctx.selectedCodexHomePath, + skipCodexHomeEnv: ctx.skipCodexHomeEnv, + settings: ptySettings + }) + if (ctx.isDaemonHostSpawn && ctx.sessionId && !ctx.preAdoptedStablePane) { + if (!isSafePtySessionId(ctx.sessionId, getAppEnvironment().getPath('userData'))) { + throw new Error('Invalid PTY session id') + } + try { + ctx.env = buildPtyHostEnv(ctx.sessionId, ctx.env ?? {}, { + isPackaged: getAppEnvironment().isPackaged(), + resourcesPath: process.resourcesPath, + userDataPath: getAppEnvironment().getPath('userData'), + selectedCodexHomePath: ctx.selectedCodexHomePath, + skipCodexHomeEnv: ctx.skipCodexHomeEnv, + stripInheritedOrcaCodexHome: ctx.stripInheritedOrcaCodexHome, + launchCommand: ctx.launchCommand, + launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, + isWsl: shouldSkipCodexHomeEnvForWindowsShell(ctx.daemonShellOverride, ctx.cwd), + wslDistro: ctx.codexSelectionTarget.runtime === 'wsl' ? ctx.expectedWslDistro : null, + agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings), + codexStatusHooksEnabled: isCodexStatusHooksEnabled(ptySettings), + networkProxySettings: ptySettings, + deferGitConfigGuardToDaemon: + ctx.provider.supportsGitCredentialGuardHost?.(ctx.sessionId) === true + }) + stampWslOrchestrationCompatibilityHost( + ctx.env, + ctx.deps.runtime?.getOrchestrationCompatibilityHostId?.(), + ctx.codexSelectionTarget.runtime === 'wsl' ? ctx.expectedWslDistro : null + ) + promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath) + } catch (error) { + // Why: host-env setup can materialize agent hooks/extensions before failing. + if (ctx.requestedSessionId === undefined) { + clearProviderPtyState(ctx.sessionId) + } + throw error + } + } + + return null +} diff --git a/src/main/ipc/pty/runtime/spawn-state.ts b/src/main/ipc/pty/runtime/spawn-state.ts new file mode 100644 index 00000000000..81878fa029b --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-state.ts @@ -0,0 +1,185 @@ +import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../../../providers/types' +import type { CodexPaneHomeRoute } from '../../../codex/codex-pane-account-registry' +import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' +import type { ClaudeRuntimeAuthPreparation } from '../../../claude-accounts/runtime-auth-service' +import type { StablePaneOwner } from '../pane/stable-owner' +import type { PaneSpawnReservation } from '../pane/spawn-reservation' +import type { AdoptStablePaneResult } from '../ipc/spawn-types' +import type { PtyBindingSourceExpectation } from '../../../persistence' +import type { PtyRuntimeControllerDeps } from './controller-deps' +import type { TuiAgent } from '../../../../shared/tui-agent' +import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' +import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' +import type { + AgentSessionExecutionClaim, + AgentSessionSurfaceBinding +} from '../../../../shared/agent-session-host-authority' +import { localProvider } from '../provider/registry' + +export type RuntimePtySpawnState = { + deps: PtyRuntimeControllerDeps + args: RuntimePtySpawnArgs + codexHomeLaunchStartedAt: Date | undefined + codexHomeLaunchStartedSequence: number | undefined + preAdoptedStablePane: AdoptStablePaneResult | null + reattachedCodexHomeRoutes: Map + cwd: string | undefined + provider: IPtyProvider + isClaudeLaunch: boolean + terminalRuntimeOptions: { shellOverride?: string; terminalWindowsWslDistro?: string | null } + daemonShellOverride: string | undefined + isDaemonHostSpawn: boolean + callerRequestedSessionId: string | undefined + requestedSessionId: string | undefined + sessionId: string | undefined + effectiveSessionRelayId: string | undefined + effectiveSessionAppId: string | undefined + isNewDaemonSession: boolean + expectedWslDistro: string | null + codexSelectionTarget: CodexAccountSelectionTarget + launchCommand: string | undefined + claudeAuth: ClaudeRuntimeAuthPreparation | null + shouldPersistHostSessionBinding: boolean + hostSessionBinding: + | { + store: NonNullable + worktreeId: string + tabId: string + leafId: string + expectedSourceBinding?: PtyBindingSourceExpectation + } + | undefined + env: Record | undefined + requestedAgentTeamsPath: string | undefined + selectedCodexHomePath: string | null + codexResumeHomeSelected: boolean + skipCodexHomeEnv: boolean + stripInheritedOrcaCodexHome: boolean + spawnOptions: PtySpawnOptions + hadSessionSizeBeforeAttach: boolean + sessionSizeBeforeAttach: { cols: number; rows: number } | undefined + materializedPaneKey: string | null + metadataLeafId: string | null + metadataPaneKey: string | null + spawnIdentityPaneKey: string | null + paneSpawnReservationKey: string | null + paneSpawnReservation: PaneSpawnReservation | null + finishTerminalInstall: () => void + result: PtySpawnResult & { + stablePaneOwner?: { handle: string; tabId: string; leafId: string } + agentSessionEnsure?: unknown + } + stablePaneOwner: StablePaneOwner | null + stablePaneBindingPersisted: boolean + rejectedRegistrationCandidate: PtySpawnResult | null + pendingRegistrationPtyId: string | null + reconciledSnapshotSeq: number | null + snapshotKittyFlagsCoverReconciledSeq: boolean + preparedProvisionalExecutionContext: boolean + releaseWorktreeSpawn: (() => void) | undefined + reportPtySpawnCommitted: () => void +} + +export type RuntimePtySpawnArgs = { + cols: number + rows: number + cwd?: string + command?: string + launchAgent?: TuiAgent + commandDelivery?: 'renderer' | 'provider' + startupCommandDelivery?: StartupCommandDelivery + telemetry?: { + agent_kind?: unknown + launch_source?: unknown + request_kind?: unknown + } + env?: Record + envToDelete?: string[] + resumeProviderSession?: AgentProviderSessionMetadata + connectionId?: string | null + worktreeId?: string + preAllocatedHandle?: string + tabId?: string + leafId?: string + sessionId?: string + isNewSession?: boolean + persistHostSessionBinding?: boolean + expectedSourceBinding?: PtyBindingSourceExpectation + terminalColorQueryReplies?: { foreground?: string; background?: string } + agentSessionEnsure?: { + claim: AgentSessionExecutionClaim + surface: AgentSessionSurfaceBinding + } + agentSessionCreateOperationId?: string + signal?: AbortSignal + onPtySpawnCommitted?: () => void + adoptedStablePane?: { + result: PtySpawnResult + owner: { + handle?: string + tabId: string + leafId: string + ptyId: string + incarnationId?: string + } + materialized?: true + } +} + +export function createRuntimePtySpawnState( + deps: PtyRuntimeControllerDeps, + args: RuntimePtySpawnArgs +): RuntimePtySpawnState { + return { + deps, + args, + codexHomeLaunchStartedAt: undefined, + codexHomeLaunchStartedSequence: undefined, + preAdoptedStablePane: null, + reattachedCodexHomeRoutes: new Map(), + cwd: undefined, + provider: localProvider, + isClaudeLaunch: false, + terminalRuntimeOptions: {}, + daemonShellOverride: undefined, + isDaemonHostSpawn: false, + callerRequestedSessionId: undefined, + requestedSessionId: undefined, + sessionId: undefined, + effectiveSessionRelayId: undefined, + effectiveSessionAppId: undefined, + isNewDaemonSession: false, + expectedWslDistro: null, + codexSelectionTarget: { runtime: 'host' }, + launchCommand: undefined, + claudeAuth: null, + shouldPersistHostSessionBinding: false, + hostSessionBinding: undefined, + env: undefined, + requestedAgentTeamsPath: undefined, + selectedCodexHomePath: null, + codexResumeHomeSelected: false, + skipCodexHomeEnv: false, + stripInheritedOrcaCodexHome: false, + spawnOptions: { cols: args.cols, rows: args.rows }, + hadSessionSizeBeforeAttach: false, + sessionSizeBeforeAttach: undefined, + materializedPaneKey: null, + metadataLeafId: null, + metadataPaneKey: null, + spawnIdentityPaneKey: null, + paneSpawnReservationKey: null, + paneSpawnReservation: null, + finishTerminalInstall: () => {}, + result: { id: '' }, + stablePaneOwner: null, + stablePaneBindingPersisted: false, + rejectedRegistrationCandidate: null, + pendingRegistrationPtyId: null, + reconciledSnapshotSeq: null, + snapshotKittyFlagsCoverReconciledSeq: true, + preparedProvisionalExecutionContext: false, + releaseWorktreeSpawn: undefined, + reportPtySpawnCommitted: () => {} + } +} diff --git a/src/main/ipc/pty/runtime/spawn.ts b/src/main/ipc/pty/runtime/spawn.ts new file mode 100644 index 00000000000..2af050cd796 --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn.ts @@ -0,0 +1,119 @@ +import type { AgentSessionClaimedSpawnResult } from '../../../../shared/agent-session-host-authority' +import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id' +import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' +import { + makePaneSpawnReservationKey, + paneSpawnReservationsByOwnerKey, + rejectPaneSpawnReservation, + reservePaneSpawn +} from '../pane/spawn-reservation' +import { resolveStablePaneOwner } from '../pane/stable-owner' +import { ptySizes } from '../delivery/visibility-state' +import type { PtyRuntimeControllerDeps } from './controller-deps' +import { adoptMaterializedRuntimePtySpawn } from './spawn-early' +import { prepareRuntimePtySpawn } from './spawn-preflight' +import { buildRuntimePtySpawnOptions } from './spawn-options' +import { executeRuntimePtySpawn } from './spawn-execute' +import { commitRuntimePtySpawn } from './spawn-commit' +import { createRuntimePtySpawnState, type RuntimePtySpawnArgs } from './spawn-state' + +function toRuntimeSpawnReply(result: { + id: string + incarnationId?: string + wslDistro?: string | null + stablePaneOwner?: { handle: string; tabId: string; leafId: string } + agentSessionEnsure?: AgentSessionClaimedSpawnResult +}) { + return { + id: result.id, + ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), + ...(typeof result.wslDistro === 'string' ? { wslDistro: result.wslDistro } : {}), + ...(result.stablePaneOwner ? { stablePaneOwner: result.stablePaneOwner } : {}), + ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}) + } +} + +function restoreProvisionalPtySize(ctx: ReturnType): void { + if (ctx.sessionId === undefined) { + return + } + const key = ctx.effectiveSessionAppId ?? ctx.sessionId + if (ctx.hadSessionSizeBeforeAttach && ctx.sessionSizeBeforeAttach) { + ptySizes.set(key, ctx.sessionSizeBeforeAttach) + } else { + ptySizes.delete(key) + } +} + +export async function spawnPtyFromRuntimeController( + deps: PtyRuntimeControllerDeps, + args: RuntimePtySpawnArgs +) { + const ctx = createRuntimePtySpawnState(deps, args) + if (!args.adoptedStablePane) { + const leafId = + typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null + const paneKey = + typeof args.worktreeId === 'string' && + typeof args.tabId === 'string' && + isValidTerminalTabId(args.tabId) && + args.tabId.length <= 512 && + leafId + ? makePaneKey(args.tabId, leafId) + : null + const ownerKey = makePaneSpawnReservationKey(args.worktreeId, args.connectionId, paneKey) + const existingOwner = paneKey + ? resolveStablePaneOwner( + deps.runtime, + deps.store, + paneKey, + args.worktreeId, + args.connectionId + ) + : null + if (ownerKey && !existingOwner && !paneSpawnReservationsByOwnerKey.has(ownerKey)) { + ctx.paneSpawnReservationKey = ownerKey + ctx.paneSpawnReservation = reservePaneSpawn(ownerKey) + } + } + try { + const materializedOrPromise = adoptMaterializedRuntimePtySpawn(ctx) + const materialized = + materializedOrPromise instanceof Promise ? await materializedOrPromise : materializedOrPromise + if (materialized) { + return toRuntimeSpawnReply(materialized) + } + const earlyAdopt = await prepareRuntimePtySpawn(ctx) + if (earlyAdopt) { + return toRuntimeSpawnReply(earlyAdopt) + } + const earlyReserved = await buildRuntimePtySpawnOptions(ctx).catch((error: unknown) => { + restoreProvisionalPtySize(ctx) + throw error + }) + if (earlyReserved) { + return toRuntimeSpawnReply(earlyReserved) + } + await executeRuntimePtySpawn(ctx) + return toRuntimeSpawnReply(await commitRuntimePtySpawn(ctx)) + } catch (err) { + if (ctx.pendingRegistrationPtyId) { + deps.runtime?.cancelPendingPtyRegistration?.( + ctx.pendingRegistrationPtyId, + ctx.rejectedRegistrationCandidate?.incarnationId + ) + ctx.pendingRegistrationPtyId = null + } + // Why: once the reservation is created, any later throw — spawn + // failure, persist failure, or a post-spawn helper such as + // registerPty/rememberPaneKeyForPty/track — must settle it. Otherwise + // it lingers in paneSpawnReservationsByOwnerKey and every future spawn + // for this pane awaits a promise that never resolves. reject is a + // no-op once the reservation has already resolved. + rejectPaneSpawnReservation(ctx.paneSpawnReservationKey, ctx.paneSpawnReservation, err) + throw err + } finally { + ctx.releaseWorktreeSpawn?.() + ctx.finishTerminalInstall() + } +} diff --git a/src/main/ipc/pty/session.ts b/src/main/ipc/pty/session.ts new file mode 100644 index 00000000000..423ecabbe3d --- /dev/null +++ b/src/main/ipc/pty/session.ts @@ -0,0 +1,289 @@ +import type { BrowserWindow } from 'electron' +import type { OrcaRuntimeService } from '../../runtime/orca-runtime' +import type { Store } from '../../persistence' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { IPtyProvider } from '../../providers/types' +import type { LegacySshProjectionSemantics } from '../ssh-pty-legacy-projection' +import type { PtyModelRestoreReason } from '../../../shared/pty-model-restore-marker' +import type { + PtyDeliveryWriteOff, + PtyRendererDeliveryStateReport +} from '../../../shared/pty-renderer-delivery-health' +import type { PtyRendererDeliveryDebugSnapshot } from './delivery/debug' +import { PtyProducerFlowController } from '../pty-producer-flow-control' +import { PtyPendingDataDrainQueue, type PendingPtyData } from '../pty-pending-data-drain-queue' +import type { SshPtyOutputIntake } from '../ssh-pty-output-intake' +import { activeRendererPtys } from './delivery/visibility-state' +import { + isHiddenPtyDeliveryGateEnabled, + shouldDropHiddenRendererPtyData +} from '../pty-hidden-delivery-gate' +import type { CodexHomePtySpawnedLifecycleArgs, PrepareCodexSessionResume } from './host-env/types' +import { tryGetProviderForPty } from './provider/registry' + +export type PtyDataPayload = { + id: string + data: string + seq?: number + rawLength?: number + transformed?: boolean + background?: boolean + droppedOutput?: boolean +} + +export type RendererPtyDeliveryAccounting = { + sentChars: number + ackedChars: number + lastSendAtMs: number + lastAckAtMs: number | null +} + +export type SerializeResult = { + data: string + cols: number + rows: number + seq?: number + lastTitle?: string + kittyKeyboardFlags?: number +} | null + +export type PtyIpcSessionOptions = { + prepareCodexSessionResume?: PrepareCodexSessionResume + awaitLocalPtyStartup?: () => Promise + awaitLocalPtyProviderStartup?: () => Promise + // Why: returns true once for the crash-recovery reload so its did-finish-load skips the orphan sweep and keeps live PTYs (#5787). + isRecoveryReloadInFlight?: (webContentsId: number) => boolean + onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void + onPtyExit?: (id: string, exitSequence: number) => void +} + +export type PtyIpcSession = { + mainWindow: BrowserWindow + runtime?: OrcaRuntimeService + store?: Store + getSettings?: () => GlobalSettings + options?: PtyIpcSessionOptions + pendingData: PtyPendingDataDrainQueue + sshOutputIntake: SshPtyOutputIntake | null + rendererExitingPtyIds: Set + rendererCreditBeforeExitByPty: Map + rendererDeliveryRestoreNeededPtys: Set + pendingOverflowMarkedPtys: Set + rendererDeliveryAccountingByPty: Map + trustedTerminalHandleEnv: Set + flushTimer: ReturnType | null + pendingDataFlushActive: boolean + pendingDataCreditReleasedDuringFlush: boolean + rendererInFlightTotalChars: number + pendingDroppedChars: number + deliveryResyncRequestSerial: number + deliveryResyncOutstandingRequestId: number | null + deliveryResyncTimer: ReturnType | null + deliveryResyncUnansweredWarnLogged: boolean + lastAckReceivedAtMs: number | null + peakPendingChars: number + peakMaxPendingCharsByPty: number + peakRendererInFlightChars: number + peakMaxRendererInFlightCharsByPty: number + ackGatedFlushSkipCount: number + rendererLifecycleResetCount: number + lastLifecycleResetClearedChars: number + rendererDispatcherReadyForcedCount: number + rendererPtyDispatcherReady: boolean + dispatcherReadyWatchdogTimer: ReturnType | null + lastHiddenDropContradictionWarnAtMs: number + pendingDataDropWarnedPtys: Set + producerFlowControl: PtyProducerFlowController + sourceCreditPendingPtys: Set + backgroundedDeliverySyncByPty: Map + syntheticKillExitPtyIds: Map + reversibleStopOwnersByPtyId: Map + retiredRejectedPtyIds: Map + pendingSerializeRequests: Map< + string, + { resolve: (result: SerializeResult) => void; timeout: NodeJS.Timeout } + > + canSendPtyDataToRenderer: (id: string, options?: { interactive?: boolean }) => boolean + schedulePendingDataFlush: (delayMs: number) => void + flushPendingData: () => void + sendPtyDataToRenderer: ( + id: string, + payload: PtyDataPayload, + projectionAdmissionIds?: readonly string[] + ) => { sent: boolean; projectionsTransferred: boolean } + sendModelRestoreNeededMarker: ( + id: string, + reason: PtyModelRestoreReason, + markerSeq: number | undefined + ) => boolean + updateProducerFlowControl: (id: string) => void + applyCumulativeAck: (id: string, processedChars: number) => number + readCurrentPtyRendererDeliveryDebugSnapshot: () => PtyRendererDeliveryDebugSnapshot + clearDeliveryResyncProbe: () => void + clearPendingPtyData: () => void + deletePendingPtyData: (id: string) => void + setPendingPtyData: (id: string, pending: PendingPtyData) => void + clearDispatcherReadyWatchdog: () => void + armDispatcherReadyWatchdog: () => void + acceptPtyDataForRenderer: ( + payload: { + id: string + data: string + sequenceChars?: number + transformed?: boolean + }, + outputSeq: number | undefined, + projection?: LegacySshProjectionSemantics + ) => void + preparePtyExitForRenderer: (payload: { + id: string + code: number + incarnationId?: string + }) => (() => void) | null + finalizePtyExitForRenderer: (payload: { + id: string + code: number + incarnationId?: string + }) => void + sendPtyExitToRenderer: (payload: { id: string; code: number; incarnationId?: string }) => void + sendPtySpawnedToRenderer: (id: string) => void + requestSerializedBuffer: ( + ptyId: string, + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + ) => Promise + shutdownProviderAndDetectExit: ( + provider: IPtyProvider, + id: string, + opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } + ) => Promise + rememberSyntheticKillExit: (id: string) => void + rememberRetiredRejectedPty: (id: string) => void + consumeSyntheticKillExit: (id: string) => boolean + syncPtyBackgroundedDelivery: (id: string, caller: string) => void + resyncBackgroundedDeliveriesAfterGateReset: () => void + transitionHiddenRendererPtyDeliveryState: ( + id: string, + hidden: boolean + ) => { droppable: boolean; droppedWhileHidden: boolean; policyChanged: boolean } + transitionSpawnHiddenRendererPtyDeliveryState: (id: string, hidden: boolean) => void + rendererPtyIsKnownHidden: (id: string) => boolean + clearHiddenRendererResizeOutput: (id: string) => void + clearDeliveredHiddenRendererResizeOutput: (id: string) => void + schedulePendingDataAfterCreditReport: (creditedAny: boolean) => void + writeOffLostRendererDelivery: (report: PtyRendererDeliveryStateReport) => PtyDeliveryWriteOff[] + getRendererInFlightCharsForPty: (id: string) => number +} + +const unsetSessionFn = (): never => { + throw new Error('pty ipc session function used before wiring') +} + +export function createPtyIpcSession(args: { + mainWindow: BrowserWindow + runtime?: OrcaRuntimeService + store?: Store + getSettings?: () => GlobalSettings + options?: PtyIpcSessionOptions +}): PtyIpcSession { + const session: PtyIpcSession = { + mainWindow: args.mainWindow, + runtime: args.runtime, + store: args.store, + getSettings: args.getSettings, + options: args.options, + pendingData: undefined as unknown as PtyPendingDataDrainQueue, + sshOutputIntake: null, + rendererExitingPtyIds: new Set(), + rendererCreditBeforeExitByPty: new Map(), + rendererDeliveryRestoreNeededPtys: new Set(), + pendingOverflowMarkedPtys: new Set(), + rendererDeliveryAccountingByPty: new Map(), + trustedTerminalHandleEnv: new Set(), + flushTimer: null, + pendingDataFlushActive: false, + pendingDataCreditReleasedDuringFlush: false, + rendererInFlightTotalChars: 0, + pendingDroppedChars: 0, + deliveryResyncRequestSerial: 0, + deliveryResyncOutstandingRequestId: null, + deliveryResyncTimer: null, + deliveryResyncUnansweredWarnLogged: false, + lastAckReceivedAtMs: null, + peakPendingChars: 0, + peakMaxPendingCharsByPty: 0, + peakRendererInFlightChars: 0, + peakMaxRendererInFlightCharsByPty: 0, + ackGatedFlushSkipCount: 0, + rendererLifecycleResetCount: 0, + lastLifecycleResetClearedChars: 0, + rendererDispatcherReadyForcedCount: 0, + rendererPtyDispatcherReady: false, + dispatcherReadyWatchdogTimer: null, + lastHiddenDropContradictionWarnAtMs: 0, + pendingDataDropWarnedPtys: new Set(), + producerFlowControl: new PtyProducerFlowController({ + pauseProducer: (id) => tryGetProviderForPty(id)?.pauseProducer?.(id), + resumeProducer: (id) => tryGetProviderForPty(id)?.resumeProducer?.(id) + }), + sourceCreditPendingPtys: new Set(), + backgroundedDeliverySyncByPty: new Map(), + syntheticKillExitPtyIds: new Map(), + reversibleStopOwnersByPtyId: new Map(), + retiredRejectedPtyIds: new Map(), + pendingSerializeRequests: new Map(), + canSendPtyDataToRenderer: unsetSessionFn, + schedulePendingDataFlush: unsetSessionFn, + flushPendingData: unsetSessionFn, + sendPtyDataToRenderer: unsetSessionFn, + sendModelRestoreNeededMarker: unsetSessionFn, + updateProducerFlowControl: unsetSessionFn, + applyCumulativeAck: unsetSessionFn, + readCurrentPtyRendererDeliveryDebugSnapshot: unsetSessionFn, + clearDeliveryResyncProbe: unsetSessionFn, + clearPendingPtyData: unsetSessionFn, + deletePendingPtyData: unsetSessionFn, + setPendingPtyData: unsetSessionFn, + clearDispatcherReadyWatchdog: unsetSessionFn, + armDispatcherReadyWatchdog: unsetSessionFn, + acceptPtyDataForRenderer: unsetSessionFn, + preparePtyExitForRenderer: unsetSessionFn, + finalizePtyExitForRenderer: unsetSessionFn, + sendPtyExitToRenderer: unsetSessionFn, + sendPtySpawnedToRenderer: unsetSessionFn, + requestSerializedBuffer: unsetSessionFn, + shutdownProviderAndDetectExit: unsetSessionFn, + rememberSyntheticKillExit: unsetSessionFn, + rememberRetiredRejectedPty: unsetSessionFn, + consumeSyntheticKillExit: unsetSessionFn, + syncPtyBackgroundedDelivery: unsetSessionFn, + resyncBackgroundedDeliveriesAfterGateReset: unsetSessionFn, + transitionHiddenRendererPtyDeliveryState: unsetSessionFn, + transitionSpawnHiddenRendererPtyDeliveryState: unsetSessionFn, + rendererPtyIsKnownHidden: unsetSessionFn, + clearHiddenRendererResizeOutput: unsetSessionFn, + clearDeliveredHiddenRendererResizeOutput: unsetSessionFn, + schedulePendingDataAfterCreditReport: unsetSessionFn, + writeOffLostRendererDelivery: unsetSessionFn, + getRendererInFlightCharsForPty: unsetSessionFn + } + + session.pendingData = new PtyPendingDataDrainQueue( + (id) => { + const runnableLane = activeRendererPtys.has(id) ? 'active' : 'background' + // Why first: hidden bytes are dropped from main's pending queue even when renderer credit is exhausted. + if (shouldDropHiddenRendererPtyData(id, session.getSettings?.())) { + return runnableLane + } + if ( + !session.rendererPtyDispatcherReady || + !session.canSendPtyDataToRenderer(id, { interactive: activeRendererPtys.has(id) }) + ) { + return 'blocked' + } + return runnableLane + }, + () => isHiddenPtyDeliveryGateEnabled(session.getSettings?.()) + ) + + return session +} diff --git a/src/main/ipc/remote-workspace.ts b/src/main/ipc/remote-workspace.ts index 527a001bb67..2b8966c3297 100644 --- a/src/main/ipc/remote-workspace.ts +++ b/src/main/ipc/remote-workspace.ts @@ -9,6 +9,7 @@ import type { } from '../../shared/remote-workspace-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' +import { parseExecutionHostId } from '../../shared/execution-host' import { getRemoteWorkspaceNamespace } from './remote-workspace-namespace' import { registerRemoteWorkspaceNotificationHandler } from './remote-workspace-events' import { CLIENT_ID } from './remote-workspace-client-identity' @@ -55,7 +56,15 @@ function getExplicitHydratedTargetIds(value: unknown): Set | null { return new Set(value) } -function targetForWorktree(store: Store, worktreeId: string): string | null { +function targetForWorktree( + store: Store, + worktreeId: string, + executionHostId?: string +): string | null { + const parsedHostId = parseExecutionHostId(executionHostId) + if (parsedHostId?.kind === 'ssh') { + return parsedHostId.targetId + } const repoId = getRepoIdFromWorktreeId(worktreeId) return store.getRepo(repoId)?.connectionId ?? null } @@ -66,7 +75,8 @@ function exportSessionForTarget( session: WorkspaceSessionState ): RemoteWorkspaceSession { return exportRemoteWorkspaceSession(session, { - isTargetWorktree: (worktreeId) => targetForWorktree(store, worktreeId) === targetId + isTargetWorktree: (worktreeId, executionHostId) => + targetForWorktree(store, worktreeId, executionHostId) === targetId }) } diff --git a/src/main/ipc/workspace-cleanup-broad-scan.test.ts b/src/main/ipc/workspace-cleanup-broad-scan.test.ts index ab65f6faa22..070ea461906 100644 --- a/src/main/ipc/workspace-cleanup-broad-scan.test.ts +++ b/src/main/ipc/workspace-cleanup-broad-scan.test.ts @@ -119,8 +119,12 @@ function makeWorktreeMeta(overrides: Partial = {}): WorktreeMeta { const META_BY_WORKTREE_ID: Record = { 'repo-1::/repo': makeWorktreeMeta({ lastActivityAt: NOW - 40 * DAY_MS }), 'repo-1::/repo-old': makeWorktreeMeta({ lastActivityAt: NOW - 40 * DAY_MS }), - 'repo-1::/repo-recent': makeWorktreeMeta({ lastActivityAt: NOW - 2 * DAY_MS }), - 'repo-folder::/folder-workspace': makeWorktreeMeta({ lastActivityAt: NOW - 40 * DAY_MS }) + 'repo-1::/repo-recent': makeWorktreeMeta({ + lastActivityAt: NOW - 2 * DAY_MS + }), + 'repo-folder::/folder-workspace': makeWorktreeMeta({ + lastActivityAt: NOW - 40 * DAY_MS + }) } function makeStore(repos: Repo[] = [REPO], allMeta: Record = {}): Store { @@ -154,7 +158,9 @@ describe('workspace cleanup broad scan opt-in', () => { }) it('emits every worktree when the client opts into the full workspace list', async () => { - const result = await scanWorkspaceCleanup(makeStore(), { includeAllWorkspaces: true }) + const result = await scanWorkspaceCleanup(makeStore(), { + includeAllWorkspaces: true + }) expect(result.errors).toEqual([]) expect(result.candidates.map((candidate) => candidate.worktreeId)).toEqual([ @@ -175,7 +181,9 @@ describe('workspace cleanup broad scan opt-in', () => { worktreeId === 'repo-1::/repo-old' ? runtimeMeta : META_BY_WORKTREE_ID[worktreeId] } as Store - const result = await scanWorkspaceCleanup(store, { includeAllWorkspaces: true }) + const result = await scanWorkspaceCleanup(store, { + includeAllWorkspaces: true + }) expect( result.candidates.find((candidate) => candidate.worktreeId === 'repo-1::/repo-old') @@ -230,6 +238,56 @@ describe('workspace cleanup broad scan opt-in', () => { ) }) + it('reads git for a pinned target during focused preflight scans', async () => { + const worktreeId = 'repo-1::/repo-old' + const pinnedMeta = makeWorktreeMeta({ + isPinned: true, + lastActivityAt: NOW - 40 * DAY_MS + }) + const store = { + ...makeStore(), + getWorktreeMeta: (id: string) => (id === worktreeId ? pinnedMeta : META_BY_WORKTREE_ID[id]) + } as Store + + const result = await scanWorkspaceCleanup(store, { + worktreeIds: [worktreeId], + refreshActivity: true + }) + + expect(result.candidates[0]?.blockers).toContain('pinned') + expect(getStatusMock).toHaveBeenCalledTimes(1) + }) + + it('continues to skip pinned git reads during broad scans', async () => { + const worktreeId = 'repo-1::/repo-old' + const pinnedMeta = makeWorktreeMeta({ + isPinned: true, + lastActivityAt: NOW - 40 * DAY_MS + }) + const store = { + ...makeStore(), + getWorktreeMeta: (id: string) => (id === worktreeId ? pinnedMeta : META_BY_WORKTREE_ID[id]) + } as Store + listRepoWorktreesMock.mockResolvedValue([GIT_WORKTREES[1]]) + + await scanWorkspaceCleanup(store, { includeAllWorkspaces: true }) + + expect(getStatusMock).not.toHaveBeenCalled() + }) + + it('qualifies targeted repo-list failures with the execution host', async () => { + listRepoWorktreesMock.mockRejectedValue(new Error('listing failed')) + + const result = await scanWorkspaceCleanup(makeStore(), { + worktreeIds: ['repo-1::/repo-old'], + refreshActivity: true + }) + + expect(result.errors).toEqual([ + expect.objectContaining({ repoId: 'repo-1', executionHostId: 'local' }) + ]) + }) + it('keeps the legacy suggestion-only projection when the flag is absent', async () => { const result = await scanWorkspaceCleanup(makeStore()) @@ -239,7 +297,9 @@ describe('workspace cleanup broad scan opt-in', () => { }) it('reports recent workspaces with no cleanup reasons', async () => { - const result = await scanWorkspaceCleanup(makeStore(), { includeAllWorkspaces: true }) + const result = await scanWorkspaceCleanup(makeStore(), { + includeAllWorkspaces: true + }) const recent = result.candidates.find( (candidate) => candidate.worktreeId === 'repo-1::/repo-recent' @@ -253,7 +313,9 @@ describe('workspace cleanup broad scan opt-in', () => { }) it('reports the main worktree with a main-worktree blocker', async () => { - const result = await scanWorkspaceCleanup(makeStore(), { includeAllWorkspaces: true }) + const result = await scanWorkspaceCleanup(makeStore(), { + includeAllWorkspaces: true + }) const main = result.candidates.find((candidate) => candidate.worktreeId === 'repo-1::/repo') expect(main).toMatchObject({ @@ -334,7 +396,9 @@ describe('workspace cleanup broad scan opt-in', () => { }) it('defers git evidence for rows that no cleanup decision can use', async () => { - const result = await scanWorkspaceCleanup(makeStore(), { includeAllWorkspaces: true }) + const result = await scanWorkspaceCleanup(makeStore(), { + includeAllWorkspaces: true + }) // Why: only the inactive row can ever be queued, so it is the only row worth // a git read; the rest stream immediately with empty evidence. @@ -342,7 +406,12 @@ describe('workspace cleanup broad scan opt-in', () => { expect(getStatusMock).toHaveBeenCalledWith('/repo-old', expect.anything()) expect( result.candidates.find((candidate) => candidate.worktreeId === 'repo-1::/repo-recent')?.git - ).toEqual({ clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null }) + ).toEqual({ + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null + }) expect( result.candidates.find((candidate) => candidate.worktreeId === 'repo-1::/repo-old')?.git ).toMatchObject({ clean: true, checkedAt: expect.any(Number) }) @@ -365,7 +434,9 @@ describe('workspace cleanup broad scan opt-in', () => { getWorktreeMeta: () => recentMeta } as unknown as Store - const result = await scanWorkspaceCleanup(store, { includeAllWorkspaces: true }) + const result = await scanWorkspaceCleanup(store, { + includeAllWorkspaces: true + }) expect(result.candidates).toHaveLength(recentWorktrees.length) expect(readFileMock).not.toHaveBeenCalled() @@ -433,7 +504,9 @@ describe('workspace cleanup broad scan opt-in', () => { it('lists a repo once for a targeted Git-evidence batch', async () => { const targets = ['repo-1::/repo-recent', 'repo-1::/repo-old'] - const result = await scanWorkspaceCleanup(makeStore(), { worktreeIds: targets }) + const result = await scanWorkspaceCleanup(makeStore(), { + worktreeIds: targets + }) expect(listRepoWorktreesMock).toHaveBeenCalledTimes(1) expect(getStatusMock).toHaveBeenCalledTimes(2) @@ -469,7 +542,9 @@ describe('workspace cleanup broad scan opt-in', () => { it('lists disconnected SSH workspaces only on an opt-in scan', async () => { const sshRepo: Repo = { ...REPO, id: 'repo-ssh', connectionId: 'ssh-1' } const allMeta: Record = { - 'repo-ssh::/remote/recent': makeWorktreeMeta({ lastActivityAt: NOW - 2 * DAY_MS }) + 'repo-ssh::/remote/recent': makeWorktreeMeta({ + lastActivityAt: NOW - 2 * DAY_MS + }) } const legacy = await scanWorkspaceCleanup(makeStore([sshRepo], allMeta)) @@ -536,7 +611,10 @@ describe('workspace cleanup broad scan opt-in', () => { }) it('streams every row through scan progress on an opt-in scan', async () => { - const progress: { scannedWorktreeCount: number; totalWorktreeCount: number }[] = [] + const progress: { + scannedWorktreeCount: number + totalWorktreeCount: number + }[] = [] await scanWorkspaceCleanup( makeStore(), diff --git a/src/main/ipc/workspace-cleanup-candidate.ts b/src/main/ipc/workspace-cleanup-candidate.ts index 58c56316acc..72f89161690 100644 --- a/src/main/ipc/workspace-cleanup-candidate.ts +++ b/src/main/ipc/workspace-cleanup-candidate.ts @@ -192,11 +192,16 @@ function shouldReadWorkspaceCleanupGitEvidence(args: { if ((skipGit && !forceGitCheck) || repoIsFolder || worktree.isMainWorktree) { return false } - if ( - blockers.includes('pinned') || - blockers.includes('main-worktree') || - blockers.includes('folder-repo') - ) { + // Why pinned sits with the cost skips and not the refusals: a pinned workspace is + // still queueable (`pinned` is not a queue blocker), so it can reach removal -- and + // removal forces whenever git is unknown. Skipping its git read on a broad scan is a + // fair saving; skipping it on the confirm-time forced read meant deleting with no + // evidence ever obtained. main-worktree and folder-repo stay unconditional: those are + // refused before removal, so reading git for them is pure cost. + if (blockers.includes('pinned') && !forceGitCheck) { + return false + } + if (blockers.includes('main-worktree') || blockers.includes('folder-repo')) { return false } diff --git a/src/main/ipc/workspace-cleanup-disconnected-ssh.ts b/src/main/ipc/workspace-cleanup-disconnected-ssh.ts index 5e3cebf260c..c0e136b7b77 100644 --- a/src/main/ipc/workspace-cleanup-disconnected-ssh.ts +++ b/src/main/ipc/workspace-cleanup-disconnected-ssh.ts @@ -74,8 +74,6 @@ function createDisconnectedSshCandidate( displayName: meta.displayName || basename(path), branch: basename(path), path, - tier: 'protected', - selectedByDefault: false, reasons, blockers: ['ssh-disconnected'], lastActivityAt: meta.lastActivityAt, diff --git a/src/main/ipc/workspace-cleanup-scan-primitives.ts b/src/main/ipc/workspace-cleanup-scan-primitives.ts index 93af63562c2..8054d333e0d 100644 --- a/src/main/ipc/workspace-cleanup-scan-primitives.ts +++ b/src/main/ipc/workspace-cleanup-scan-primitives.ts @@ -1,4 +1,5 @@ import { basename } from 'node:path' +import { getRepoExecutionHostId } from '../../shared/execution-host' import type { Repo } from '../../shared/repo-types' import type { WorkspaceCleanupScanError } from '../../shared/workspace-cleanup' @@ -91,7 +92,8 @@ export function createWorkspaceCleanupScanError( return { repoId: repo.id, repoName: repo.displayName || basename(repo.path), - message + message, + executionHostId: getRepoExecutionHostId(repo) } } diff --git a/src/main/ipc/workspace-cleanup-scan.ts b/src/main/ipc/workspace-cleanup-scan.ts index 278c3ae7ee8..b87667f0a63 100644 --- a/src/main/ipc/workspace-cleanup-scan.ts +++ b/src/main/ipc/workspace-cleanup-scan.ts @@ -252,8 +252,8 @@ async function scanRepoWorkspaces( worktree: worktreeWithActivity, scannedAt, provider, - // Why: a row with no inactivity reason can never be queued or selected, - // so full-fleet scans stream it now and let a focused scan read git later. + // Why: full-fleet scans defer git for recently active rows; removal preflight + // forces a fresh read before any selected row can be deleted. skipGit: skipGitWorktreeIds.has(worktreeWithActivity.id) || !isInactive, forceGitCheck: Boolean(targetWorktreeIds), signal diff --git a/src/main/ipc/workspace-cleanup.test.ts b/src/main/ipc/workspace-cleanup.test.ts index b35167906c8..6f5d3d74a9b 100644 --- a/src/main/ipc/workspace-cleanup.test.ts +++ b/src/main/ipc/workspace-cleanup.test.ts @@ -322,6 +322,7 @@ describe('workspace cleanup scan', () => { { repoId: 'repo-1', repoName: 'Repo', + executionHostId: 'local', message: 'Git could not list worktrees.' } ]) @@ -344,6 +345,7 @@ describe('workspace cleanup scan', () => { { repoId: 'repo-1', repoName: 'Repo', + executionHostId: 'local', message: 'Timed out listing worktrees.' } ] diff --git a/src/main/jira/issues.test.ts b/src/main/jira/issues.test.ts index 2972fa6b531..46c7a430537 100644 --- a/src/main/jira/issues.test.ts +++ b/src/main/jira/issues.test.ts @@ -387,7 +387,7 @@ describe('Jira issue operations', () => { ]) expect(String(jiraRequestMock.mock.calls[0][1])).toContain( - '/rest/api/3/issue/createmeta/10000/issuetypes/10001?' + '/rest/api/3/issue/createmeta/10000/issuetypes/10001?maxResults=100&startAt=0' ) }) diff --git a/src/main/jira/issues.ts b/src/main/jira/issues.ts index 976862c0e96..7529c2131da 100644 --- a/src/main/jira/issues.ts +++ b/src/main/jira/issues.ts @@ -1,1246 +1,13 @@ -/* eslint-disable max-lines -- Why: Jira task reads and mutations share ADF - mapping, multi-site fan-out, and auth-clearing behavior; keeping the API - boundary together avoids subtle drift between operations. */ -import type { - JiraComment, - JiraCreateField, - JiraCreateFieldAllowedValue, - JiraCreateIssueArgs, - JiraCreateIssueResult, - JiraIssue, - JiraIssueFilter, - JiraIssueType, - JiraIssueUpdate, - JiraMutationResult, - JiraPriority, - JiraProject, - JiraProjectStatusOrder, - JiraSite, - JiraSiteSelection, - JiraStatus, - JiraTransition, - JiraUser -} from '../../shared/jira-types' -import { acquire, release } from './request-queue' -import { apiBasePath, jiraRequest, type JiraClientForSite } from './authenticated-request' -import { clearToken, getClients, isAuthError } from './client' -import { - adfToMarkdownText, - collectAdfMediaAttrs, - textToAdf, - type AdfToMarkdownOptions, - type JiraAdfMediaAttrs -} from './adf-markdown' -import { - extractAttachmentContentIdsFromHtml, - selectPreferredAttachmentIds, - warnIfMediaResolutionIncomplete -} from './attachment-discovery' -import { - createMediaMarkdownResolver, - loadIssueImageAttachments, - type MediaResolutionStats -} from './attachment-images' -import { JiraSummaryLookupError } from '../../shared/jira-summary-lookup' - -const ISSUE_FIELDS = [ - 'summary', - 'description', - 'project', - 'issuetype', - 'status', - 'assignee', - 'reporter', - 'priority', - 'labels', - 'created', - 'updated' -] - -// Why: list/typeahead only need identity + metadata; description ADF parse is the hot-path cost. -const ISSUE_LIST_FIELDS = ISSUE_FIELDS.filter((field) => field !== 'description') - -// Why: detail reads need attachment metadata so inline ADF media can be resolved -// to downloadable image content; list/search omit this for payload size. -const ISSUE_DETAIL_FIELDS = [...ISSUE_FIELDS, 'attachment'] -// `created`/`updated` are required: mapJiraIssue falls back to "now" when they're absent, -// which would silently report the lookup time as the issue's timestamps. -const ISSUE_SUMMARY_FIELDS = ['summary', 'project', 'issuetype', 'status', 'created', 'updated'] -const ISSUE_SUMMARY_TIMEOUT_MS = 30_000 -const ISSUE_SEARCH_TIMEOUT_MS = 30_000 - -type JiraRecord = Record - -type JiraSearchResponse = { - issues?: JiraRecord[] -} - -type JiraPagedResponse = { - startAt?: number - maxResults?: number - total?: number - isLast?: boolean - values?: T[] - issueTypes?: T[] - comments?: T[] - fields?: T[] | Record -} - -type JiraPageItemKey = 'values' | 'issueTypes' | 'comments' - -function clampLimit(limit: number | undefined, fallback = 30): number { - return Math.min(Math.max(1, Number.isFinite(limit) ? Number(limit) : fallback), 100) -} - -type JiraIssueSearchFailure = { - error: unknown - auth: boolean -} - -/** Run against one signal that trips on the caller's abort or the request deadline. */ -async function withJiraDeadline( - signal: AbortSignal | undefined, - timeoutMs: number, - run: (deadlineSignal: AbortSignal) => Promise -): Promise { - const controller = new AbortController() - const abort = (): void => controller.abort() - signal?.addEventListener('abort', abort, { once: true }) - if (signal?.aborted) { - controller.abort() - } - const timer = setTimeout(abort, timeoutMs) - try { - return await run(controller.signal) - } finally { - clearTimeout(timer) - signal?.removeEventListener('abort', abort) - } -} - -function settleJiraSummaryRead(read: Promise, signal: AbortSignal): Promise { - if (signal.aborted) { - return Promise.reject(new Error('Jira summary lookup aborted')) - } - return new Promise((resolve, reject) => { - const handleAbort = (): void => { - cleanup() - reject(new Error('Jira summary lookup aborted')) - } - const cleanup = (): void => signal.removeEventListener('abort', handleAbort) - signal.addEventListener('abort', handleAbort, { once: true }) - void read.then( - (value) => { - cleanup() - resolve(value) - }, - (error: unknown) => { - cleanup() - reject(error) - } - ) - }) -} - -function getErrorStatus(error: unknown): number | null { - if (!error || typeof error !== 'object' || !('status' in error)) { - return null - } - const status = (error as { status?: unknown }).status - return typeof status === 'number' && Number.isFinite(status) ? status : null -} - -function toIssueSearchFailureError(error: unknown): unknown { - const status = getErrorStatus(error) - if ( - status === null || - !(error instanceof Error) || - error.message.startsWith(`Error ${status}:`) - ) { - return error - } - return new Error(`Error ${status}: ${error.message}`) -} - -function shouldSurfaceSiteFailure( - selection: JiraSiteSelection | null | undefined, - entryCount: number -): boolean { - // getClients can resolve an omitted selection to the persisted 'all' choice; - // multi-entry reads need the same resilient fan-out policy as explicit 'all'. - return selection !== 'all' && entryCount <= 1 -} - -function asRecord(value: unknown): JiraRecord { - return value && typeof value === 'object' ? (value as JiraRecord) : {} -} - -function asString(value: unknown, fallback = ''): string { - return typeof value === 'string' ? value : fallback -} - -function asIdentifier(value: unknown): string { - if (typeof value === 'string') { - return value - } - return typeof value === 'number' && Number.isFinite(value) ? String(value) : '' -} - -function asStringArray(value: unknown): string[] { - return Array.isArray(value) - ? value.filter((item): item is string => typeof item === 'string') - : [] -} - -function asFiniteNumber(value: unknown): number | null { - return typeof value === 'number' && Number.isFinite(value) ? value : null -} - -function getPageItems(response: JiraPagedResponse, key: JiraPageItemKey): T[] { - const keyedItems = response[key] - if (Array.isArray(keyedItems)) { - return keyedItems - } - return response.values ?? [] -} - -function shouldFetchNextPage( - response: JiraPagedResponse, - startAt: number, - items: T[], - requestedMaxResults: number -): boolean { - if (response.isLast === true || items.length === 0) { - return false - } - const total = asFiniteNumber(response.total) - const pageSize = asFiniteNumber(response.maxResults) - if (total !== null) { - return startAt + items.length < total && (pageSize ?? requestedMaxResults) > 0 - } - if (response.isLast === false) { - return (pageSize ?? requestedMaxResults) > 0 - } - return pageSize !== null && items.length >= pageSize -} - -async function fetchPagedRecords( - entry: JiraClientForSite, - key: JiraPageItemKey, - pathForPage: (startAt: number, maxResults: number) => string, - maxResults = 100 -): Promise { - const records: JiraRecord[] = [] - let startAt = 0 - for (let guard = 0; guard < 100; guard += 1) { - const response = await jiraRequest>( - entry, - pathForPage(startAt, maxResults) - ) - const items = getPageItems(response, key) - records.push(...items) - if (!shouldFetchNextPage(response, startAt, items, maxResults)) { - break - } - startAt += asFiniteNumber(response.maxResults) ?? maxResults - } - return records -} - -function avatarUrl(value: unknown): string | undefined { - const avatars = asRecord(value) - return ( - asString(avatars['48x48']) || - asString(avatars['32x32']) || - asString(avatars['24x24']) || - undefined - ) -} - -function mapUser(value: unknown): JiraUser | undefined { - const user = asRecord(value) - // Server/DC users have no accountId; name (login) and key are its stable ids. - const accountId = asString(user.accountId) || asString(user.name) || asString(user.key) - if (!accountId) { - return undefined - } - return { - accountId, - displayName: asString(user.displayName, 'Unknown'), - email: typeof user.emailAddress === 'string' ? user.emailAddress : undefined, - avatarUrl: avatarUrl(user.avatarUrls) - } -} - -function mapProject(value: unknown, site?: JiraSite): JiraProject { - const project = asRecord(value) - return { - id: asString(project.id), - key: asString(project.key), - name: asString(project.name, asString(project.key)), - siteId: site?.id, - siteName: site?.displayName - } -} - -function mapIssueType(value: unknown): JiraIssueType { - const issueType = asRecord(value) - return { - id: asString(issueType.id), - name: asString(issueType.name, 'Issue'), - description: asString(issueType.description) || undefined, - iconUrl: asString(issueType.iconUrl) || undefined, - subtask: typeof issueType.subtask === 'boolean' ? issueType.subtask : undefined - } -} - -function mapCreateFieldAllowedValue(value: unknown): JiraCreateFieldAllowedValue { - const option = asRecord(value) - return { - id: asString(option.id) || undefined, - value: asString(option.value) || undefined, - name: asString(option.name) || undefined - } -} - -function mapCreateField(value: unknown, fallbackKey = ''): JiraCreateField | null { - const field = asRecord(value) - const schema = asRecord(field.schema) - const key = - asString(field.key) || - asString(field.fieldId) || - asString(field.id) || - asString(field.fieldKey) || - fallbackKey - if (!key) { - return null - } - const allowedValues = Array.isArray(field.allowedValues) - ? field.allowedValues.map(mapCreateFieldAllowedValue) - : undefined - return { - key, - name: asString(field.name, key), - required: field.required === true, - schema: { - type: asString(schema.type) || undefined, - items: asString(schema.items) || undefined, - custom: asString(schema.custom) || undefined - }, - allowedValues - } -} - -function getCreateFieldRecords(response: JiraPagedResponse): JiraRecord[] { - if (Array.isArray(response.values)) { - return response.values - } - if (Array.isArray(response.fields)) { - return response.fields - } - if (response.fields && typeof response.fields === 'object') { - return Object.entries(response.fields).map(([key, value]) => ({ - key, - ...asRecord(value) - })) - } - return [] -} - -function mapPriority(value: unknown): JiraPriority | undefined { - const priority = asRecord(value) - const id = asString(priority.id) - if (!id) { - return undefined - } - return { - id, - name: asString(priority.name, 'Priority'), - iconUrl: asString(priority.iconUrl) || undefined - } -} - -function mapStatus(value: unknown): JiraStatus { - const status = asRecord(value) - const category = asRecord(status.statusCategory) - return { - id: asString(status.id), - name: asString(status.name, 'Unknown'), - categoryKey: asString(category.key, 'undefined'), - categoryName: asString(category.name, 'No Category'), - colorName: asString(category.colorName) || undefined - } -} - -function issueUrl(site: JiraSite, key: string): string { - return `${site.siteUrl}/browse/${encodeURIComponent(key)}` -} - -// REST v2 (Server/DC) bodies are plain text; v3 (Cloud) requires ADF documents. -function toBodyText(site: JiraSite, text: string): unknown { - return site.authType === 'server' ? text : textToAdf(text) -} - -export function mapJiraIssue( - site: JiraSite, - raw: JiraRecord, - adfOptions?: AdfToMarkdownOptions -): JiraIssue { - const fields = asRecord(raw.fields) - const key = asString(raw.key) - return { - id: asString(raw.id, key), - key, - siteId: site.id, - siteName: site.displayName, - title: asString(fields.summary, key || 'Untitled issue'), - description: adfToMarkdownText(fields.description, adfOptions), - url: issueUrl(site, key), - project: mapProject(fields.project, site), - issueType: mapIssueType(fields.issuetype), - status: mapStatus(fields.status), - labels: asStringArray(fields.labels), - assignee: mapUser(fields.assignee), - reporter: mapUser(fields.reporter), - priority: mapPriority(fields.priority), - createdAt: asString(fields.created, new Date().toISOString()), - updatedAt: asString(fields.updated, new Date().toISOString()) - } -} - -type MediaRequest = { - attachmentField: unknown - preferredIds: string[] - needCount: number - fallbackRan: boolean - issueKey: string -} - -/** Pooled: HTML/ADF selection only — no binary downloads. */ -function collectIssueMediaRequest(raw: JiraRecord): MediaRequest | undefined { - const fields = asRecord(raw.fields) - const renderedFields = asRecord(raw.renderedFields) - const htmlIds = extractAttachmentContentIdsFromHtml( - asString(renderedFields.description) || undefined - ) - const mediaAttrs = collectAdfMediaAttrs(fields.description) - const selection = selectPreferredAttachmentIds({ - renderedHtmlIds: htmlIds, - attachmentField: fields.attachment, - mediaAttrs - }) - if (selection.needCount === 0 && selection.preferredIds.length === 0) { - return undefined - } - return { - attachmentField: fields.attachment, - preferredIds: selection.preferredIds, - needCount: selection.needCount, - fallbackRan: selection.fallbackRan, - issueKey: asString(raw.key) - } -} - -type PreparedMedia = { - options: AdfToMarkdownOptions - stats: MediaResolutionStats - request: MediaRequest -} - -/** Unpooled: binary downloads + resolver (outside the Jira API semaphore). */ -async function prepareMediaResolver( - client: JiraClientForSite, - request: MediaRequest -): Promise { - if (request.preferredIds.length === 0) { - warnIfMediaResolutionIncomplete({ - siteId: client.site.id, - issueKey: request.issueKey, - needCount: request.needCount, - preferredIdCount: 0, - resolvedCount: 0, - fallbackRan: request.fallbackRan - }) - return undefined - } - const images = await loadIssueImageAttachments( - client, - request.attachmentField, - request.preferredIds - ) - if (images.length === 0) { - warnIfMediaResolutionIncomplete({ - siteId: client.site.id, - issueKey: request.issueKey, - needCount: request.needCount, - preferredIdCount: request.preferredIds.length, - resolvedCount: 0, - fallbackRan: request.fallbackRan - }) - return undefined - } - const stats: MediaResolutionStats = { attachmentResolvedCount: 0 } - const resolveMedia = createMediaMarkdownResolver(images, request.preferredIds, stats) - return { - options: { resolveMedia }, - stats, - request - } -} - -function flushMediaResolutionWarn(client: JiraClientForSite, prepared: PreparedMedia): void { - warnIfMediaResolutionIncomplete({ - siteId: client.site.id, - issueKey: prepared.request.issueKey, - needCount: prepared.request.needCount, - preferredIdCount: prepared.request.preferredIds.length, - resolvedCount: prepared.stats.attachmentResolvedCount, - fallbackRan: prepared.request.fallbackRan - }) -} - -function sortAndLimitIssues(issues: JiraIssue[], limit: number): JiraIssue[] { - return issues - .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) - .slice(0, limit) -} - -function filterToJql(filter: JiraIssueFilter): string { - if (filter === 'assigned') { - return 'assignee = currentUser() AND resolution = Unresolved ORDER BY updated DESC' - } - if (filter === 'reported') { - return 'reporter = currentUser() AND resolution = Unresolved ORDER BY updated DESC' - } - if (filter === 'done') { - return 'assignee = currentUser() AND resolution IS NOT EMPTY ORDER BY updated DESC' - } - return 'resolution = Unresolved ORDER BY updated DESC' -} - -async function searchIssuesForClient( - entry: JiraClientForSite, - jql: string, - limit: number, - signal?: AbortSignal -): Promise { - // Server/DC only has the classic /search resource; /search/jql is Cloud-only. - const searchPath = - entry.site.authType === 'server' - ? `${apiBasePath(entry.site)}/search` - : '/rest/api/3/search/jql' - const result = await jiraRequest(entry, searchPath, { - method: 'POST', - body: JSON.stringify({ - jql, - maxResults: limit, - fields: ISSUE_LIST_FIELDS - }), - signal - }) - return (result.issues ?? []).map((issue) => mapJiraIssue(entry.site, issue)) -} - -export async function listIssues( - filter: JiraIssueFilter = 'assigned', - limit = 30, - siteId?: JiraSiteSelection | null -): Promise { - return searchIssues(filterToJql(filter), limit, siteId) -} - -export async function searchIssues( - jql: string, - limit = 30, - siteId?: JiraSiteSelection | null, - signal?: AbortSignal -): Promise { - const entries = getClients(siteId) - if (entries.length === 0 || !jql.trim()) { - return [] - } - const safeLimit = clampLimit(limit) - const failures: (JiraIssueSearchFailure | undefined)[] = Array.from({ length: entries.length }) - const surfaceSiteFailure = shouldSurfaceSiteFailure(siteId, entries.length) - const results = await withJiraDeadline(signal, ISSUE_SEARCH_TIMEOUT_MS, (requestSignal) => - Promise.all( - entries.map(async (entry, index) => { - // Why: queueing on an abandoned search would keep occupying the shared Jira pool. - await acquire(requestSignal) - try { - return await searchIssuesForClient(entry, jql.trim(), safeLimit, requestSignal) - } catch (error) { - if (requestSignal.aborted) { - // Abandoned by the caller: not a site failure, so don't clear tokens or mask a real one. - throw error - } - const authFailure = isAuthError(error) - if (authFailure) { - clearToken(entry.site.id) - } - if (surfaceSiteFailure) { - throw toIssueSearchFailureError(error) - } - console.warn('[jira] searchIssues failed:', error) - failures[index] = { error: toIssueSearchFailureError(error), auth: authFailure } - return [] as JiraIssue[] - } finally { - release() - } - }) - ) - ) - // 'all' fan-out: only surface an error when every connected site failed, so a - // partial success (or a genuinely empty result) is not reported as an error. - const recordedFailures = failures.filter( - (failure): failure is JiraIssueSearchFailure => failure !== undefined - ) - if (recordedFailures.length === entries.length) { - throw (recordedFailures.find((failure) => !failure.auth) ?? recordedFailures[0]).error - } - return entries.length === 1 - ? results.flat().slice(0, safeLimit) - : sortAndLimitIssues(results.flat(), safeLimit) -} - -export async function getIssue( - key: string, - siteId?: JiraSiteSelection | null -): Promise { - const entries = getClients(siteId) - for (const entry of entries) { - let mediaRequest: MediaRequest | undefined - let issue: JiraRecord | undefined - let held = false - try { - await acquire() - held = true - const params = new URLSearchParams({ - fields: ISSUE_DETAIL_FIELDS.join(','), - expand: 'renderedFields' - }) - issue = await jiraRequest( - entry, - `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}?${params.toString()}` - ) - // Why: keep only JSON under the pool; binary downloads fan out after release. - mediaRequest = collectIssueMediaRequest(issue) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - if (shouldSurfaceSiteFailure(siteId, entries.length)) { - throw error - } - } else { - console.warn('[jira] getIssue failed:', error) - } - continue - } finally { - if (held) { - held = false - release() - } - } - - try { - if (!issue) { - continue - } - const prepared = mediaRequest ? await prepareMediaResolver(entry, mediaRequest) : undefined - const mapped = mapJiraIssue(entry.site, issue, prepared?.options) - if (prepared) { - flushMediaResolutionWarn(entry, prepared) - } - return mapped - } catch (error) { - console.warn('[jira] getIssue media load failed:', error) - return mapJiraIssue(entry.site, issue) - } - } - return null -} - -export async function getIssueSummary( - key: string, - siteId: string, - signal?: AbortSignal -): Promise { - let entries: JiraClientForSite[] - try { - entries = getClients(siteId) - } catch (error) { - throw new JiraSummaryLookupError('auth', error) - } - const entry = entries.find((candidate) => candidate.site.id === siteId) - if (!entry) { - throw new JiraSummaryLookupError('disconnected') - } - - return withJiraDeadline(signal, ISSUE_SUMMARY_TIMEOUT_MS, async (requestSignal) => { - await acquire(requestSignal) - try { - const params = new URLSearchParams({ fields: ISSUE_SUMMARY_FIELDS.join(',') }) - const issue = await settleJiraSummaryRead( - jiraRequest( - entry, - `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}?${params.toString()}`, - { signal: requestSignal } - ), - requestSignal - ) - return mapJiraIssue(entry.site, issue) - } catch (error) { - if (isAuthError(error)) { - throw new JiraSummaryLookupError('auth', error) - } - if (getErrorStatus(error) === 404) { - throw new JiraSummaryLookupError('not-found', error) - } - throw new JiraSummaryLookupError('read-failed', error) - } finally { - release() - } - }) -} - -export async function createIssue(args: JiraCreateIssueArgs): Promise { - const entry = getClients(args.siteId)[0] - if (!entry) { - return { ok: false, error: 'Not connected to Jira.' } - } - const title = args.title.trim() - if (!title) { - return { ok: false, error: 'Title is required.' } - } - - await acquire() - try { - const fields: JiraRecord = { - project: { id: args.projectId }, - issuetype: { id: args.issueTypeId }, - summary: title - } - if (args.description?.trim()) { - fields.description = toBodyText(entry.site, args.description.trim()) - } - for (const [fieldKey, value] of Object.entries(args.customFields ?? {})) { - if (!fieldKey || value === undefined || value === null || value === '') { - continue - } - fields[fieldKey] = value - } - const created = await jiraRequest<{ id: string; key: string; self: string }>( - entry, - `${apiBasePath(entry.site)}/issue`, - { - method: 'POST', - body: JSON.stringify({ fields }) - } - ) - return { ok: true, id: created.id, key: created.key, url: issueUrl(entry.site, created.key) } - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - throw error - } - return { ok: false, error: error instanceof Error ? error.message : 'Failed to create issue.' } - } finally { - release() - } -} - -export async function updateIssue( - key: string, - updates: JiraIssueUpdate, - siteId?: string | null -): Promise { - const entry = getClients(siteId)[0] - if (!entry) { - return { ok: false, error: 'Not connected to Jira.' } - } - await acquire() - try { - const fields: JiraRecord = {} - if (updates.title !== undefined) { - fields.summary = updates.title - } - if (updates.labels !== undefined) { - fields.labels = updates.labels - } - if (updates.priorityId !== undefined) { - fields.priority = updates.priorityId ? { id: updates.priorityId } : null - } - const issueBase = `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}` - if (Object.keys(fields).length > 0) { - await jiraRequest(entry, issueBase, { - method: 'PUT', - body: JSON.stringify({ fields }) - }) - } - if (updates.assigneeAccountId !== undefined) { - // Server/DC identifies assignees by username (`name`), not accountId; - // mapUser stores the Server username in the accountId slot. - const assigneeBody = - entry.site.authType === 'server' - ? { name: updates.assigneeAccountId } - : { accountId: updates.assigneeAccountId } - await jiraRequest(entry, `${issueBase}/assignee`, { - method: 'PUT', - body: JSON.stringify(assigneeBody) - }) - } - if (updates.transitionId) { - await jiraRequest(entry, `${issueBase}/transitions`, { - method: 'POST', - body: JSON.stringify({ transition: { id: updates.transitionId } }) - }) - } - return { ok: true } - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - throw error - } - return { ok: false, error: error instanceof Error ? error.message : 'Failed to update issue.' } - } finally { - release() - } -} - -export async function addIssueComment( - key: string, - body: string, - siteId?: string | null -): Promise<{ ok: true; id: string } | { ok: false; error: string }> { - const entry = getClients(siteId)[0] - if (!entry) { - return { ok: false, error: 'Not connected to Jira.' } - } - await acquire() - try { - const comment = await jiraRequest<{ id: string }>( - entry, - `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/comment`, - { - method: 'POST', - body: JSON.stringify({ body: toBodyText(entry.site, body) }) - } - ) - return { ok: true, id: comment.id } - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - throw error - } - return { ok: false, error: error instanceof Error ? error.message : 'Failed to add comment.' } - } finally { - release() - } -} - -function mapComment(raw: JiraRecord, adfOptions?: AdfToMarkdownOptions): JiraComment { - return { - id: asString(raw.id), - body: adfToMarkdownText(raw.body, adfOptions), - createdAt: asString(raw.created, new Date().toISOString()), - updatedAt: asString(raw.updated) || undefined, - user: mapUser(raw.author) - } -} - -/** - * Pooled comment media collect: attachment metadata JSON stays under the semaphore. - * Residual: Server/DC comment bodies are wiki markup, not ADF — this only fixes - * the lookup path; wiki `!filename!` is not rendered as media. - */ -async function collectCommentMediaRequest( - client: JiraClientForSite, - key: string, - comments: JiraRecord[] -): Promise { - const htmlIds: string[] = [] - const seen = new Set() - const mediaAttrs: JiraAdfMediaAttrs[] = [] - for (const comment of comments) { - for (const id of extractAttachmentContentIdsFromHtml(asString(comment.renderedBody))) { - if (!seen.has(id)) { - seen.add(id) - htmlIds.push(id) - } - } - mediaAttrs.push(...collectAdfMediaAttrs(comment.body)) - } - - const needingCount = mediaAttrs.filter( - (attrs) => !(attrs.url && /^https?:\/\//i.test(attrs.url)) - ).length - // Why: selectPreferredAttachmentIds yields nothing without attachment-needing media, so - // HTML ids alone can never produce a download — skip the extra metadata request entirely. - if (needingCount === 0) { - return undefined - } - - // Why: comment media usually references issue-level attachments; pull them once - // for the whole thread. Use apiBasePath so Server/DC does not 404 on /rest/api/3. - let attachmentField: unknown - try { - const issue = await jiraRequest( - client, - `${apiBasePath(client.site)}/issue/${encodeURIComponent(key)}?fields=attachment` - ) - attachmentField = asRecord(issue.fields).attachment - } catch (error) { - console.warn('[jira] comment attachment lookup failed:', error) - return undefined - } - - const selection = selectPreferredAttachmentIds({ - renderedHtmlIds: htmlIds, - attachmentField, - mediaAttrs - }) - if (selection.needCount === 0 && selection.preferredIds.length === 0) { - return undefined - } - return { - attachmentField, - preferredIds: selection.preferredIds, - needCount: selection.needCount, - fallbackRan: selection.fallbackRan, - issueKey: key - } -} - -export async function getIssueComments( - key: string, - siteId?: string | null -): Promise { - const entry = getClients(siteId)[0] - if (!entry) { - return [] - } - - let comments: JiraRecord[] = [] - let mediaRequest: MediaRequest | undefined - let held = false - try { - await acquire() - held = true - comments = await fetchPagedRecords(entry, 'comments', (startAt, maxResults) => { - const params = new URLSearchParams({ - maxResults: String(maxResults), - orderBy: 'created', - startAt: String(startAt), - expand: 'renderedBody' - }) - return `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/comment?${params.toString()}` - }) - mediaRequest = await collectCommentMediaRequest(entry, key, comments) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - throw error - } - console.warn('[jira] getIssueComments failed:', error) - return [] - } finally { - if (held) { - held = false - release() - } - } - - try { - const prepared = mediaRequest ? await prepareMediaResolver(entry, mediaRequest) : undefined - const mapped = comments.map((comment) => mapComment(comment, prepared?.options)) - if (prepared) { - flushMediaResolutionWarn(entry, prepared) - } - return mapped - } catch (error) { - console.warn('[jira] getIssueComments media load failed:', error) - return comments.map((comment) => mapComment(comment)) - } -} - -export async function listProjects(siteId?: JiraSiteSelection | null): Promise { - const entries = getClients(siteId) - if (entries.length === 0) { - return [] - } - const results = await Promise.all( - entries.map(async (entry) => { - await acquire() - try { - // Server/DC has no /project/search resource; /project returns the - // full list as a plain (unpaged) array. - const projects = - entry.site.authType === 'server' - ? await jiraRequest(entry, `${apiBasePath(entry.site)}/project`) - : await fetchPagedRecords(entry, 'values', (startAt, maxResults) => { - const params = new URLSearchParams({ - maxResults: String(maxResults), - startAt: String(startAt) - }) - return `/rest/api/3/project/search?${params.toString()}` - }) - return projects.map((project) => mapProject(project, entry.site)) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - if (shouldSurfaceSiteFailure(siteId, entries.length)) { - throw error - } - } else { - console.warn('[jira] listProjects failed:', error) - } - return [] - } finally { - release() - } - }) - ) - return results.flat().sort((a, b) => a.name.localeCompare(b.name)) -} - -export async function listIssueTypes( - projectIdOrKey: string, - siteId?: string | null -): Promise { - const entry = getClients(siteId)[0] - if (!entry) { - return [] - } - await acquire() - try { - const issueTypes = await fetchPagedRecords(entry, 'issueTypes', (startAt, maxResults) => { - const params = new URLSearchParams({ - maxResults: String(maxResults), - startAt: String(startAt) - }) - // Per-project createmeta paths exist on Server/DC from Jira 8.4 onward. - return `${apiBasePath(entry.site)}/issue/createmeta/${encodeURIComponent( - projectIdOrKey - )}/issuetypes?${params.toString()}` - }) - return issueTypes.map(mapIssueType) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - throw error - } - console.warn('[jira] listIssueTypes failed:', error) - return [] - } finally { - release() - } -} - -export async function listCreateFields( - projectIdOrKey: string, - issueTypeId: string, - siteId?: string | null -): Promise { - const entry = getClients(siteId)[0] - if (!entry) { - return [] - } - await acquire() - try { - const fields: JiraCreateField[] = [] - let startAt = 0 - const maxResults = 100 - for (let guard = 0; guard < 100; guard += 1) { - const params = new URLSearchParams({ - maxResults: String(maxResults), - startAt: String(startAt) - }) - const response = await jiraRequest>( - entry, - `${apiBasePath(entry.site)}/issue/createmeta/${encodeURIComponent( - projectIdOrKey - )}/issuetypes/${encodeURIComponent(issueTypeId)}?${params.toString()}` - ) - const records = getCreateFieldRecords(response) - fields.push( - ...records - .map((record) => mapCreateField(record)) - .filter((field): field is JiraCreateField => field !== null) - ) - if (!shouldFetchNextPage(response, startAt, records, maxResults)) { - break - } - startAt += asFiniteNumber(response.maxResults) ?? maxResults - } - return fields - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - throw error - } - console.warn('[jira] listCreateFields failed:', error) - return [] - } finally { - release() - } -} - -export async function listPriorities(siteId?: string | null): Promise { - const entry = getClients(siteId)[0] - if (!entry) { - return [] - } - await acquire() - try { - const response = await jiraRequest(entry, `${apiBasePath(entry.site)}/priority`) - return response.map(mapPriority).filter((priority): priority is JiraPriority => !!priority) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - throw error - } - console.warn('[jira] listPriorities failed:', error) - return [] - } finally { - release() - } -} - -export async function listAssignableUsers( - key: string, - query?: string, - siteId?: string | null -): Promise { - const entry = getClients(siteId)[0] - if (!entry) { - return [] - } - const isServer = entry.site.authType === 'server' - const params = new URLSearchParams({ issueKey: key, maxResults: '50' }) - if (query?.trim()) { - // Server/DC filters assignable users by `username`; `query` is Cloud-only. - params.set(isServer ? 'username' : 'query', query.trim()) - } - await acquire() - try { - const response = await jiraRequest( - entry, - `${apiBasePath(entry.site)}/user/assignable/search?${params.toString()}` - ) - return response.map(mapUser).filter((user): user is JiraUser => !!user) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - throw error - } - console.warn('[jira] listAssignableUsers failed:', error) - return [] - } finally { - release() - } -} - -export async function listTransitions( - key: string, - siteId?: string | null -): Promise { - const entry = getClients(siteId)[0] - if (!entry) { - return [] - } - await acquire() - try { - const response = await jiraRequest<{ transitions?: JiraRecord[] }>( - entry, - `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/transitions` - ) - return (response.transitions ?? []).map((transition) => ({ - id: asString(transition.id), - name: asString(transition.name), - to: mapStatus(transition.to) - })) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - throw error - } - console.warn('[jira] listTransitions failed:', error) - return [] - } finally { - release() - } -} - -export async function getProjectStatusOrder( - projectKey: string, - siteId?: string | null -): Promise { - // Why: an omitted site can resolve to the persisted "all" selection; board - // metadata is only truthful when exactly one Jira connection owns the project. - const entries = getClients(siteId) - const entry = entries.length === 1 ? entries[0] : undefined - if (!entry) { - return { statusIdsByColumn: [] } - } - await acquire() - try { - // Why: without an explicit board picker there is no truthful way to choose - // among multiple project boards, so ambiguous projects keep alphabetical order. - const params = new URLSearchParams({ projectKeyOrId: projectKey, maxResults: '2' }) - const boardsResponse = await jiraRequest>( - entry, - `/rest/agile/1.0/board?${params.toString()}` - ) - const boards = boardsResponse.values ?? [] - const boardCount = asFiniteNumber(boardsResponse.total) - const singleBoardIsProven = - boards.length === 1 && - boardsResponse.isLast !== false && - (boardCount === 1 || (boardCount === null && boardsResponse.isLast === true)) - const boardId = singleBoardIsProven ? asIdentifier(asRecord(boards[0]).id) : '' - if (!boardId) { - return { statusIdsByColumn: [] } - } - - const configResponse = await jiraRequest( - entry, - `/rest/agile/1.0/board/${encodeURIComponent(boardId)}/configuration` - ) - const columns = asRecord(asRecord(configResponse).columnConfig).columns - if (!Array.isArray(columns)) { - return { statusIdsByColumn: [] } - } - - // Why: issues already carry status names and IDs, so returning board IDs - // avoids a second metadata request and keeps duplicate names unambiguous. - const seenStatusIds = new Set() - const statusIdsByColumn: string[][] = [] - for (const column of columns) { - const statuses = asRecord(column).statuses - if (!Array.isArray(statuses)) { - continue - } - const columnStatusIds: string[] = [] - for (const status of statuses) { - const statusId = asIdentifier(asRecord(status).id) - if (statusId && !seenStatusIds.has(statusId)) { - seenStatusIds.add(statusId) - columnStatusIds.push(statusId) - } - } - if (columnStatusIds.length > 0) { - statusIdsByColumn.push(columnStatusIds) - } - } - return { statusIdsByColumn } - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - throw error - } - console.warn('[jira] getProjectStatusOrder failed:', error) - return { statusIdsByColumn: [] } - } finally { - release() - } -} +export { mapJiraIssue } from './jira-issue-mapping' +export { listIssues, searchIssues } from './jira-issue-search' +export { getIssue, getIssueSummary } from './jira-issue-read' +export { addIssueComment, createIssue, updateIssue } from './jira-issue-mutations' +export { getIssueComments } from './jira-issue-comments' +export { listProjects } from './jira-project-queries' +export { + listAssignableUsers, + listCreateFields, + listIssueTypes, + listPriorities +} from './jira-issue-create-metadata' +export { getProjectStatusOrder, listTransitions } from './jira-transition-queries' diff --git a/src/main/jira/jira-issue-comments.ts b/src/main/jira/jira-issue-comments.ts new file mode 100644 index 00000000000..c81ce300dd4 --- /dev/null +++ b/src/main/jira/jira-issue-comments.ts @@ -0,0 +1,146 @@ +import type { JiraComment } from '../../shared/jira-types' +import { acquire, release } from './request-queue' +import { apiBasePath, jiraRequest, type JiraClientForSite } from './authenticated-request' +import { clearToken, getClients, isAuthError } from './client' +import { + adfToMarkdownText, + collectAdfMediaAttrs, + type AdfToMarkdownOptions, + type JiraAdfMediaAttrs +} from './adf-markdown' +import { + extractAttachmentContentIdsFromHtml, + selectPreferredAttachmentIds +} from './attachment-discovery' +import { mapUser } from './jira-issue-mapping' +import { + flushMediaResolutionWarn, + prepareMediaResolver, + type MediaRequest +} from './jira-issue-media' +import { asRecord, asString, fetchPagedRecords, type JiraRecord } from './jira-record-pages' + +function mapComment(raw: JiraRecord, adfOptions?: AdfToMarkdownOptions): JiraComment { + return { + id: asString(raw.id), + body: adfToMarkdownText(raw.body, adfOptions), + createdAt: asString(raw.created, new Date().toISOString()), + updatedAt: asString(raw.updated) || undefined, + user: mapUser(raw.author) + } +} + +/** + * Pooled comment media collect: attachment metadata JSON stays under the semaphore. + * Residual: Server/DC comment bodies are wiki markup, not ADF — this only fixes + * the lookup path; wiki `!filename!` is not rendered as media. + */ +async function collectCommentMediaRequest( + client: JiraClientForSite, + key: string, + comments: JiraRecord[] +): Promise { + const htmlIds: string[] = [] + const seen = new Set() + const mediaAttrs: JiraAdfMediaAttrs[] = [] + for (const comment of comments) { + for (const id of extractAttachmentContentIdsFromHtml(asString(comment.renderedBody))) { + if (!seen.has(id)) { + seen.add(id) + htmlIds.push(id) + } + } + mediaAttrs.push(...collectAdfMediaAttrs(comment.body)) + } + + const needingCount = mediaAttrs.filter( + (attrs) => !(attrs.url && /^https?:\/\//i.test(attrs.url)) + ).length + // Why: selectPreferredAttachmentIds yields nothing without attachment-needing media, so + // HTML ids alone can never produce a download — skip the extra metadata request entirely. + if (needingCount === 0) { + return undefined + } + + // Why: comment media usually references issue-level attachments; pull them once + // for the whole thread. Use apiBasePath so Server/DC does not 404 on /rest/api/3. + let attachmentField: unknown + try { + const issue = await jiraRequest( + client, + `${apiBasePath(client.site)}/issue/${encodeURIComponent(key)}?fields=attachment` + ) + attachmentField = asRecord(issue.fields).attachment + } catch (error) { + console.warn('[jira] comment attachment lookup failed:', error) + return undefined + } + + const selection = selectPreferredAttachmentIds({ + renderedHtmlIds: htmlIds, + attachmentField, + mediaAttrs + }) + if (selection.needCount === 0 && selection.preferredIds.length === 0) { + return undefined + } + return { + attachmentField, + preferredIds: selection.preferredIds, + needCount: selection.needCount, + fallbackRan: selection.fallbackRan, + issueKey: key + } +} + +export async function getIssueComments( + key: string, + siteId?: string | null +): Promise { + const entry = getClients(siteId)[0] + if (!entry) { + return [] + } + + let comments: JiraRecord[] = [] + let mediaRequest: MediaRequest | undefined + let held = false + try { + await acquire() + held = true + comments = await fetchPagedRecords(entry, 'comments', (startAt, maxResults) => { + const params = new URLSearchParams({ + maxResults: String(maxResults), + orderBy: 'created', + startAt: String(startAt), + expand: 'renderedBody' + }) + return `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/comment?${params.toString()}` + }) + mediaRequest = await collectCommentMediaRequest(entry, key, comments) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + console.warn('[jira] getIssueComments failed:', error) + return [] + } finally { + if (held) { + held = false + release() + } + } + + try { + const prepared = mediaRequest ? await prepareMediaResolver(entry, mediaRequest) : undefined + const mapped = comments.map((comment) => mapComment(comment, prepared?.options)) + if (prepared) { + flushMediaResolutionWarn(entry, prepared) + } + return mapped + } catch (error) { + console.warn('[jira] getIssueComments media load failed:', error) + return comments.map((comment) => mapComment(comment)) + } +} diff --git a/src/main/jira/jira-issue-create-metadata.ts b/src/main/jira/jira-issue-create-metadata.ts new file mode 100644 index 00000000000..e3c5338bd49 --- /dev/null +++ b/src/main/jira/jira-issue-create-metadata.ts @@ -0,0 +1,160 @@ +import type { + JiraCreateField, + JiraIssueType, + JiraPriority, + JiraUser +} from '../../shared/jira-types' +import { acquire, release } from './request-queue' +import { apiBasePath, jiraRequest } from './authenticated-request' +import { clearToken, getClients, isAuthError } from './client' +import { + getCreateFieldRecords, + mapCreateField, + mapIssueType, + mapPriority, + mapUser +} from './jira-issue-mapping' +import { + asFiniteNumber, + fetchPagedRecords, + shouldFetchNextPage, + type JiraPagedResponse, + type JiraRecord +} from './jira-record-pages' + +export async function listIssueTypes( + projectIdOrKey: string, + siteId?: string | null +): Promise { + const entry = getClients(siteId)[0] + if (!entry) { + return [] + } + await acquire() + try { + const issueTypes = await fetchPagedRecords(entry, 'issueTypes', (startAt, maxResults) => { + const params = new URLSearchParams({ + maxResults: String(maxResults), + startAt: String(startAt) + }) + // Per-project createmeta paths exist on Server/DC from Jira 8.4 onward. + return `${apiBasePath(entry.site)}/issue/createmeta/${encodeURIComponent( + projectIdOrKey + )}/issuetypes?${params.toString()}` + }) + return issueTypes.map(mapIssueType) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + console.warn('[jira] listIssueTypes failed:', error) + return [] + } finally { + release() + } +} + +export async function listCreateFields( + projectIdOrKey: string, + issueTypeId: string, + siteId?: string | null +): Promise { + const entry = getClients(siteId)[0] + if (!entry) { + return [] + } + await acquire() + try { + const fields: JiraCreateField[] = [] + let startAt = 0 + const maxResults = 100 + for (let guard = 0; guard < 100; guard += 1) { + const params = new URLSearchParams({ + maxResults: String(maxResults), + startAt: String(startAt) + }) + const response = await jiraRequest>( + entry, + `${apiBasePath(entry.site)}/issue/createmeta/${encodeURIComponent( + projectIdOrKey + )}/issuetypes/${encodeURIComponent(issueTypeId)}?${params.toString()}` + ) + const records = getCreateFieldRecords(response) + fields.push( + ...records + .map((record) => mapCreateField(record)) + .filter((field): field is JiraCreateField => field !== null) + ) + if (!shouldFetchNextPage(response, startAt, records, maxResults)) { + break + } + startAt += asFiniteNumber(response.maxResults) ?? maxResults + } + return fields + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + console.warn('[jira] listCreateFields failed:', error) + return [] + } finally { + release() + } +} + +export async function listPriorities(siteId?: string | null): Promise { + const entry = getClients(siteId)[0] + if (!entry) { + return [] + } + await acquire() + try { + const response = await jiraRequest(entry, `${apiBasePath(entry.site)}/priority`) + return response.map(mapPriority).filter((priority): priority is JiraPriority => !!priority) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + console.warn('[jira] listPriorities failed:', error) + return [] + } finally { + release() + } +} + +export async function listAssignableUsers( + key: string, + query?: string, + siteId?: string | null +): Promise { + const entry = getClients(siteId)[0] + if (!entry) { + return [] + } + const isServer = entry.site.authType === 'server' + const params = new URLSearchParams({ issueKey: key, maxResults: '50' }) + if (query?.trim()) { + // Server/DC filters assignable users by `username`; `query` is Cloud-only. + params.set(isServer ? 'username' : 'query', query.trim()) + } + await acquire() + try { + const response = await jiraRequest( + entry, + `${apiBasePath(entry.site)}/user/assignable/search?${params.toString()}` + ) + return response.map(mapUser).filter((user): user is JiraUser => !!user) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + console.warn('[jira] listAssignableUsers failed:', error) + return [] + } finally { + release() + } +} diff --git a/src/main/jira/jira-issue-mapping.ts b/src/main/jira/jira-issue-mapping.ts new file mode 100644 index 00000000000..cc7c9357eaf --- /dev/null +++ b/src/main/jira/jira-issue-mapping.ts @@ -0,0 +1,210 @@ +import type { + JiraCreateField, + JiraCreateFieldAllowedValue, + JiraIssue, + JiraIssueType, + JiraPriority, + JiraProject, + JiraSite, + JiraStatus, + JiraUser +} from '../../shared/jira-types' +import { adfToMarkdownText, textToAdf, type AdfToMarkdownOptions } from './adf-markdown' +import { + asRecord, + asString, + asStringArray, + type JiraPagedResponse, + type JiraRecord +} from './jira-record-pages' + +export const ISSUE_FIELDS = [ + 'summary', + 'description', + 'project', + 'issuetype', + 'status', + 'assignee', + 'reporter', + 'priority', + 'labels', + 'created', + 'updated' +] + +// Why: list/typeahead only need identity + metadata; description ADF parse is the hot-path cost. +export const ISSUE_LIST_FIELDS = ISSUE_FIELDS.filter((field) => field !== 'description') + +// Why: detail reads need attachment metadata so inline ADF media can be resolved +// to downloadable image content; list/search omit this for payload size. +export const ISSUE_DETAIL_FIELDS = [...ISSUE_FIELDS, 'attachment'] +// `created`/`updated` are required: mapJiraIssue falls back to "now" when they're absent, +// which would silently report the lookup time as the issue's timestamps. +export const ISSUE_SUMMARY_FIELDS = [ + 'summary', + 'project', + 'issuetype', + 'status', + 'created', + 'updated' +] +export function avatarUrl(value: unknown): string | undefined { + const avatars = asRecord(value) + return ( + asString(avatars['48x48']) || + asString(avatars['32x32']) || + asString(avatars['24x24']) || + undefined + ) +} + +export function mapUser(value: unknown): JiraUser | undefined { + const user = asRecord(value) + // Server/DC users have no accountId; name (login) and key are its stable ids. + const accountId = asString(user.accountId) || asString(user.name) || asString(user.key) + if (!accountId) { + return undefined + } + return { + accountId, + displayName: asString(user.displayName, 'Unknown'), + email: typeof user.emailAddress === 'string' ? user.emailAddress : undefined, + avatarUrl: avatarUrl(user.avatarUrls) + } +} + +export function mapProject(value: unknown, site?: JiraSite): JiraProject { + const project = asRecord(value) + return { + id: asString(project.id), + key: asString(project.key), + name: asString(project.name, asString(project.key)), + siteId: site?.id, + siteName: site?.displayName + } +} + +export function mapIssueType(value: unknown): JiraIssueType { + const issueType = asRecord(value) + return { + id: asString(issueType.id), + name: asString(issueType.name, 'Issue'), + description: asString(issueType.description) || undefined, + iconUrl: asString(issueType.iconUrl) || undefined, + subtask: typeof issueType.subtask === 'boolean' ? issueType.subtask : undefined + } +} + +export function mapCreateFieldAllowedValue(value: unknown): JiraCreateFieldAllowedValue { + const option = asRecord(value) + return { + id: asString(option.id) || undefined, + value: asString(option.value) || undefined, + name: asString(option.name) || undefined + } +} + +export function mapCreateField(value: unknown, fallbackKey = ''): JiraCreateField | null { + const field = asRecord(value) + const schema = asRecord(field.schema) + const key = + asString(field.key) || + asString(field.fieldId) || + asString(field.id) || + asString(field.fieldKey) || + fallbackKey + if (!key) { + return null + } + const allowedValues = Array.isArray(field.allowedValues) + ? field.allowedValues.map(mapCreateFieldAllowedValue) + : undefined + return { + key, + name: asString(field.name, key), + required: field.required === true, + schema: { + type: asString(schema.type) || undefined, + items: asString(schema.items) || undefined, + custom: asString(schema.custom) || undefined + }, + allowedValues + } +} + +export function getCreateFieldRecords(response: JiraPagedResponse): JiraRecord[] { + if (Array.isArray(response.values)) { + return response.values + } + if (Array.isArray(response.fields)) { + return response.fields + } + if (response.fields && typeof response.fields === 'object') { + return Object.entries(response.fields).map(([key, value]) => ({ + key, + ...asRecord(value) + })) + } + return [] +} + +export function mapPriority(value: unknown): JiraPriority | undefined { + const priority = asRecord(value) + const id = asString(priority.id) + if (!id) { + return undefined + } + return { + id, + name: asString(priority.name, 'Priority'), + iconUrl: asString(priority.iconUrl) || undefined + } +} + +export function mapStatus(value: unknown): JiraStatus { + const status = asRecord(value) + const category = asRecord(status.statusCategory) + return { + id: asString(status.id), + name: asString(status.name, 'Unknown'), + categoryKey: asString(category.key, 'undefined'), + categoryName: asString(category.name, 'No Category'), + colorName: asString(category.colorName) || undefined + } +} + +export function issueUrl(site: JiraSite, key: string): string { + return `${site.siteUrl}/browse/${encodeURIComponent(key)}` +} + +// REST v2 (Server/DC) bodies are plain text; v3 (Cloud) requires ADF documents. +export function toBodyText(site: JiraSite, text: string): unknown { + return site.authType === 'server' ? text : textToAdf(text) +} + +export function mapJiraIssue( + site: JiraSite, + raw: JiraRecord, + adfOptions?: AdfToMarkdownOptions +): JiraIssue { + const fields = asRecord(raw.fields) + const key = asString(raw.key) + return { + id: asString(raw.id, key), + key, + siteId: site.id, + siteName: site.displayName, + title: asString(fields.summary, key || 'Untitled issue'), + description: adfToMarkdownText(fields.description, adfOptions), + url: issueUrl(site, key), + project: mapProject(fields.project, site), + issueType: mapIssueType(fields.issuetype), + status: mapStatus(fields.status), + labels: asStringArray(fields.labels), + assignee: mapUser(fields.assignee), + reporter: mapUser(fields.reporter), + priority: mapPriority(fields.priority), + createdAt: asString(fields.created, new Date().toISOString()), + updatedAt: asString(fields.updated, new Date().toISOString()) + } +} diff --git a/src/main/jira/jira-issue-media.ts b/src/main/jira/jira-issue-media.ts new file mode 100644 index 00000000000..743717a320f --- /dev/null +++ b/src/main/jira/jira-issue-media.ts @@ -0,0 +1,104 @@ +import { collectAdfMediaAttrs, type AdfToMarkdownOptions } from './adf-markdown' +import { + extractAttachmentContentIdsFromHtml, + selectPreferredAttachmentIds, + warnIfMediaResolutionIncomplete +} from './attachment-discovery' +import { + createMediaMarkdownResolver, + loadIssueImageAttachments, + type MediaResolutionStats +} from './attachment-images' +import type { JiraClientForSite } from './authenticated-request' +import { asRecord, asString, type JiraRecord } from './jira-record-pages' + +export type MediaRequest = { + attachmentField: unknown + preferredIds: string[] + needCount: number + fallbackRan: boolean + issueKey: string +} + +/** Pooled: HTML/ADF selection only — no binary downloads. */ +export function collectIssueMediaRequest(raw: JiraRecord): MediaRequest | undefined { + const fields = asRecord(raw.fields) + const renderedFields = asRecord(raw.renderedFields) + const htmlIds = extractAttachmentContentIdsFromHtml( + asString(renderedFields.description) || undefined + ) + const mediaAttrs = collectAdfMediaAttrs(fields.description) + const selection = selectPreferredAttachmentIds({ + renderedHtmlIds: htmlIds, + attachmentField: fields.attachment, + mediaAttrs + }) + if (selection.needCount === 0 && selection.preferredIds.length === 0) { + return undefined + } + return { + attachmentField: fields.attachment, + preferredIds: selection.preferredIds, + needCount: selection.needCount, + fallbackRan: selection.fallbackRan, + issueKey: asString(raw.key) + } +} + +export type PreparedMedia = { + options: AdfToMarkdownOptions + stats: MediaResolutionStats + request: MediaRequest +} + +/** Unpooled: binary downloads + resolver (outside the Jira API semaphore). */ +export async function prepareMediaResolver( + client: JiraClientForSite, + request: MediaRequest +): Promise { + if (request.preferredIds.length === 0) { + warnIfMediaResolutionIncomplete({ + siteId: client.site.id, + issueKey: request.issueKey, + needCount: request.needCount, + preferredIdCount: 0, + resolvedCount: 0, + fallbackRan: request.fallbackRan + }) + return undefined + } + const images = await loadIssueImageAttachments( + client, + request.attachmentField, + request.preferredIds + ) + if (images.length === 0) { + warnIfMediaResolutionIncomplete({ + siteId: client.site.id, + issueKey: request.issueKey, + needCount: request.needCount, + preferredIdCount: request.preferredIds.length, + resolvedCount: 0, + fallbackRan: request.fallbackRan + }) + return undefined + } + const stats: MediaResolutionStats = { attachmentResolvedCount: 0 } + const resolveMedia = createMediaMarkdownResolver(images, request.preferredIds, stats) + return { + options: { resolveMedia }, + stats, + request + } +} + +export function flushMediaResolutionWarn(client: JiraClientForSite, prepared: PreparedMedia): void { + warnIfMediaResolutionIncomplete({ + siteId: client.site.id, + issueKey: prepared.request.issueKey, + needCount: prepared.request.needCount, + preferredIdCount: prepared.request.preferredIds.length, + resolvedCount: prepared.stats.attachmentResolvedCount, + fallbackRan: prepared.request.fallbackRan + }) +} diff --git a/src/main/jira/jira-issue-mutations.ts b/src/main/jira/jira-issue-mutations.ts new file mode 100644 index 00000000000..29b5fd7a679 --- /dev/null +++ b/src/main/jira/jira-issue-mutations.ts @@ -0,0 +1,146 @@ +import type { + JiraCreateIssueArgs, + JiraCreateIssueResult, + JiraIssueUpdate, + JiraMutationResult +} from '../../shared/jira-types' +import { acquire, release } from './request-queue' +import { apiBasePath, jiraRequest } from './authenticated-request' +import { clearToken, getClients, isAuthError } from './client' +import { issueUrl, toBodyText } from './jira-issue-mapping' +import type { JiraRecord } from './jira-record-pages' + +export async function createIssue(args: JiraCreateIssueArgs): Promise { + const entry = getClients(args.siteId)[0] + if (!entry) { + return { ok: false, error: 'Not connected to Jira.' } + } + const title = args.title.trim() + if (!title) { + return { ok: false, error: 'Title is required.' } + } + + await acquire() + try { + const fields: JiraRecord = { + project: { id: args.projectId }, + issuetype: { id: args.issueTypeId }, + summary: title + } + if (args.description?.trim()) { + fields.description = toBodyText(entry.site, args.description.trim()) + } + for (const [fieldKey, value] of Object.entries(args.customFields ?? {})) { + if (!fieldKey || value === undefined || value === null || value === '') { + continue + } + fields[fieldKey] = value + } + const created = await jiraRequest<{ id: string; key: string; self: string }>( + entry, + `${apiBasePath(entry.site)}/issue`, + { + method: 'POST', + body: JSON.stringify({ fields }) + } + ) + return { ok: true, id: created.id, key: created.key, url: issueUrl(entry.site, created.key) } + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + return { ok: false, error: error instanceof Error ? error.message : 'Failed to create issue.' } + } finally { + release() + } +} + +export async function updateIssue( + key: string, + updates: JiraIssueUpdate, + siteId?: string | null +): Promise { + const entry = getClients(siteId)[0] + if (!entry) { + return { ok: false, error: 'Not connected to Jira.' } + } + await acquire() + try { + const fields: JiraRecord = {} + if (updates.title !== undefined) { + fields.summary = updates.title + } + if (updates.labels !== undefined) { + fields.labels = updates.labels + } + if (updates.priorityId !== undefined) { + fields.priority = updates.priorityId ? { id: updates.priorityId } : null + } + const issueBase = `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}` + if (Object.keys(fields).length > 0) { + await jiraRequest(entry, issueBase, { + method: 'PUT', + body: JSON.stringify({ fields }) + }) + } + if (updates.assigneeAccountId !== undefined) { + // Server/DC identifies assignees by username (`name`), not accountId; + // mapUser stores the Server username in the accountId slot. + const assigneeBody = + entry.site.authType === 'server' + ? { name: updates.assigneeAccountId } + : { accountId: updates.assigneeAccountId } + await jiraRequest(entry, `${issueBase}/assignee`, { + method: 'PUT', + body: JSON.stringify(assigneeBody) + }) + } + if (updates.transitionId) { + await jiraRequest(entry, `${issueBase}/transitions`, { + method: 'POST', + body: JSON.stringify({ transition: { id: updates.transitionId } }) + }) + } + return { ok: true } + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + return { ok: false, error: error instanceof Error ? error.message : 'Failed to update issue.' } + } finally { + release() + } +} + +export async function addIssueComment( + key: string, + body: string, + siteId?: string | null +): Promise<{ ok: true; id: string } | { ok: false; error: string }> { + const entry = getClients(siteId)[0] + if (!entry) { + return { ok: false, error: 'Not connected to Jira.' } + } + await acquire() + try { + const comment = await jiraRequest<{ id: string }>( + entry, + `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/comment`, + { + method: 'POST', + body: JSON.stringify({ body: toBodyText(entry.site, body) }) + } + ) + return { ok: true, id: comment.id } + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + return { ok: false, error: error instanceof Error ? error.message : 'Failed to add comment.' } + } finally { + release() + } +} diff --git a/src/main/jira/jira-issue-read.ts b/src/main/jira/jira-issue-read.ts new file mode 100644 index 00000000000..16889c50b3b --- /dev/null +++ b/src/main/jira/jira-issue-read.ts @@ -0,0 +1,121 @@ +import type { JiraIssue, JiraSiteSelection } from '../../shared/jira-types' +import { JiraSummaryLookupError } from '../../shared/jira-summary-lookup' +import { acquire, release } from './request-queue' +import { apiBasePath, jiraRequest, type JiraClientForSite } from './authenticated-request' +import { clearToken, getClients, isAuthError } from './client' +import { ISSUE_DETAIL_FIELDS, ISSUE_SUMMARY_FIELDS, mapJiraIssue } from './jira-issue-mapping' +import { + collectIssueMediaRequest, + flushMediaResolutionWarn, + prepareMediaResolver, + type MediaRequest +} from './jira-issue-media' +import type { JiraRecord } from './jira-record-pages' +import { + getErrorStatus, + settleJiraSummaryRead, + shouldSurfaceSiteFailure, + withJiraDeadline +} from './jira-read-failure' + +const ISSUE_SUMMARY_TIMEOUT_MS = 30_000 + +export async function getIssue( + key: string, + siteId?: JiraSiteSelection | null +): Promise { + const entries = getClients(siteId) + for (const entry of entries) { + let mediaRequest: MediaRequest | undefined + let issue: JiraRecord | undefined + let held = false + try { + await acquire() + held = true + const params = new URLSearchParams({ + fields: ISSUE_DETAIL_FIELDS.join(','), + expand: 'renderedFields' + }) + issue = await jiraRequest( + entry, + `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}?${params.toString()}` + ) + // Why: keep only JSON under the pool; binary downloads fan out after release. + mediaRequest = collectIssueMediaRequest(issue) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + if (shouldSurfaceSiteFailure(siteId, entries.length)) { + throw error + } + } else { + console.warn('[jira] getIssue failed:', error) + } + continue + } finally { + if (held) { + held = false + release() + } + } + + try { + if (!issue) { + continue + } + const prepared = mediaRequest ? await prepareMediaResolver(entry, mediaRequest) : undefined + const mapped = mapJiraIssue(entry.site, issue, prepared?.options) + if (prepared) { + flushMediaResolutionWarn(entry, prepared) + } + return mapped + } catch (error) { + console.warn('[jira] getIssue media load failed:', error) + return mapJiraIssue(entry.site, issue) + } + } + return null +} + +export async function getIssueSummary( + key: string, + siteId: string, + signal?: AbortSignal +): Promise { + let entries: JiraClientForSite[] + try { + entries = getClients(siteId) + } catch (error) { + throw new JiraSummaryLookupError('auth', error) + } + const entry = entries.find((candidate) => candidate.site.id === siteId) + if (!entry) { + throw new JiraSummaryLookupError('disconnected') + } + + return withJiraDeadline(signal, ISSUE_SUMMARY_TIMEOUT_MS, async (requestSignal) => { + await acquire(requestSignal) + try { + const params = new URLSearchParams({ fields: ISSUE_SUMMARY_FIELDS.join(',') }) + const issue = await settleJiraSummaryRead( + jiraRequest( + entry, + `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}?${params.toString()}`, + { signal: requestSignal } + ), + requestSignal + ) + return mapJiraIssue(entry.site, issue) + } catch (error) { + if (isAuthError(error)) { + throw new JiraSummaryLookupError('auth', error) + } + if (getErrorStatus(error) === 404) { + throw new JiraSummaryLookupError('not-found', error) + } + throw new JiraSummaryLookupError('read-failed', error) + } finally { + release() + } + }) +} diff --git a/src/main/jira/jira-issue-search.ts b/src/main/jira/jira-issue-search.ts new file mode 100644 index 00000000000..743342f7727 --- /dev/null +++ b/src/main/jira/jira-issue-search.ts @@ -0,0 +1,122 @@ +import type { JiraIssue, JiraIssueFilter, JiraSiteSelection } from '../../shared/jira-types' +import { acquire, release } from './request-queue' +import { apiBasePath, jiraRequest, type JiraClientForSite } from './authenticated-request' +import { clearToken, getClients, isAuthError } from './client' +import { ISSUE_LIST_FIELDS, mapJiraIssue } from './jira-issue-mapping' +import type { JiraSearchResponse } from './jira-record-pages' +import { + shouldSurfaceSiteFailure, + toIssueSearchFailureError, + withJiraDeadline, + type JiraIssueSearchFailure +} from './jira-read-failure' + +const ISSUE_SEARCH_TIMEOUT_MS = 30_000 + +function clampLimit(limit: number | undefined, fallback = 30): number { + return Math.min(Math.max(1, Number.isFinite(limit) ? Number(limit) : fallback), 100) +} + +function sortAndLimitIssues(issues: JiraIssue[], limit: number): JiraIssue[] { + return issues + .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) + .slice(0, limit) +} + +function filterToJql(filter: JiraIssueFilter): string { + if (filter === 'assigned') { + return 'assignee = currentUser() AND resolution = Unresolved ORDER BY updated DESC' + } + if (filter === 'reported') { + return 'reporter = currentUser() AND resolution = Unresolved ORDER BY updated DESC' + } + if (filter === 'done') { + return 'assignee = currentUser() AND resolution IS NOT EMPTY ORDER BY updated DESC' + } + return 'resolution = Unresolved ORDER BY updated DESC' +} + +async function searchIssuesForClient( + entry: JiraClientForSite, + jql: string, + limit: number, + signal?: AbortSignal +): Promise { + // Server/DC only has the classic /search resource; /search/jql is Cloud-only. + const searchPath = + entry.site.authType === 'server' + ? `${apiBasePath(entry.site)}/search` + : '/rest/api/3/search/jql' + const result = await jiraRequest(entry, searchPath, { + method: 'POST', + body: JSON.stringify({ + jql, + maxResults: limit, + fields: ISSUE_LIST_FIELDS + }), + signal + }) + return (result.issues ?? []).map((issue) => mapJiraIssue(entry.site, issue)) +} + +export async function listIssues( + filter: JiraIssueFilter = 'assigned', + limit = 30, + siteId?: JiraSiteSelection | null +): Promise { + return searchIssues(filterToJql(filter), limit, siteId) +} + +export async function searchIssues( + jql: string, + limit = 30, + siteId?: JiraSiteSelection | null, + signal?: AbortSignal +): Promise { + const entries = getClients(siteId) + if (entries.length === 0 || !jql.trim()) { + return [] + } + const safeLimit = clampLimit(limit) + const failures: (JiraIssueSearchFailure | undefined)[] = Array.from({ length: entries.length }) + const surfaceSiteFailure = shouldSurfaceSiteFailure(siteId, entries.length) + const results = await withJiraDeadline(signal, ISSUE_SEARCH_TIMEOUT_MS, (requestSignal) => + Promise.all( + entries.map(async (entry, index) => { + // Why: queueing on an abandoned search would keep occupying the shared Jira pool. + await acquire(requestSignal) + try { + return await searchIssuesForClient(entry, jql.trim(), safeLimit, requestSignal) + } catch (error) { + if (requestSignal.aborted) { + // Abandoned by the caller: not a site failure, so don't clear tokens or mask a real one. + throw error + } + const authFailure = isAuthError(error) + if (authFailure) { + clearToken(entry.site.id) + } + if (surfaceSiteFailure) { + throw toIssueSearchFailureError(error) + } + console.warn('[jira] searchIssues failed:', error) + failures[index] = { error: toIssueSearchFailureError(error), auth: authFailure } + return [] as JiraIssue[] + } finally { + release() + } + }) + ) + ) + // 'all' fan-out: only surface an error when every connected site failed, so a + // partial success (or a genuinely empty result) is not reported as an error. + const recordedFailures = failures.filter( + (failure): failure is JiraIssueSearchFailure => failure !== undefined + ) + if (recordedFailures.length === entries.length) { + throw (recordedFailures.find((failure) => !failure.auth) ?? recordedFailures[0]).error + } + return entries.length === 1 + ? results.flat().slice(0, safeLimit) + : sortAndLimitIssues(results.flat(), safeLimit) +} diff --git a/src/main/jira/jira-project-queries.ts b/src/main/jira/jira-project-queries.ts new file mode 100644 index 00000000000..3c5c6d7b63d --- /dev/null +++ b/src/main/jira/jira-project-queries.ts @@ -0,0 +1,47 @@ +import type { JiraProject, JiraSiteSelection } from '../../shared/jira-types' +import { acquire, release } from './request-queue' +import { apiBasePath, jiraRequest } from './authenticated-request' +import { clearToken, getClients, isAuthError } from './client' +import { mapProject } from './jira-issue-mapping' +import { fetchPagedRecords, type JiraRecord } from './jira-record-pages' +import { shouldSurfaceSiteFailure } from './jira-read-failure' + +export async function listProjects(siteId?: JiraSiteSelection | null): Promise { + const entries = getClients(siteId) + if (entries.length === 0) { + return [] + } + const results = await Promise.all( + entries.map(async (entry) => { + await acquire() + try { + // Server/DC has no /project/search resource; /project returns the + // full list as a plain (unpaged) array. + const projects = + entry.site.authType === 'server' + ? await jiraRequest(entry, `${apiBasePath(entry.site)}/project`) + : await fetchPagedRecords(entry, 'values', (startAt, maxResults) => { + const params = new URLSearchParams({ + maxResults: String(maxResults), + startAt: String(startAt) + }) + return `/rest/api/3/project/search?${params.toString()}` + }) + return projects.map((project) => mapProject(project, entry.site)) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + if (shouldSurfaceSiteFailure(siteId, entries.length)) { + throw error + } + } else { + console.warn('[jira] listProjects failed:', error) + } + return [] + } finally { + release() + } + }) + ) + return results.flat().sort((a, b) => a.name.localeCompare(b.name)) +} diff --git a/src/main/jira/jira-read-failure.ts b/src/main/jira/jira-read-failure.ts new file mode 100644 index 00000000000..e87011d12b9 --- /dev/null +++ b/src/main/jira/jira-read-failure.ts @@ -0,0 +1,80 @@ +import type { JiraSiteSelection } from '../../shared/jira-types' + +export type JiraIssueSearchFailure = { + error: unknown + auth: boolean +} + +/** Run against one signal that trips on the caller's abort or the request deadline. */ +export async function withJiraDeadline( + signal: AbortSignal | undefined, + timeoutMs: number, + run: (deadlineSignal: AbortSignal) => Promise +): Promise { + const controller = new AbortController() + const abort = (): void => controller.abort() + signal?.addEventListener('abort', abort, { once: true }) + if (signal?.aborted) { + controller.abort() + } + const timer = setTimeout(abort, timeoutMs) + try { + return await run(controller.signal) + } finally { + clearTimeout(timer) + signal?.removeEventListener('abort', abort) + } +} + +export function settleJiraSummaryRead(read: Promise, signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.reject(new Error('Jira summary lookup aborted')) + } + return new Promise((resolve, reject) => { + const handleAbort = (): void => { + cleanup() + reject(new Error('Jira summary lookup aborted')) + } + const cleanup = (): void => signal.removeEventListener('abort', handleAbort) + signal.addEventListener('abort', handleAbort, { once: true }) + void read.then( + (value) => { + cleanup() + resolve(value) + }, + (error: unknown) => { + cleanup() + reject(error) + } + ) + }) +} + +export function getErrorStatus(error: unknown): number | null { + if (!error || typeof error !== 'object' || !('status' in error)) { + return null + } + const status = (error as { status?: unknown }).status + return typeof status === 'number' && Number.isFinite(status) ? status : null +} + +export function toIssueSearchFailureError(error: unknown): unknown { + const status = getErrorStatus(error) + if ( + status === null || + !(error instanceof Error) || + error.message.startsWith(`Error ${status}:`) + ) { + return error + } + return new Error(`Error ${status}: ${error.message}`) +} + +export function shouldSurfaceSiteFailure( + selection: JiraSiteSelection | null | undefined, + entryCount: number +): boolean { + // getClients can resolve an omitted selection to the persisted 'all' choice; + // multi-entry reads need the same resilient fan-out policy as explicit 'all'. + return selection !== 'all' && entryCount <= 1 +} diff --git a/src/main/jira/jira-record-pages.ts b/src/main/jira/jira-record-pages.ts new file mode 100644 index 00000000000..c6bcba6ea9f --- /dev/null +++ b/src/main/jira/jira-record-pages.ts @@ -0,0 +1,96 @@ +import { jiraRequest, type JiraClientForSite } from './authenticated-request' + +export type JiraRecord = Record + +export type JiraSearchResponse = { + issues?: JiraRecord[] +} + +export type JiraPagedResponse = { + startAt?: number + maxResults?: number + total?: number + isLast?: boolean + values?: T[] + issueTypes?: T[] + comments?: T[] + fields?: T[] | Record +} + +export type JiraPageItemKey = 'values' | 'issueTypes' | 'comments' + +export function asRecord(value: unknown): JiraRecord { + return value && typeof value === 'object' ? (value as JiraRecord) : {} +} + +export function asString(value: unknown, fallback = ''): string { + return typeof value === 'string' ? value : fallback +} + +export function asIdentifier(value: unknown): string { + if (typeof value === 'string') { + return value + } + return typeof value === 'number' && Number.isFinite(value) ? String(value) : '' +} + +export function asStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : [] +} + +export function asFiniteNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +export function getPageItems(response: JiraPagedResponse, key: JiraPageItemKey): T[] { + const keyedItems = response[key] + if (Array.isArray(keyedItems)) { + return keyedItems + } + return response.values ?? [] +} + +export function shouldFetchNextPage( + response: JiraPagedResponse, + startAt: number, + items: T[], + requestedMaxResults: number +): boolean { + if (response.isLast === true || items.length === 0) { + return false + } + const total = asFiniteNumber(response.total) + const pageSize = asFiniteNumber(response.maxResults) + if (total !== null) { + return startAt + items.length < total && (pageSize ?? requestedMaxResults) > 0 + } + if (response.isLast === false) { + return (pageSize ?? requestedMaxResults) > 0 + } + return pageSize !== null && items.length >= pageSize +} + +export async function fetchPagedRecords( + entry: JiraClientForSite, + key: JiraPageItemKey, + pathForPage: (startAt: number, maxResults: number) => string, + maxResults = 100 +): Promise { + const records: JiraRecord[] = [] + let startAt = 0 + for (let guard = 0; guard < 100; guard += 1) { + const response = await jiraRequest>( + entry, + pathForPage(startAt, maxResults) + ) + const items = getPageItems(response, key) + records.push(...items) + if (!shouldFetchNextPage(response, startAt, items, maxResults)) { + break + } + startAt += asFiniteNumber(response.maxResults) ?? maxResults + } + return records +} diff --git a/src/main/jira/jira-transition-queries.ts b/src/main/jira/jira-transition-queries.ts new file mode 100644 index 00000000000..2e40a905981 --- /dev/null +++ b/src/main/jira/jira-transition-queries.ts @@ -0,0 +1,118 @@ +import type { JiraProjectStatusOrder, JiraTransition } from '../../shared/jira-types' +import { acquire, release } from './request-queue' +import { apiBasePath, jiraRequest } from './authenticated-request' +import { clearToken, getClients, isAuthError } from './client' +import { mapStatus } from './jira-issue-mapping' +import { + asFiniteNumber, + asIdentifier, + asRecord, + asString, + type JiraPagedResponse, + type JiraRecord +} from './jira-record-pages' + +export async function listTransitions( + key: string, + siteId?: string | null +): Promise { + const entry = getClients(siteId)[0] + if (!entry) { + return [] + } + await acquire() + try { + const response = await jiraRequest<{ transitions?: JiraRecord[] }>( + entry, + `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/transitions` + ) + return (response.transitions ?? []).map((transition) => ({ + id: asString(transition.id), + name: asString(transition.name), + to: mapStatus(transition.to) + })) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + console.warn('[jira] listTransitions failed:', error) + return [] + } finally { + release() + } +} + +export async function getProjectStatusOrder( + projectKey: string, + siteId?: string | null +): Promise { + // Why: an omitted site can resolve to the persisted "all" selection; board + // metadata is only truthful when exactly one Jira connection owns the project. + const entries = getClients(siteId) + const entry = entries.length === 1 ? entries[0] : undefined + if (!entry) { + return { statusIdsByColumn: [] } + } + await acquire() + try { + // Why: without an explicit board picker there is no truthful way to choose + // among multiple project boards, so ambiguous projects keep alphabetical order. + const params = new URLSearchParams({ projectKeyOrId: projectKey, maxResults: '2' }) + const boardsResponse = await jiraRequest>( + entry, + `/rest/agile/1.0/board?${params.toString()}` + ) + const boards = boardsResponse.values ?? [] + const boardCount = asFiniteNumber(boardsResponse.total) + const singleBoardIsProven = + boards.length === 1 && + boardsResponse.isLast !== false && + (boardCount === 1 || (boardCount === null && boardsResponse.isLast === true)) + const boardId = singleBoardIsProven ? asIdentifier(asRecord(boards[0]).id) : '' + if (!boardId) { + return { statusIdsByColumn: [] } + } + + const configResponse = await jiraRequest( + entry, + `/rest/agile/1.0/board/${encodeURIComponent(boardId)}/configuration` + ) + const columns = asRecord(asRecord(configResponse).columnConfig).columns + if (!Array.isArray(columns)) { + return { statusIdsByColumn: [] } + } + + // Why: issues already carry status names and IDs, so returning board IDs + // avoids a second metadata request and keeps duplicate names unambiguous. + const seenStatusIds = new Set() + const statusIdsByColumn: string[][] = [] + for (const column of columns) { + const statuses = asRecord(column).statuses + if (!Array.isArray(statuses)) { + continue + } + const columnStatusIds: string[] = [] + for (const status of statuses) { + const statusId = asIdentifier(asRecord(status).id) + if (statusId && !seenStatusIds.has(statusId)) { + seenStatusIds.add(statusId) + columnStatusIds.push(statusId) + } + } + if (columnStatusIds.length > 0) { + statusIdsByColumn.push(columnStatusIds) + } + } + return { statusIdsByColumn } + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + console.warn('[jira] getProjectStatusOrder failed:', error) + return { statusIdsByColumn: [] } + } finally { + release() + } +} diff --git a/src/main/linear/linear-custom-view-graphql.ts b/src/main/linear/linear-custom-view-graphql.ts new file mode 100644 index 00000000000..8cdfd96dddd --- /dev/null +++ b/src/main/linear/linear-custom-view-graphql.ts @@ -0,0 +1,114 @@ +import { ORCA_ISSUE_FIELDS, ORCA_PROJECT_FIELDS } from './linear-project-graphql' + +export const CUSTOM_VIEWS_QUERY = ` + query OrcaLinearCustomViews( + $first: Int, + $filter: CustomViewFilter, + $orderBy: PaginationOrderBy + ) { + customViews(first: $first, filter: $filter, orderBy: $orderBy) { + nodes { + id + name + description + modelName + color + icon + shared + slugId + createdAt + updatedAt + team { + id + name + key + } + owner { + id + displayName + avatarUrl + } + creator { + id + displayName + avatarUrl + } + } + pageInfo { + hasNextPage + } + } + } +` + +export const CUSTOM_VIEW_QUERY = ` + query OrcaLinearCustomView($id: String!) { + customView(id: $id) { + id + name + description + modelName + color + icon + shared + slugId + createdAt + updatedAt + team { + id + name + key + } + owner { + id + displayName + avatarUrl + } + creator { + id + displayName + avatarUrl + } + } + } +` + +export const CUSTOM_VIEW_ISSUES_QUERY = ` + query OrcaLinearCustomViewIssues( + $id: String!, + $first: Int, + $after: String, + $orderBy: PaginationOrderBy + ) { + customView(id: $id) { + id + modelName + issues(first: $first, after: $after, orderBy: $orderBy) { + nodes { + ${ORCA_ISSUE_FIELDS} + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +` + +export const CUSTOM_VIEW_PROJECTS_QUERY = ` + query OrcaLinearCustomViewProjects($id: String!, $first: Int, $orderBy: PaginationOrderBy) { + customView(id: $id) { + id + modelName + projects(first: $first, orderBy: $orderBy) { + nodes { + ${ORCA_PROJECT_FIELDS} + } + pageInfo { + hasNextPage + } + } + } + } +` diff --git a/src/main/linear/linear-custom-view-queries.ts b/src/main/linear/linear-custom-view-queries.ts new file mode 100644 index 00000000000..fe225e644d3 --- /dev/null +++ b/src/main/linear/linear-custom-view-queries.ts @@ -0,0 +1,169 @@ +import type { LinearIssue } from '../../shared/linear/issue-types' +import type { + LinearCustomViewModel, + LinearCustomViewSummary, + LinearProjectSummary +} from '../../shared/linear/project-types' +import type { + LinearCollectionResult, + LinearConcreteWorkspaceId, + LinearWorkspaceSelection +} from '../../shared/linear/workspace-types' +import { clampLinearIssueListLimit } from '../../shared/linear/issue-read-limits' +import { acquire, release } from './linear-request-concurrency' +import { clearToken } from './linear-token-store' +import { getClients, isAuthError } from './client' +import { + CUSTOM_VIEW_ISSUES_QUERY, + CUSTOM_VIEW_PROJECTS_QUERY, + CUSTOM_VIEW_QUERY, + CUSTOM_VIEWS_QUERY +} from './linear-custom-view-graphql' +import type { CustomViewConnectionResponse, LinearRawVariables } from './linear-project-nodes' +import { + clampLimit, + coalesce, + mapCustomViewForWorkspace, + mapCustomViewModel, + mapProjectForWorkspace, + normalizeConcreteWorkspaceId +} from './linear-project-models' +import { readCollection, readConcreteCollection } from './linear-project-collection-read' +import { readIssueConnectionPages } from './linear-project-issue-queries' + +export async function listCustomViews( + model: LinearCustomViewModel, + limit = 20, + workspaceId?: LinearWorkspaceSelection | null, + force = false +): Promise> { + const first = clampLimit(limit) + const key = `listCustomViews:${workspaceId ?? 'default'}:${model}:${first}` + const filter = { modelName: { eq: model === 'project' ? 'Project' : 'Issue' } } + return readCollection( + key, + workspaceId, + async (entry) => { + const result = await entry.client.client.rawRequest< + CustomViewConnectionResponse, + LinearRawVariables + >(CUSTOM_VIEWS_QUERY, { first, filter, orderBy: 'updatedAt' }) + const connection = result.data?.customViews + return { + items: (connection?.nodes ?? []) + .map((view) => mapCustomViewForWorkspace(entry, view)) + .filter((view): view is LinearCustomViewSummary => !!view && view.model === model), + hasMore: !!connection?.pageInfo?.hasNextPage + } + }, + force + ) +} + +export async function getCustomView( + viewId: string, + model: LinearCustomViewModel, + workspaceId: LinearConcreteWorkspaceId, + force = false +): Promise { + const id = viewId.trim() + const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) + if (!id) { + throw new Error('Custom view ID is required') + } + const key = `getCustomView:${concreteWorkspaceId}:${model}:${id}` + return coalesce( + key, + async () => { + const entries = getClients(concreteWorkspaceId) + const entry = entries[0] + if (!entry) { + return null + } + await acquire() + try { + const result = await entry.client.client.rawRequest< + CustomViewConnectionResponse, + LinearRawVariables + >(CUSTOM_VIEW_QUERY, { id }) + const view = result.data?.customView + const mapped = view ? mapCustomViewForWorkspace(entry, view) : null + return mapped?.model === model ? mapped : null + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.workspace.id) + } + throw error + } finally { + release() + } + }, + force + ) +} + +export async function listCustomViewIssues( + viewId: string, + limit = 20, + workspaceId: LinearConcreteWorkspaceId, + force = false +): Promise> { + const id = viewId.trim() + if (!id) { + throw new Error('Custom view ID is required') + } + const first = clampLinearIssueListLimit(limit) + const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) + return readConcreteCollection( + `listCustomViewIssues:${concreteWorkspaceId}:${id}:${first}`, + concreteWorkspaceId, + async (entry) => { + return readIssueConnectionPages(entry, first, async (page) => { + const result = await entry.client.client.rawRequest< + CustomViewConnectionResponse, + LinearRawVariables + >(CUSTOM_VIEW_ISSUES_QUERY, { id, ...page, orderBy: 'updatedAt' }) + const view = result.data?.customView + if (mapCustomViewModel(view?.modelName) !== 'issue') { + throw new Error('Custom view does not contain issues') + } + return view?.issues + }) + }, + force + ) +} + +export async function listCustomViewProjects( + viewId: string, + limit = 20, + workspaceId: LinearConcreteWorkspaceId, + force = false +): Promise> { + const id = viewId.trim() + if (!id) { + throw new Error('Custom view ID is required') + } + const first = clampLimit(limit) + const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) + return readConcreteCollection( + `listCustomViewProjects:${concreteWorkspaceId}:${id}:${first}`, + concreteWorkspaceId, + async (entry) => { + const result = await entry.client.client.rawRequest< + CustomViewConnectionResponse, + LinearRawVariables + >(CUSTOM_VIEW_PROJECTS_QUERY, { id, first, orderBy: 'updatedAt' }) + const view = result.data?.customView + if (mapCustomViewModel(view?.modelName) !== 'project') { + throw new Error('Custom view does not contain projects') + } + const connection = view?.projects + return { + items: (connection?.nodes ?? []).map((project) => mapProjectForWorkspace(entry, project)), + hasMore: !!connection?.pageInfo?.hasNextPage + } + }, + force + ) +} diff --git a/src/main/linear/linear-project-collection-read.ts b/src/main/linear/linear-project-collection-read.ts new file mode 100644 index 00000000000..d9924e46346 --- /dev/null +++ b/src/main/linear/linear-project-collection-read.ts @@ -0,0 +1,71 @@ +import type { + LinearCollectionResult, + LinearConcreteWorkspaceId, + LinearWorkspaceSelection +} from '../../shared/linear/workspace-types' +import { acquire, release } from './linear-request-concurrency' +import { clearToken } from './linear-token-store' +import { getClients, isAuthError, type LinearClientForWorkspace } from './client' +import { + coalesce, + normalizeConcreteWorkspaceId, + shouldFailWholeRequest, + workspaceError +} from './linear-project-models' + +export async function readCollection( + key: string, + workspaceId: LinearWorkspaceSelection | null | undefined, + load: (entry: LinearClientForWorkspace) => Promise>, + force = false +): Promise> { + return coalesce( + key, + async () => { + const entries = getClients(workspaceId) + if (entries.length === 0) { + return { items: [] } + } + + const results = await Promise.all( + entries.map(async (entry) => { + await acquire() + try { + return await load(entry) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.workspace.id) + } else { + console.warn('[linear] project/view read failed:', error) + } + if (shouldFailWholeRequest(workspaceId)) { + throw error + } + return { items: [], errors: [workspaceError(entry, error)] } + } finally { + release() + } + }) + ) + + return { + items: results.flatMap((result) => result.items), + errors: results.flatMap((result) => result.errors ?? []).length + ? results.flatMap((result) => result.errors ?? []) + : undefined, + hasMore: results.some((result) => result.hasMore) + } + }, + force + ) +} + +export async function readConcreteCollection( + key: string, + workspaceId: LinearConcreteWorkspaceId, + load: (entry: LinearClientForWorkspace) => Promise>, + force = false +): Promise> { + const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) + return readCollection(key, concreteWorkspaceId, load, force) +} diff --git a/src/main/linear/linear-project-graphql.ts b/src/main/linear/linear-project-graphql.ts new file mode 100644 index 00000000000..bd85f70e819 --- /dev/null +++ b/src/main/linear/linear-project-graphql.ts @@ -0,0 +1,207 @@ +export const ORCA_PROJECT_FIELDS = ` + id + slugId + name + description + content + url + color + icon + health + priority + priorityLabel + progress + scope + issueCountHistory + completedIssueCountHistory + startDate + targetDate + createdAt + updatedAt + completedAt + canceledAt + startedAt + status { + id + name + type + color + } + lead { + id + displayName + avatarUrl + } + members(first: 10) { + nodes { + id + displayName + avatarUrl + } + } + teams(first: 10) { + nodes { + id + name + key + } + } + labels(first: 20) { + nodes { + id + name + color + } + } +` + +export const ORCA_PROJECT_DETAIL_FIELDS = ` + ${ORCA_PROJECT_FIELDS} + projectMilestones(first: 20) { + nodes { + id + name + status + targetDate + progress + } + } + externalLinks(first: 20) { + nodes { + id + label + url + } + } + lastUpdate { + id + body + health + url + createdAt + updatedAt + user { + id + displayName + avatarUrl + } + } +` + +export const ORCA_ISSUE_FIELDS = ` + id + identifier + title + description + url + priority + estimate + updatedAt + labelIds + state { + name + type + color + } + team { + id + name + key + } + assignee { + id + displayName + avatarUrl + } + labels(first: 50) { + nodes { + id + name + } + } +` + +export const PROJECTS_QUERY = ` + query OrcaLinearProjects($first: Int, $filter: ProjectFilter, $orderBy: PaginationOrderBy) { + projects(first: $first, filter: $filter, orderBy: $orderBy) { + nodes { + ${ORCA_PROJECT_FIELDS} + } + pageInfo { + hasNextPage + } + } + } +` + +export const SEARCH_PROJECTS_QUERY = ` + query OrcaLinearProjectSearch($term: String!, $first: Int, $after: String) { + searchProjects(term: $term, first: $first, after: $after) { + nodes { + ${ORCA_PROJECT_FIELDS} + } + pageInfo { + hasNextPage + endCursor + } + } + } +` + +export const PROJECT_QUERY = ` + query OrcaLinearProject($id: String!) { + project(id: $id) { + ${ORCA_PROJECT_DETAIL_FIELDS} + } + } +` + +export const CREATE_PROJECT_MUTATION = ` + mutation OrcaLinearProjectCreate($input: ProjectCreateInput!) { + projectCreate(input: $input) { + success + project { + ${ORCA_PROJECT_DETAIL_FIELDS} + } + } + } +` + +export const PROJECT_ISSUES_QUERY = ` + query OrcaLinearProjectIssues( + $id: String!, + $first: Int, + $after: String, + $orderBy: PaginationOrderBy + ) { + project(id: $id) { + issues(first: $first, after: $after, orderBy: $orderBy) { + nodes { + ${ORCA_ISSUE_FIELDS} + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +` + +export const PROJECT_TEAMS_QUERY = ` + query OrcaLinearProjectTeams($id: String!, $first: Int, $after: String) { + project(id: $id) { + teams(first: $first, after: $after) { + nodes { + id + name + key + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +` diff --git a/src/main/linear/linear-project-issue-queries.ts b/src/main/linear/linear-project-issue-queries.ts new file mode 100644 index 00000000000..a2ced4a878b --- /dev/null +++ b/src/main/linear/linear-project-issue-queries.ts @@ -0,0 +1,82 @@ +import type { LinearIssue } from '../../shared/linear/issue-types' +import type { + LinearCollectionResult, + LinearConcreteWorkspaceId +} from '../../shared/linear/workspace-types' +import { + LINEAR_ISSUE_API_PAGE_SIZE_MAX, + clampLinearIssueListLimit +} from '../../shared/linear/issue-read-limits' +import type { LinearClientForWorkspace } from './client' +import { PROJECT_ISSUES_QUERY } from './linear-project-graphql' +import type { + LinearConnection, + LinearIssueNode, + LinearRawVariables, + ProjectIssueConnectionResponse +} from './linear-project-nodes' +import { mapIssueForWorkspace, normalizeConcreteWorkspaceId } from './linear-project-models' +import { readConcreteCollection } from './linear-project-collection-read' + +export async function readIssueConnectionPages( + entry: LinearClientForWorkspace, + limit: number, + loadConnection: (variables: { + first: number + after?: string + }) => Promise | null | undefined> +): Promise> { + const items: LinearIssue[] = [] + let after: string | undefined + let hasMore = false + + while (items.length < limit) { + // Why: Linear returns issue connections in pages of up to 50; expanded + // Orca reads must follow cursors to show more than one backend page. + const first = Math.min(LINEAR_ISSUE_API_PAGE_SIZE_MAX, limit - items.length) + const connection = await loadConnection(after ? { first, after } : { first }) + const nodes = connection?.nodes ?? [] + items.push(...nodes.map((issue) => mapIssueForWorkspace(entry, issue))) + hasMore = Boolean(connection?.pageInfo?.hasNextPage) + + const nextCursor = connection?.pageInfo?.endCursor ?? undefined + if (!hasMore || !nextCursor || nextCursor === after || nodes.length === 0) { + break + } + after = nextCursor + } + + return { items, hasMore } +} + +export async function listProjectIssues( + projectId: string, + limit = 20, + workspaceId: LinearConcreteWorkspaceId, + force = false +): Promise> { + const id = projectId.trim() + if (!id) { + throw new Error('Project ID is required') + } + const first = clampLinearIssueListLimit(limit) + const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) + return readConcreteCollection( + `listProjectIssues:${concreteWorkspaceId}:${id}:${first}`, + concreteWorkspaceId, + async (entry) => { + return readIssueConnectionPages(entry, first, async (page) => { + const result = await entry.client.client.rawRequest< + ProjectIssueConnectionResponse, + LinearRawVariables + >(PROJECT_ISSUES_QUERY, { id, ...page, orderBy: 'updatedAt' }) + const project = result.data?.project + if (!project) { + throw new Error('Project was not found') + } + return project.issues + }) + }, + force + ) +} diff --git a/src/main/linear/linear-project-models.ts b/src/main/linear/linear-project-models.ts new file mode 100644 index 00000000000..38371e70b1f --- /dev/null +++ b/src/main/linear/linear-project-models.ts @@ -0,0 +1,285 @@ +import type { LinearIssue } from '../../shared/linear/issue-types' +import type { + LinearCustomViewModel, + LinearCustomViewSummary, + LinearProjectDetail, + LinearProjectMemberSummary, + LinearProjectSummary +} from '../../shared/linear/project-types' +import type { + LinearConcreteWorkspaceId, + LinearWorkspaceError, + LinearWorkspaceSelection +} from '../../shared/linear/workspace-types' +import { isAuthError, type LinearClientForWorkspace } from './client' +import type { + LinearCustomViewNode, + LinearIssueNode, + LinearProjectNode, + LinearUserNode +} from './linear-project-nodes' + +const inFlight = new Map>() +export const LINEAR_PROJECT_API_PAGE_SIZE_MAX = 50 + +export function clampLimit(limit = 20): number { + return Math.min(Math.max(1, Math.floor(limit)), LINEAR_PROJECT_API_PAGE_SIZE_MAX) +} + +export function coalesce(key: string, load: () => Promise, force = false): Promise { + const existing = inFlight.get(key) as Promise | undefined + if (existing && !force) { + return existing + } + const promise = load().finally(() => { + if (inFlight.get(key) === promise) { + inFlight.delete(key) + } + }) + inFlight.set(key, promise) + return promise +} + +export function normalizeConcreteWorkspaceId(workspaceId: unknown): LinearConcreteWorkspaceId { + if (typeof workspaceId !== 'string' || !workspaceId.trim() || workspaceId === 'all') { + throw new Error('Concrete Linear workspace ID is required') + } + return workspaceId.trim() +} + +export function workspaceError( + entry: LinearClientForWorkspace, + error: unknown +): LinearWorkspaceError { + if (isAuthError(error)) { + return { + workspaceId: entry.workspace.id, + workspaceName: entry.workspace.organizationName, + type: 'auth', + message: 'Linear authentication expired for this workspace.' + } + } + + const record = error as { name?: string; message?: string; status?: number; response?: unknown } + const message = record.message || 'Linear request failed.' + const status = + typeof record.status === 'number' + ? record.status + : typeof (record.response as { status?: unknown } | undefined)?.status === 'number' + ? ((record.response as { status: number }).status as number) + : undefined + const name = record.name ?? '' + + if (status === 429 || /rate/i.test(name)) { + return { + workspaceId: entry.workspace.id, + workspaceName: entry.workspace.organizationName, + type: 'rate_limited', + message + } + } + if ((typeof status === 'number' && status >= 500) || /network/i.test(name)) { + return { + workspaceId: entry.workspace.id, + workspaceName: entry.workspace.organizationName, + type: 'network', + message + } + } + + return { + workspaceId: entry.workspace.id, + workspaceName: entry.workspace.organizationName, + type: 'unknown', + message + } +} + +export function shouldFailWholeRequest( + selection: LinearWorkspaceSelection | null | undefined +): boolean { + return selection !== 'all' +} + +export function lastNumericValue(values?: number[] | null): number | undefined { + const last = values?.at(-1) + return typeof last === 'number' ? last : undefined +} + +export function mapUser(user?: LinearUserNode | null): LinearProjectMemberSummary | undefined { + if (!user?.id) { + return undefined + } + return { + id: user.id, + displayName: user.displayName ?? '', + avatarUrl: user.avatarUrl ?? undefined + } +} + +export function mapProjectForWorkspace( + entry: LinearClientForWorkspace, + project: LinearProjectNode +): LinearProjectSummary { + return { + id: project.id, + slugId: project.slugId ?? undefined, + workspaceId: entry.workspace.id, + workspaceName: entry.workspace.organizationName, + name: project.name, + url: project.url ?? undefined, + color: project.color ?? undefined, + icon: project.icon ?? undefined, + description: project.description ?? undefined, + content: project.content ?? undefined, + status: project.status + ? { + id: project.status.id, + name: project.status.name ?? '', + type: project.status.type ?? undefined, + color: project.status.color ?? undefined + } + : undefined, + health: project.health ?? null, + priority: project.priority ?? null, + priorityLabel: project.priorityLabel ?? null, + lead: mapUser(project.lead), + members: project.members?.nodes + ?.map(mapUser) + .filter((user): user is LinearProjectMemberSummary => !!user), + teams: project.teams?.nodes?.map((team) => ({ + id: team.id, + name: team.name ?? '', + key: team.key ?? undefined + })), + labels: project.labels?.nodes?.map((label) => ({ + id: label.id, + name: label.name ?? '', + color: label.color ?? undefined + })), + startDate: project.startDate ?? null, + targetDate: project.targetDate ?? null, + createdAt: project.createdAt ?? undefined, + updatedAt: project.updatedAt ?? undefined, + completedAt: project.completedAt ?? null, + canceledAt: project.canceledAt ?? null, + startedAt: project.startedAt ?? null, + progress: project.progress ?? null, + scope: project.scope ?? null, + issueCount: lastNumericValue(project.issueCountHistory), + completedIssueCount: lastNumericValue(project.completedIssueCountHistory) + } +} + +export function mapProjectDetailForWorkspace( + entry: LinearClientForWorkspace, + project: LinearProjectNode +): LinearProjectDetail { + return { + ...mapProjectForWorkspace(entry, project), + milestones: project.projectMilestones?.nodes?.map((milestone) => ({ + id: milestone.id, + name: milestone.name ?? '', + status: milestone.status ?? undefined, + targetDate: milestone.targetDate ?? null, + progress: milestone.progress ?? null + })), + resources: project.externalLinks?.nodes + ?.filter((link) => link.url) + .map((link) => ({ + id: link.id, + title: link.label || link.url || 'Link', + url: link.url!, + type: 'link' + })), + latestUpdate: project.lastUpdate + ? { + id: project.lastUpdate.id, + body: project.lastUpdate.body ?? undefined, + health: project.lastUpdate.health ?? null, + url: project.lastUpdate.url ?? undefined, + createdAt: project.lastUpdate.createdAt ?? undefined, + updatedAt: project.lastUpdate.updatedAt ?? undefined, + user: mapUser(project.lastUpdate.user) + } + : undefined + } +} + +export function mapIssueForWorkspace( + entry: LinearClientForWorkspace, + issue: LinearIssueNode +): LinearIssue { + const labelNodes = issue.labels?.nodes ?? [] + return { + id: issue.id, + identifier: issue.identifier, + title: issue.title, + description: issue.description ?? undefined, + url: issue.url, + state: { + name: issue.state?.name ?? '', + type: issue.state?.type ?? '', + color: issue.state?.color ?? '' + }, + team: { + id: issue.team?.id ?? '', + name: issue.team?.name ?? '', + key: issue.team?.key ?? '' + }, + labels: labelNodes.map((label) => label.name), + labelIds: issue.labelIds ?? labelNodes.map((label) => label.id), + assignee: mapUser(issue.assignee), + estimate: issue.estimate ?? null, + priority: issue.priority, + updatedAt: issue.updatedAt, + workspaceId: entry.workspace.id, + workspaceName: entry.workspace.organizationName + } +} + +export function mapCustomViewModel(modelName?: string | null): LinearCustomViewModel | null { + const normalized = modelName?.toLowerCase() + if (normalized === 'issue') { + return 'issue' + } + if (normalized === 'project') { + return 'project' + } + return null +} + +export function mapCustomViewForWorkspace( + entry: LinearClientForWorkspace, + view: LinearCustomViewNode +): LinearCustomViewSummary | null { + const model = mapCustomViewModel(view.modelName) + if (!model) { + return null + } + return { + id: view.id, + workspaceId: entry.workspace.id, + workspaceName: entry.workspace.organizationName, + name: view.name, + description: view.description ?? undefined, + model, + url: view.slugId + ? `https://linear.app/${entry.workspace.organizationUrlKey}/view/${view.slugId}` + : undefined, + color: view.color ?? undefined, + icon: view.icon ?? undefined, + shared: view.shared ?? undefined, + team: view.team + ? { + id: view.team.id, + name: view.team.name ?? undefined, + key: view.team.key ?? undefined + } + : undefined, + owner: mapUser(view.owner), + creator: mapUser(view.creator), + createdAt: view.createdAt ?? undefined, + updatedAt: view.updatedAt ?? undefined + } +} diff --git a/src/main/linear/linear-project-nodes.ts b/src/main/linear/linear-project-nodes.ts new file mode 100644 index 00000000000..2796ca35d94 --- /dev/null +++ b/src/main/linear/linear-project-nodes.ts @@ -0,0 +1,161 @@ +export type LinearRawVariables = Record + +export type PageInfoNode = { + hasNextPage?: boolean | null + endCursor?: string | null +} + +export type LinearConnection = { + nodes?: T[] | null + pageInfo?: PageInfoNode | null +} + +export type LinearUserNode = { + id: string + displayName?: string | null + avatarUrl?: string | null +} + +export type LinearProjectNode = { + id: string + slugId?: string | null + name: string + description?: string | null + content?: string | null + url?: string | null + color?: string | null + icon?: string | null + health?: string | null + priority?: number | null + priorityLabel?: string | null + progress?: number | null + scope?: number | null + issueCountHistory?: number[] | null + completedIssueCountHistory?: number[] | null + startDate?: string | null + targetDate?: string | null + createdAt?: string | null + updatedAt?: string | null + completedAt?: string | null + canceledAt?: string | null + startedAt?: string | null + status?: { + id: string + name?: string | null + type?: string | null + color?: string | null + } | null + lead?: LinearUserNode | null + members?: LinearConnection | null + teams?: LinearConnection<{ id: string; name?: string | null; key?: string | null }> | null + labels?: LinearConnection<{ id: string; name?: string | null; color?: string | null }> | null + projectMilestones?: LinearConnection<{ + id: string + name?: string | null + status?: string | null + targetDate?: string | null + progress?: number | null + }> | null + externalLinks?: LinearConnection<{ + id: string + label?: string | null + url?: string | null + }> | null + lastUpdate?: { + id: string + body?: string | null + health?: string | null + url?: string | null + createdAt?: string | null + updatedAt?: string | null + user?: LinearUserNode | null + } | null +} + +export type LinearIssueNode = { + id: string + identifier: string + title: string + description?: string | null + url: string + estimate?: number | null + priority: number + updatedAt: string + labelIds?: string[] | null + state?: { + name?: string | null + type?: string | null + color?: string | null + } | null + team?: { + id?: string | null + name?: string | null + key?: string | null + } | null + assignee?: LinearUserNode | null + labels?: LinearConnection<{ id: string; name: string }> | null +} + +export type LinearCustomViewNode = { + id: string + name: string + description?: string | null + modelName?: string | null + color?: string | null + icon?: string | null + shared?: boolean | null + slugId?: string | null + createdAt?: string | null + updatedAt?: string | null + team?: { id: string; name?: string | null; key?: string | null } | null + owner?: LinearUserNode | null + creator?: LinearUserNode | null +} + +export type ProjectConnectionResponse = { + projects?: LinearConnection | null + searchProjects?: LinearConnection | null + project?: LinearProjectNode | null +} + +export type ProjectIssueConnectionResponse = { + project?: { + issues?: LinearConnection | null + } | null +} + +export type ProjectTeamsResponse = { + project?: { + teams?: LinearConnection<{ id: string; name?: string | null; key?: string | null }> | null + } | null +} + +export type CustomViewConnectionResponse = { + customViews?: LinearConnection | null + customView?: + | (LinearCustomViewNode & { + issues?: LinearConnection | null + projects?: LinearConnection | null + }) + | null +} + +export type ProjectMutationResponse = { + projectCreate?: { + success?: boolean | null + project?: LinearProjectNode | null + } | null +} + +export type LinearProjectCreateInput = { + name: string + description?: string + content?: string + teamIds: string[] + leadId?: string + memberIds?: string[] + labelIds?: string[] + priority?: number + startDate?: string + targetDate?: string +} diff --git a/src/main/linear/linear-project-queries.ts b/src/main/linear/linear-project-queries.ts new file mode 100644 index 00000000000..961d2f642ba --- /dev/null +++ b/src/main/linear/linear-project-queries.ts @@ -0,0 +1,196 @@ +import type { LinearProjectDetail, LinearProjectSummary } from '../../shared/linear/project-types' +import type { + LinearCollectionResult, + LinearConcreteWorkspaceId, + LinearWorkspaceSelection +} from '../../shared/linear/workspace-types' +import { acquire, release } from './linear-request-concurrency' +import { clearToken } from './linear-token-store' +import { getClients, isAuthError } from './client' +import { + CREATE_PROJECT_MUTATION, + PROJECT_QUERY, + PROJECTS_QUERY, + SEARCH_PROJECTS_QUERY +} from './linear-project-graphql' +import type { + LinearProjectCreateInput, + LinearRawVariables, + ProjectConnectionResponse, + ProjectMutationResponse +} from './linear-project-nodes' +import { + clampLimit, + coalesce, + LINEAR_PROJECT_API_PAGE_SIZE_MAX, + mapProjectDetailForWorkspace, + mapProjectForWorkspace, + normalizeConcreteWorkspaceId +} from './linear-project-models' +import { readCollection } from './linear-project-collection-read' + +export async function listProjects( + query: string | undefined, + limit = 20, + workspaceId?: LinearWorkspaceSelection | null, + force = false +): Promise> { + const first = clampLimit(limit) + const trimmed = query?.trim() + const key = `listProjects:${workspaceId ?? 'default'}:${trimmed ?? ''}:${first}` + return readCollection( + key, + workspaceId, + async (entry) => { + const variables = trimmed ? { term: trimmed, first } : { first, orderBy: 'updatedAt' } + const result = await entry.client.client.rawRequest< + ProjectConnectionResponse, + LinearRawVariables + >(trimmed ? SEARCH_PROJECTS_QUERY : PROJECTS_QUERY, variables) + const connection = trimmed ? result.data?.searchProjects : result.data?.projects + return { + items: (connection?.nodes ?? []).map((project) => mapProjectForWorkspace(entry, project)), + hasMore: !!connection?.pageInfo?.hasNextPage + } + }, + force + ) +} + +export async function listProjectsByExactName( + name: string, + workspaceId: LinearConcreteWorkspaceId, + force = false +): Promise { + const projectName = name.trim() + if (!projectName) { + throw new Error('Project name is required') + } + const normalized = projectName.toLowerCase() + const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) + const key = `listProjectsByExactName:${concreteWorkspaceId}:${normalized}` + return coalesce( + key, + async () => { + const entries = getClients(concreteWorkspaceId) + const entry = entries[0] + if (!entry) { + return [] + } + await acquire() + try { + const matches: LinearProjectSummary[] = [] + let after: string | undefined + while (true) { + const result = await entry.client.client.rawRequest< + ProjectConnectionResponse, + LinearRawVariables + >(SEARCH_PROJECTS_QUERY, { + term: projectName, + first: LINEAR_PROJECT_API_PAGE_SIZE_MAX, + ...(after ? { after } : {}) + }) + const connection = result.data?.searchProjects + for (const project of connection?.nodes ?? []) { + if (project.name.trim().toLowerCase() === normalized) { + matches.push(mapProjectForWorkspace(entry, project)) + } + } + const nextCursor = connection?.pageInfo?.endCursor ?? undefined + if ( + connection?.pageInfo?.hasNextPage !== true || + !nextCursor || + nextCursor === after || + (connection.nodes ?? []).length === 0 + ) { + break + } + after = nextCursor + } + return matches + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.workspace.id) + } + throw error + } finally { + release() + } + }, + force + ) +} + +export async function getProject( + id: string, + workspaceId: LinearConcreteWorkspaceId, + force = false +): Promise { + const projectId = id.trim() + const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) + if (!projectId) { + throw new Error('Project ID is required') + } + const key = `getProject:${concreteWorkspaceId}:${projectId}` + return coalesce( + key, + async () => { + const entries = getClients(concreteWorkspaceId) + const entry = entries[0] + if (!entry) { + return null + } + await acquire() + try { + const result = await entry.client.client.rawRequest< + ProjectConnectionResponse, + LinearRawVariables + >(PROJECT_QUERY, { id: projectId }) + return result.data?.project + ? mapProjectDetailForWorkspace(entry, result.data.project) + : null + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.workspace.id) + } + throw error + } finally { + release() + } + }, + force + ) +} + +export async function createProject( + input: LinearProjectCreateInput, + workspaceId?: string | null +): Promise<{ ok: true; project: LinearProjectDetail } | { ok: false; error: string }> { + const entry = getClients(workspaceId)[0] + if (!entry) { + return { ok: false, error: 'Not connected to Linear' } + } + + await acquire() + try { + const result = await entry.client.client.rawRequest< + ProjectMutationResponse, + LinearRawVariables + >(CREATE_PROJECT_MUTATION, { input }) + const payload = result.data?.projectCreate + const project = payload?.project + if (!payload?.success || !project) { + return { ok: false, error: 'Linear project create failed' } + } + return { ok: true, project: mapProjectDetailForWorkspace(entry, project) } + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.workspace.id) + throw error + } + const message = error instanceof Error ? error.message : String(error) + return { ok: false, error: message } + } finally { + release() + } +} diff --git a/src/main/linear/linear-project-team-queries.ts b/src/main/linear/linear-project-team-queries.ts new file mode 100644 index 00000000000..569dd130220 --- /dev/null +++ b/src/main/linear/linear-project-team-queries.ts @@ -0,0 +1,77 @@ +import type { LinearProjectSummary } from '../../shared/linear/project-types' +import type { LinearConcreteWorkspaceId } from '../../shared/linear/workspace-types' +import { acquire, release } from './linear-request-concurrency' +import { clearToken } from './linear-token-store' +import { getClients, isAuthError } from './client' +import { PROJECT_TEAMS_QUERY } from './linear-project-graphql' +import type { LinearRawVariables, ProjectTeamsResponse } from './linear-project-nodes' +import { coalesce, normalizeConcreteWorkspaceId } from './linear-project-models' + +export async function listProjectTeams( + projectId: string, + workspaceId: LinearConcreteWorkspaceId, + force = false +): Promise> { + const id = projectId.trim() + if (!id) { + throw new Error('Project ID is required') + } + const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) + const key = `listProjectTeams:${concreteWorkspaceId}:${id}` + return coalesce( + key, + async () => { + const entry = getClients(concreteWorkspaceId)[0] + if (!entry) { + return [] + } + const teams: NonNullable = [] + let after: string | undefined + await acquire() + try { + while (true) { + const result = await entry.client.client.rawRequest< + ProjectTeamsResponse, + LinearRawVariables + >(PROJECT_TEAMS_QUERY, { + id, + first: 50, + ...(after ? { after } : {}) + }) + const project = result.data?.project + if (!project) { + throw new Error('Project was not found') + } + const connection = project.teams + const nodes = connection?.nodes ?? [] + teams.push( + ...nodes.map((team) => ({ + id: team.id, + name: team.name ?? '', + key: team.key ?? undefined + })) + ) + const nextCursor = connection?.pageInfo?.endCursor ?? undefined + if ( + !connection?.pageInfo?.hasNextPage || + !nextCursor || + nextCursor === after || + nodes.length === 0 + ) { + break + } + after = nextCursor + } + return teams + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.workspace.id) + } + throw error + } finally { + release() + } + }, + force + ) +} diff --git a/src/main/linear/projects.test.ts b/src/main/linear/projects.test.ts index c8a5dd6ac09..45655495f64 100644 --- a/src/main/linear/projects.test.ts +++ b/src/main/linear/projects.test.ts @@ -388,6 +388,8 @@ describe('Linear project queries', () => { targetDate: '2026-08-01' } }) + expect(rawRequest.mock.calls[0]?.[0]).toContain('mutation OrcaLinearProjectCreate') + expect(rawRequest.mock.calls[0]?.[0]).toContain('projectCreate(input: $input)') }) it('lets manual custom view list refresh bypass older in-flight reads', async () => { diff --git a/src/main/linear/projects.ts b/src/main/linear/projects.ts index 483b1b032a6..4ea2d13c241 100644 --- a/src/main/linear/projects.ts +++ b/src/main/linear/projects.ts @@ -1,1258 +1,15 @@ -/* eslint-disable max-lines -- Why: Linear project and custom-view reads share - raw GraphQL selection sets, workspace fan-out, and partial-failure mapping. */ -import type { LinearIssue } from '../../shared/linear/issue-types' -import type { - LinearCustomViewModel, - LinearCustomViewSummary, - LinearProjectDetail, - LinearProjectMemberSummary, - LinearProjectSummary -} from '../../shared/linear/project-types' -import type { - LinearCollectionResult, - LinearConcreteWorkspaceId, - LinearWorkspaceError, - LinearWorkspaceSelection -} from '../../shared/linear/workspace-types' -import { - LINEAR_ISSUE_API_PAGE_SIZE_MAX, - clampLinearIssueListLimit -} from '../../shared/linear/issue-read-limits' -import { acquire, release } from './linear-request-concurrency' -import { clearToken } from './linear-token-store' -import { getClients, isAuthError, type LinearClientForWorkspace } from './client' - -type LinearRawVariables = Record - -type PageInfoNode = { - hasNextPage?: boolean | null - endCursor?: string | null -} - -type LinearConnection = { - nodes?: T[] | null - pageInfo?: PageInfoNode | null -} - -type LinearUserNode = { - id: string - displayName?: string | null - avatarUrl?: string | null -} - -type LinearProjectNode = { - id: string - slugId?: string | null - name: string - description?: string | null - content?: string | null - url?: string | null - color?: string | null - icon?: string | null - health?: string | null - priority?: number | null - priorityLabel?: string | null - progress?: number | null - scope?: number | null - issueCountHistory?: number[] | null - completedIssueCountHistory?: number[] | null - startDate?: string | null - targetDate?: string | null - createdAt?: string | null - updatedAt?: string | null - completedAt?: string | null - canceledAt?: string | null - startedAt?: string | null - status?: { - id: string - name?: string | null - type?: string | null - color?: string | null - } | null - lead?: LinearUserNode | null - members?: LinearConnection | null - teams?: LinearConnection<{ id: string; name?: string | null; key?: string | null }> | null - labels?: LinearConnection<{ id: string; name?: string | null; color?: string | null }> | null - projectMilestones?: LinearConnection<{ - id: string - name?: string | null - status?: string | null - targetDate?: string | null - progress?: number | null - }> | null - externalLinks?: LinearConnection<{ - id: string - label?: string | null - url?: string | null - }> | null - lastUpdate?: { - id: string - body?: string | null - health?: string | null - url?: string | null - createdAt?: string | null - updatedAt?: string | null - user?: LinearUserNode | null - } | null -} - -type LinearIssueNode = { - id: string - identifier: string - title: string - description?: string | null - url: string - estimate?: number | null - priority: number - updatedAt: string - labelIds?: string[] | null - state?: { - name?: string | null - type?: string | null - color?: string | null - } | null - team?: { - id?: string | null - name?: string | null - key?: string | null - } | null - assignee?: LinearUserNode | null - labels?: LinearConnection<{ id: string; name: string }> | null -} - -type LinearCustomViewNode = { - id: string - name: string - description?: string | null - modelName?: string | null - color?: string | null - icon?: string | null - shared?: boolean | null - slugId?: string | null - createdAt?: string | null - updatedAt?: string | null - team?: { id: string; name?: string | null; key?: string | null } | null - owner?: LinearUserNode | null - creator?: LinearUserNode | null -} - -type ProjectConnectionResponse = { - projects?: LinearConnection | null - searchProjects?: LinearConnection | null - project?: LinearProjectNode | null -} - -type ProjectIssueConnectionResponse = { - project?: { - issues?: LinearConnection | null - } | null -} - -type ProjectTeamsResponse = { - project?: { - teams?: LinearConnection<{ id: string; name?: string | null; key?: string | null }> | null - } | null -} - -type CustomViewConnectionResponse = { - customViews?: LinearConnection | null - customView?: - | (LinearCustomViewNode & { - issues?: LinearConnection | null - projects?: LinearConnection | null - }) - | null -} - -type ProjectMutationResponse = { - projectCreate?: { - success?: boolean | null - project?: LinearProjectNode | null - } | null -} - -export type LinearProjectCreateInput = { - name: string - description?: string - content?: string - teamIds: string[] - leadId?: string - memberIds?: string[] - labelIds?: string[] - priority?: number - startDate?: string - targetDate?: string -} - -const ORCA_PROJECT_FIELDS = ` - id - slugId - name - description - content - url - color - icon - health - priority - priorityLabel - progress - scope - issueCountHistory - completedIssueCountHistory - startDate - targetDate - createdAt - updatedAt - completedAt - canceledAt - startedAt - status { - id - name - type - color - } - lead { - id - displayName - avatarUrl - } - members(first: 10) { - nodes { - id - displayName - avatarUrl - } - } - teams(first: 10) { - nodes { - id - name - key - } - } - labels(first: 20) { - nodes { - id - name - color - } - } -` - -const ORCA_PROJECT_DETAIL_FIELDS = ` - ${ORCA_PROJECT_FIELDS} - projectMilestones(first: 20) { - nodes { - id - name - status - targetDate - progress - } - } - externalLinks(first: 20) { - nodes { - id - label - url - } - } - lastUpdate { - id - body - health - url - createdAt - updatedAt - user { - id - displayName - avatarUrl - } - } -` - -const ORCA_ISSUE_FIELDS = ` - id - identifier - title - description - url - priority - estimate - updatedAt - labelIds - state { - name - type - color - } - team { - id - name - key - } - assignee { - id - displayName - avatarUrl - } - labels(first: 50) { - nodes { - id - name - } - } -` - -const PROJECTS_QUERY = ` - query OrcaLinearProjects($first: Int, $filter: ProjectFilter, $orderBy: PaginationOrderBy) { - projects(first: $first, filter: $filter, orderBy: $orderBy) { - nodes { - ${ORCA_PROJECT_FIELDS} - } - pageInfo { - hasNextPage - } - } - } -` - -const SEARCH_PROJECTS_QUERY = ` - query OrcaLinearProjectSearch($term: String!, $first: Int, $after: String) { - searchProjects(term: $term, first: $first, after: $after) { - nodes { - ${ORCA_PROJECT_FIELDS} - } - pageInfo { - hasNextPage - endCursor - } - } - } -` - -const PROJECT_QUERY = ` - query OrcaLinearProject($id: String!) { - project(id: $id) { - ${ORCA_PROJECT_DETAIL_FIELDS} - } - } -` - -const CREATE_PROJECT_MUTATION = ` - mutation OrcaLinearProjectCreate($input: ProjectCreateInput!) { - projectCreate(input: $input) { - success - project { - ${ORCA_PROJECT_DETAIL_FIELDS} - } - } - } -` - -const PROJECT_ISSUES_QUERY = ` - query OrcaLinearProjectIssues( - $id: String!, - $first: Int, - $after: String, - $orderBy: PaginationOrderBy - ) { - project(id: $id) { - issues(first: $first, after: $after, orderBy: $orderBy) { - nodes { - ${ORCA_ISSUE_FIELDS} - } - pageInfo { - hasNextPage - endCursor - } - } - } - } -` - -const PROJECT_TEAMS_QUERY = ` - query OrcaLinearProjectTeams($id: String!, $first: Int, $after: String) { - project(id: $id) { - teams(first: $first, after: $after) { - nodes { - id - name - key - } - pageInfo { - hasNextPage - endCursor - } - } - } - } -` - -const CUSTOM_VIEWS_QUERY = ` - query OrcaLinearCustomViews( - $first: Int, - $filter: CustomViewFilter, - $orderBy: PaginationOrderBy - ) { - customViews(first: $first, filter: $filter, orderBy: $orderBy) { - nodes { - id - name - description - modelName - color - icon - shared - slugId - createdAt - updatedAt - team { - id - name - key - } - owner { - id - displayName - avatarUrl - } - creator { - id - displayName - avatarUrl - } - } - pageInfo { - hasNextPage - } - } - } -` - -const CUSTOM_VIEW_QUERY = ` - query OrcaLinearCustomView($id: String!) { - customView(id: $id) { - id - name - description - modelName - color - icon - shared - slugId - createdAt - updatedAt - team { - id - name - key - } - owner { - id - displayName - avatarUrl - } - creator { - id - displayName - avatarUrl - } - } - } -` - -const CUSTOM_VIEW_ISSUES_QUERY = ` - query OrcaLinearCustomViewIssues( - $id: String!, - $first: Int, - $after: String, - $orderBy: PaginationOrderBy - ) { - customView(id: $id) { - id - modelName - issues(first: $first, after: $after, orderBy: $orderBy) { - nodes { - ${ORCA_ISSUE_FIELDS} - } - pageInfo { - hasNextPage - endCursor - } - } - } - } -` - -const CUSTOM_VIEW_PROJECTS_QUERY = ` - query OrcaLinearCustomViewProjects($id: String!, $first: Int, $orderBy: PaginationOrderBy) { - customView(id: $id) { - id - modelName - projects(first: $first, orderBy: $orderBy) { - nodes { - ${ORCA_PROJECT_FIELDS} - } - pageInfo { - hasNextPage - } - } - } - } -` - -const inFlight = new Map>() -const LINEAR_PROJECT_API_PAGE_SIZE_MAX = 50 - -function clampLimit(limit = 20): number { - return Math.min(Math.max(1, Math.floor(limit)), LINEAR_PROJECT_API_PAGE_SIZE_MAX) -} - -function coalesce(key: string, load: () => Promise, force = false): Promise { - const existing = inFlight.get(key) as Promise | undefined - if (existing && !force) { - return existing - } - const promise = load().finally(() => { - if (inFlight.get(key) === promise) { - inFlight.delete(key) - } - }) - inFlight.set(key, promise) - return promise -} - -function normalizeConcreteWorkspaceId(workspaceId: unknown): LinearConcreteWorkspaceId { - if (typeof workspaceId !== 'string' || !workspaceId.trim() || workspaceId === 'all') { - throw new Error('Concrete Linear workspace ID is required') - } - return workspaceId.trim() -} - -function workspaceError(entry: LinearClientForWorkspace, error: unknown): LinearWorkspaceError { - if (isAuthError(error)) { - return { - workspaceId: entry.workspace.id, - workspaceName: entry.workspace.organizationName, - type: 'auth', - message: 'Linear authentication expired for this workspace.' - } - } - - const record = error as { name?: string; message?: string; status?: number; response?: unknown } - const message = record.message || 'Linear request failed.' - const status = - typeof record.status === 'number' - ? record.status - : typeof (record.response as { status?: unknown } | undefined)?.status === 'number' - ? ((record.response as { status: number }).status as number) - : undefined - const name = record.name ?? '' - - if (status === 429 || /rate/i.test(name)) { - return { - workspaceId: entry.workspace.id, - workspaceName: entry.workspace.organizationName, - type: 'rate_limited', - message - } - } - if ((typeof status === 'number' && status >= 500) || /network/i.test(name)) { - return { - workspaceId: entry.workspace.id, - workspaceName: entry.workspace.organizationName, - type: 'network', - message - } - } - - return { - workspaceId: entry.workspace.id, - workspaceName: entry.workspace.organizationName, - type: 'unknown', - message - } -} - -function shouldFailWholeRequest(selection: LinearWorkspaceSelection | null | undefined): boolean { - return selection !== 'all' -} - -function lastNumericValue(values?: number[] | null): number | undefined { - const last = values?.at(-1) - return typeof last === 'number' ? last : undefined -} - -function mapUser(user?: LinearUserNode | null): LinearProjectMemberSummary | undefined { - if (!user?.id) { - return undefined - } - return { - id: user.id, - displayName: user.displayName ?? '', - avatarUrl: user.avatarUrl ?? undefined - } -} - -function mapProjectForWorkspace( - entry: LinearClientForWorkspace, - project: LinearProjectNode -): LinearProjectSummary { - return { - id: project.id, - slugId: project.slugId ?? undefined, - workspaceId: entry.workspace.id, - workspaceName: entry.workspace.organizationName, - name: project.name, - url: project.url ?? undefined, - color: project.color ?? undefined, - icon: project.icon ?? undefined, - description: project.description ?? undefined, - content: project.content ?? undefined, - status: project.status - ? { - id: project.status.id, - name: project.status.name ?? '', - type: project.status.type ?? undefined, - color: project.status.color ?? undefined - } - : undefined, - health: project.health ?? null, - priority: project.priority ?? null, - priorityLabel: project.priorityLabel ?? null, - lead: mapUser(project.lead), - members: project.members?.nodes - ?.map(mapUser) - .filter((user): user is LinearProjectMemberSummary => !!user), - teams: project.teams?.nodes?.map((team) => ({ - id: team.id, - name: team.name ?? '', - key: team.key ?? undefined - })), - labels: project.labels?.nodes?.map((label) => ({ - id: label.id, - name: label.name ?? '', - color: label.color ?? undefined - })), - startDate: project.startDate ?? null, - targetDate: project.targetDate ?? null, - createdAt: project.createdAt ?? undefined, - updatedAt: project.updatedAt ?? undefined, - completedAt: project.completedAt ?? null, - canceledAt: project.canceledAt ?? null, - startedAt: project.startedAt ?? null, - progress: project.progress ?? null, - scope: project.scope ?? null, - issueCount: lastNumericValue(project.issueCountHistory), - completedIssueCount: lastNumericValue(project.completedIssueCountHistory) - } -} - -function mapProjectDetailForWorkspace( - entry: LinearClientForWorkspace, - project: LinearProjectNode -): LinearProjectDetail { - return { - ...mapProjectForWorkspace(entry, project), - milestones: project.projectMilestones?.nodes?.map((milestone) => ({ - id: milestone.id, - name: milestone.name ?? '', - status: milestone.status ?? undefined, - targetDate: milestone.targetDate ?? null, - progress: milestone.progress ?? null - })), - resources: project.externalLinks?.nodes - ?.filter((link) => link.url) - .map((link) => ({ - id: link.id, - title: link.label || link.url || 'Link', - url: link.url!, - type: 'link' - })), - latestUpdate: project.lastUpdate - ? { - id: project.lastUpdate.id, - body: project.lastUpdate.body ?? undefined, - health: project.lastUpdate.health ?? null, - url: project.lastUpdate.url ?? undefined, - createdAt: project.lastUpdate.createdAt ?? undefined, - updatedAt: project.lastUpdate.updatedAt ?? undefined, - user: mapUser(project.lastUpdate.user) - } - : undefined - } -} - -function mapIssueForWorkspace( - entry: LinearClientForWorkspace, - issue: LinearIssueNode -): LinearIssue { - const labelNodes = issue.labels?.nodes ?? [] - return { - id: issue.id, - identifier: issue.identifier, - title: issue.title, - description: issue.description ?? undefined, - url: issue.url, - state: { - name: issue.state?.name ?? '', - type: issue.state?.type ?? '', - color: issue.state?.color ?? '' - }, - team: { - id: issue.team?.id ?? '', - name: issue.team?.name ?? '', - key: issue.team?.key ?? '' - }, - labels: labelNodes.map((label) => label.name), - labelIds: issue.labelIds ?? labelNodes.map((label) => label.id), - assignee: mapUser(issue.assignee), - estimate: issue.estimate ?? null, - priority: issue.priority, - updatedAt: issue.updatedAt, - workspaceId: entry.workspace.id, - workspaceName: entry.workspace.organizationName - } -} - -function mapCustomViewModel(modelName?: string | null): LinearCustomViewModel | null { - const normalized = modelName?.toLowerCase() - if (normalized === 'issue') { - return 'issue' - } - if (normalized === 'project') { - return 'project' - } - return null -} - -function mapCustomViewForWorkspace( - entry: LinearClientForWorkspace, - view: LinearCustomViewNode -): LinearCustomViewSummary | null { - const model = mapCustomViewModel(view.modelName) - if (!model) { - return null - } - return { - id: view.id, - workspaceId: entry.workspace.id, - workspaceName: entry.workspace.organizationName, - name: view.name, - description: view.description ?? undefined, - model, - url: view.slugId - ? `https://linear.app/${entry.workspace.organizationUrlKey}/view/${view.slugId}` - : undefined, - color: view.color ?? undefined, - icon: view.icon ?? undefined, - shared: view.shared ?? undefined, - team: view.team - ? { - id: view.team.id, - name: view.team.name ?? undefined, - key: view.team.key ?? undefined - } - : undefined, - owner: mapUser(view.owner), - creator: mapUser(view.creator), - createdAt: view.createdAt ?? undefined, - updatedAt: view.updatedAt ?? undefined - } -} - -async function readIssueConnectionPages( - entry: LinearClientForWorkspace, - limit: number, - loadConnection: (variables: { - first: number - after?: string - }) => Promise | null | undefined> -): Promise> { - const items: LinearIssue[] = [] - let after: string | undefined - let hasMore = false - - while (items.length < limit) { - // Why: Linear returns issue connections in pages of up to 50; expanded - // Orca reads must follow cursors to show more than one backend page. - const first = Math.min(LINEAR_ISSUE_API_PAGE_SIZE_MAX, limit - items.length) - const connection = await loadConnection(after ? { first, after } : { first }) - const nodes = connection?.nodes ?? [] - items.push(...nodes.map((issue) => mapIssueForWorkspace(entry, issue))) - hasMore = Boolean(connection?.pageInfo?.hasNextPage) - - const nextCursor = connection?.pageInfo?.endCursor ?? undefined - if (!hasMore || !nextCursor || nextCursor === after || nodes.length === 0) { - break - } - after = nextCursor - } - - return { items, hasMore } -} - -async function readCollection( - key: string, - workspaceId: LinearWorkspaceSelection | null | undefined, - load: (entry: LinearClientForWorkspace) => Promise>, - force = false -): Promise> { - return coalesce( - key, - async () => { - const entries = getClients(workspaceId) - if (entries.length === 0) { - return { items: [] } - } - - const results = await Promise.all( - entries.map(async (entry) => { - await acquire() - try { - return await load(entry) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.workspace.id) - } else { - console.warn('[linear] project/view read failed:', error) - } - if (shouldFailWholeRequest(workspaceId)) { - throw error - } - return { items: [], errors: [workspaceError(entry, error)] } - } finally { - release() - } - }) - ) - - return { - items: results.flatMap((result) => result.items), - errors: results.flatMap((result) => result.errors ?? []).length - ? results.flatMap((result) => result.errors ?? []) - : undefined, - hasMore: results.some((result) => result.hasMore) - } - }, - force - ) -} - -async function readConcreteCollection( - key: string, - workspaceId: LinearConcreteWorkspaceId, - load: (entry: LinearClientForWorkspace) => Promise>, - force = false -): Promise> { - const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) - return readCollection(key, concreteWorkspaceId, load, force) -} - -export async function listProjects( - query: string | undefined, - limit = 20, - workspaceId?: LinearWorkspaceSelection | null, - force = false -): Promise> { - const first = clampLimit(limit) - const trimmed = query?.trim() - const key = `listProjects:${workspaceId ?? 'default'}:${trimmed ?? ''}:${first}` - return readCollection( - key, - workspaceId, - async (entry) => { - const variables = trimmed ? { term: trimmed, first } : { first, orderBy: 'updatedAt' } - const result = await entry.client.client.rawRequest< - ProjectConnectionResponse, - LinearRawVariables - >(trimmed ? SEARCH_PROJECTS_QUERY : PROJECTS_QUERY, variables) - const connection = trimmed ? result.data?.searchProjects : result.data?.projects - return { - items: (connection?.nodes ?? []).map((project) => mapProjectForWorkspace(entry, project)), - hasMore: !!connection?.pageInfo?.hasNextPage - } - }, - force - ) -} - -export async function listProjectsByExactName( - name: string, - workspaceId: LinearConcreteWorkspaceId, - force = false -): Promise { - const projectName = name.trim() - if (!projectName) { - throw new Error('Project name is required') - } - const normalized = projectName.toLowerCase() - const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) - const key = `listProjectsByExactName:${concreteWorkspaceId}:${normalized}` - return coalesce( - key, - async () => { - const entries = getClients(concreteWorkspaceId) - const entry = entries[0] - if (!entry) { - return [] - } - await acquire() - try { - const matches: LinearProjectSummary[] = [] - let after: string | undefined - while (true) { - const result = await entry.client.client.rawRequest< - ProjectConnectionResponse, - LinearRawVariables - >(SEARCH_PROJECTS_QUERY, { - term: projectName, - first: LINEAR_PROJECT_API_PAGE_SIZE_MAX, - ...(after ? { after } : {}) - }) - const connection = result.data?.searchProjects - for (const project of connection?.nodes ?? []) { - if (project.name.trim().toLowerCase() === normalized) { - matches.push(mapProjectForWorkspace(entry, project)) - } - } - const nextCursor = connection?.pageInfo?.endCursor ?? undefined - if ( - connection?.pageInfo?.hasNextPage !== true || - !nextCursor || - nextCursor === after || - (connection.nodes ?? []).length === 0 - ) { - break - } - after = nextCursor - } - return matches - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.workspace.id) - } - throw error - } finally { - release() - } - }, - force - ) -} - -export async function getProject( - id: string, - workspaceId: LinearConcreteWorkspaceId, - force = false -): Promise { - const projectId = id.trim() - const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) - if (!projectId) { - throw new Error('Project ID is required') - } - const key = `getProject:${concreteWorkspaceId}:${projectId}` - return coalesce( - key, - async () => { - const entries = getClients(concreteWorkspaceId) - const entry = entries[0] - if (!entry) { - return null - } - await acquire() - try { - const result = await entry.client.client.rawRequest< - ProjectConnectionResponse, - LinearRawVariables - >(PROJECT_QUERY, { id: projectId }) - return result.data?.project - ? mapProjectDetailForWorkspace(entry, result.data.project) - : null - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.workspace.id) - } - throw error - } finally { - release() - } - }, - force - ) -} - -export async function createProject( - input: LinearProjectCreateInput, - workspaceId?: string | null -): Promise<{ ok: true; project: LinearProjectDetail } | { ok: false; error: string }> { - const entry = getClients(workspaceId)[0] - if (!entry) { - return { ok: false, error: 'Not connected to Linear' } - } - - await acquire() - try { - const result = await entry.client.client.rawRequest< - ProjectMutationResponse, - LinearRawVariables - >(CREATE_PROJECT_MUTATION, { input }) - const payload = result.data?.projectCreate - const project = payload?.project - if (!payload?.success || !project) { - return { ok: false, error: 'Linear project create failed' } - } - return { ok: true, project: mapProjectDetailForWorkspace(entry, project) } - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.workspace.id) - throw error - } - const message = error instanceof Error ? error.message : String(error) - return { ok: false, error: message } - } finally { - release() - } -} - -export async function listProjectIssues( - projectId: string, - limit = 20, - workspaceId: LinearConcreteWorkspaceId, - force = false -): Promise> { - const id = projectId.trim() - if (!id) { - throw new Error('Project ID is required') - } - const first = clampLinearIssueListLimit(limit) - const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) - return readConcreteCollection( - `listProjectIssues:${concreteWorkspaceId}:${id}:${first}`, - concreteWorkspaceId, - async (entry) => { - return readIssueConnectionPages(entry, first, async (page) => { - const result = await entry.client.client.rawRequest< - ProjectIssueConnectionResponse, - LinearRawVariables - >(PROJECT_ISSUES_QUERY, { id, ...page, orderBy: 'updatedAt' }) - const project = result.data?.project - if (!project) { - throw new Error('Project was not found') - } - return project.issues - }) - }, - force - ) -} - -export async function listProjectTeams( - projectId: string, - workspaceId: LinearConcreteWorkspaceId, - force = false -): Promise> { - const id = projectId.trim() - if (!id) { - throw new Error('Project ID is required') - } - const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) - const key = `listProjectTeams:${concreteWorkspaceId}:${id}` - return coalesce( - key, - async () => { - const entry = getClients(concreteWorkspaceId)[0] - if (!entry) { - return [] - } - const teams: NonNullable = [] - let after: string | undefined - await acquire() - try { - while (true) { - const result = await entry.client.client.rawRequest< - ProjectTeamsResponse, - LinearRawVariables - >(PROJECT_TEAMS_QUERY, { - id, - first: 50, - ...(after ? { after } : {}) - }) - const project = result.data?.project - if (!project) { - throw new Error('Project was not found') - } - const connection = project.teams - const nodes = connection?.nodes ?? [] - teams.push( - ...nodes.map((team) => ({ - id: team.id, - name: team.name ?? '', - key: team.key ?? undefined - })) - ) - const nextCursor = connection?.pageInfo?.endCursor ?? undefined - if ( - !connection?.pageInfo?.hasNextPage || - !nextCursor || - nextCursor === after || - nodes.length === 0 - ) { - break - } - after = nextCursor - } - return teams - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.workspace.id) - } - throw error - } finally { - release() - } - }, - force - ) -} - -export async function listCustomViews( - model: LinearCustomViewModel, - limit = 20, - workspaceId?: LinearWorkspaceSelection | null, - force = false -): Promise> { - const first = clampLimit(limit) - const key = `listCustomViews:${workspaceId ?? 'default'}:${model}:${first}` - const filter = { modelName: { eq: model === 'project' ? 'Project' : 'Issue' } } - return readCollection( - key, - workspaceId, - async (entry) => { - const result = await entry.client.client.rawRequest< - CustomViewConnectionResponse, - LinearRawVariables - >(CUSTOM_VIEWS_QUERY, { first, filter, orderBy: 'updatedAt' }) - const connection = result.data?.customViews - return { - items: (connection?.nodes ?? []) - .map((view) => mapCustomViewForWorkspace(entry, view)) - .filter((view): view is LinearCustomViewSummary => !!view && view.model === model), - hasMore: !!connection?.pageInfo?.hasNextPage - } - }, - force - ) -} - -export async function getCustomView( - viewId: string, - model: LinearCustomViewModel, - workspaceId: LinearConcreteWorkspaceId, - force = false -): Promise { - const id = viewId.trim() - const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) - if (!id) { - throw new Error('Custom view ID is required') - } - const key = `getCustomView:${concreteWorkspaceId}:${model}:${id}` - return coalesce( - key, - async () => { - const entries = getClients(concreteWorkspaceId) - const entry = entries[0] - if (!entry) { - return null - } - await acquire() - try { - const result = await entry.client.client.rawRequest< - CustomViewConnectionResponse, - LinearRawVariables - >(CUSTOM_VIEW_QUERY, { id }) - const view = result.data?.customView - const mapped = view ? mapCustomViewForWorkspace(entry, view) : null - return mapped?.model === model ? mapped : null - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.workspace.id) - } - throw error - } finally { - release() - } - }, - force - ) -} - -export async function listCustomViewIssues( - viewId: string, - limit = 20, - workspaceId: LinearConcreteWorkspaceId, - force = false -): Promise> { - const id = viewId.trim() - if (!id) { - throw new Error('Custom view ID is required') - } - const first = clampLinearIssueListLimit(limit) - const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) - return readConcreteCollection( - `listCustomViewIssues:${concreteWorkspaceId}:${id}:${first}`, - concreteWorkspaceId, - async (entry) => { - return readIssueConnectionPages(entry, first, async (page) => { - const result = await entry.client.client.rawRequest< - CustomViewConnectionResponse, - LinearRawVariables - >(CUSTOM_VIEW_ISSUES_QUERY, { id, ...page, orderBy: 'updatedAt' }) - const view = result.data?.customView - if (mapCustomViewModel(view?.modelName) !== 'issue') { - throw new Error('Custom view does not contain issues') - } - return view?.issues - }) - }, - force - ) -} - -export async function listCustomViewProjects( - viewId: string, - limit = 20, - workspaceId: LinearConcreteWorkspaceId, - force = false -): Promise> { - const id = viewId.trim() - if (!id) { - throw new Error('Custom view ID is required') - } - const first = clampLimit(limit) - const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) - return readConcreteCollection( - `listCustomViewProjects:${concreteWorkspaceId}:${id}:${first}`, - concreteWorkspaceId, - async (entry) => { - const result = await entry.client.client.rawRequest< - CustomViewConnectionResponse, - LinearRawVariables - >(CUSTOM_VIEW_PROJECTS_QUERY, { id, first, orderBy: 'updatedAt' }) - const view = result.data?.customView - if (mapCustomViewModel(view?.modelName) !== 'project') { - throw new Error('Custom view does not contain projects') - } - const connection = view?.projects - return { - items: (connection?.nodes ?? []).map((project) => mapProjectForWorkspace(entry, project)), - hasMore: !!connection?.pageInfo?.hasNextPage - } - }, - force - ) -} +export type { LinearProjectCreateInput } from './linear-project-nodes' +export { + createProject, + getProject, + listProjects, + listProjectsByExactName +} from './linear-project-queries' +export { listProjectIssues } from './linear-project-issue-queries' +export { listProjectTeams } from './linear-project-team-queries' +export { + getCustomView, + listCustomViewIssues, + listCustomViewProjects, + listCustomViews +} from './linear-custom-view-queries' diff --git a/src/main/orca-profiles/profile-project-session-transfer.ts b/src/main/orca-profiles/profile-project-session-transfer.ts index d5884096bd9..da55794b551 100644 --- a/src/main/orca-profiles/profile-project-session-transfer.ts +++ b/src/main/orca-profiles/profile-project-session-transfer.ts @@ -39,6 +39,7 @@ function hasTransferredSessionState(session: WorkspaceSessionState): boolean { Object.keys(session.browserTabsByWorktree ?? {}).length > 0 || Object.keys(session.unifiedTabs ?? {}).length > 0 || Object.keys(session.tabGroups ?? {}).length > 0 || + Object.keys(session.lastVisitedAtByWorktreeId ?? {}).length > 0 || Object.keys(session.terminalTopologyRevisionByRepoId ?? {}).length > 0 ) } diff --git a/src/main/orca-profiles/profile-project-transfer-worktree-ids.ts b/src/main/orca-profiles/profile-project-transfer-worktree-ids.ts index 51d87cfc668..485861d748d 100644 --- a/src/main/orca-profiles/profile-project-transfer-worktree-ids.ts +++ b/src/main/orca-profiles/profile-project-transfer-worktree-ids.ts @@ -2,6 +2,10 @@ import type { PersistedState } from '../../shared/persisted-state-types' import { parseWorkspaceKey } from '../../shared/workspace-scope' import type { TransferProfileState } from './profile-project-state-file' import { isRepoWorktreeId } from './profile-project-worktree-identity' +import { + getWorktreeIdFromHostIdentity, + isWorktreeHostIdentity +} from '../../shared/worktree/host-qualified-identity' export function collectTransferWorktreeIds( state: TransferProfileState, @@ -51,8 +55,9 @@ function collectSessionWorktreeIds( } const addOwnerKeys = (record: Record | undefined): void => { for (const key of Object.keys(record ?? {})) { - if (isRepoWorktreeId(repoId, key)) { - ids.add(key) + const rawKey = isWorktreeHostIdentity(key) ? getWorktreeIdFromHostIdentity(key) : key + if (isRepoWorktreeId(repoId, rawKey)) { + ids.add(rawKey) } const parsed = parseWorkspaceKey(key) if (parsed?.type === 'worktree' && isRepoWorktreeId(repoId, parsed.worktreeId)) { diff --git a/src/main/orca-profiles/profile-project-worktree-identity.ts b/src/main/orca-profiles/profile-project-worktree-identity.ts index cdc8d9182d2..1586a0e0120 100644 --- a/src/main/orca-profiles/profile-project-worktree-identity.ts +++ b/src/main/orca-profiles/profile-project-worktree-identity.ts @@ -4,6 +4,10 @@ import type { WorkspaceKey } from '../../shared/folder-workspace-types' import type { Repo } from '../../shared/repo-types' import { parseWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope' import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree/id' +import { + getWorktreeIdFromHostIdentity, + isWorktreeHostIdentity +} from '../../shared/worktree/host-qualified-identity' export function repoPhysicalKey( repo: Pick @@ -46,8 +50,14 @@ export function rekeyOwnerKey( newRepoId: string, ownerKey: string ): string | null { - if (isRepoWorktreeId(oldRepoId, ownerKey)) { - return rekeyWorktreeId(oldRepoId, newRepoId, ownerKey) + const rawOwnerKey = isWorktreeHostIdentity(ownerKey) + ? getWorktreeIdFromHostIdentity(ownerKey) + : ownerKey + if (isRepoWorktreeId(oldRepoId, rawOwnerKey)) { + const rekeyed = rekeyWorktreeId(oldRepoId, newRepoId, rawOwnerKey) + return isWorktreeHostIdentity(ownerKey) + ? `${ownerKey.slice(0, ownerKey.length - rawOwnerKey.length)}${rekeyed}` + : rekeyed } const parsed = parseWorkspaceKey(ownerKey) if (parsed?.type === 'worktree' && isRepoWorktreeId(oldRepoId, parsed.worktreeId)) { @@ -57,7 +67,10 @@ export function rekeyOwnerKey( } export function ownerKeyBelongsToRepo(ownerKey: string, repoId: string): boolean { - if (isRepoWorktreeId(repoId, ownerKey)) { + const rawOwnerKey = isWorktreeHostIdentity(ownerKey) + ? getWorktreeIdFromHostIdentity(ownerKey) + : ownerKey + if (isRepoWorktreeId(repoId, rawOwnerKey)) { return true } const parsed = parseWorkspaceKey(ownerKey) diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index bab68282e53..94a7f4267da 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -2,12 +2,14 @@ * `orcad` — the Orca runtime served from plain Node, with no Electron. * * Installs the Node host adapters, constructs the same `OrcaRuntimeService` the - * desktop uses, installs a PTY controller via `registerPtyHandlers(null, …)`, and + * desktop uses, installs a PTY controller via `registerHeadlessPtyRuntime`, and * serves runtime RPC. See docs/design/node-only-runtime-backend.html. * * The desktop-only surfaces are deliberately left uninstalled: no notifications, no - * renderer window, no browser panes. Those are declared rather than faked — see - * `runtime-desktop-surface.ts` and `pty-host-bindings.ts`. + * renderer window, no browser panes. Most are declared rather than faked — see + * `runtime-desktop-surface.ts` and `pty-host-bindings.ts`. The renderer window is the + * exception: `registerPtyHandlers` takes a non-null `BrowserWindow`, so the headless + * path still fakes one that reports itself destroyed. */ import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' @@ -144,7 +146,7 @@ export async function startOrcad(options: OrcadOptions = {}): Promise 'blocked' }) - // Why the headless entry point rather than registerPtyHandlers(null, …): this is the + // Why the headless entry point rather than registerPtyHandlers directly: this is the // same call `--serve` makes, and it threads the store through. Without the store the // handlers install fine and every terminal.create then fails at persistence time. // diff --git a/src/main/persistence-cohort-and-identity-migration.test.ts b/src/main/persistence-cohort-and-identity-migration.test.ts index 9a8edc4a964..9d931a1bbd4 100644 --- a/src/main/persistence-cohort-and-identity-migration.test.ts +++ b/src/main/persistence-cohort-and-identity-migration.test.ts @@ -387,6 +387,19 @@ describe('Store.migrateWorktreeIdentity', () => { expect(store.getWorktreeMeta(NEWER)?.priorWorktreeIds).toEqual([OLD, NEW]) }) + it('keeps the newest visit timestamp when a partial migration left both identities', async () => { + const store = await createStore() + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + lastVisitedAtByWorktreeId: { [OLD]: 100, [NEW]: 900 } + }) + + store.migrateWorktreeIdentity(OLD, NEW) + + expect(store.getWorkspaceSession().lastVisitedAtByWorktreeId?.[OLD]).toBeUndefined() + expect(store.getWorkspaceSession().lastVisitedAtByWorktreeId?.[NEW]).toBe(900) + }) + it('is a no-op when the ids match', async () => { const store = await createStore() store.setWorktreeMeta(OLD, { displayName: 'Cunner' }) diff --git a/src/main/persistence-repo-lifecycle.test.ts b/src/main/persistence-repo-lifecycle.test.ts index 3266e92bb7f..e27287c147e 100644 --- a/src/main/persistence-repo-lifecycle.test.ts +++ b/src/main/persistence-repo-lifecycle.test.ts @@ -467,6 +467,32 @@ describe('Store', () => { ).toBe(222) }) + it('removeProjectForHost on local prunes the unqualified host-identity visit key', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'shared', path: '/repo' })) + store.addRepo( + makeRepo({ + id: 'shared', + path: '/repo', + connectionId: 'ssh-a', + executionHostId: 'ssh:ssh-a' + }) + ) + // No meta for this worktree, so only the unqualified host-identity key names it. The host split + // parks that canonical unknown-host form in the local partition; leaving it behind after a local + // removal re-materializes an orphaned workspace next launch. + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + lastVisitedAtByWorktreeId: { '|shared::/repo/wt-orphan': 111 } + }) + + store.removeProjectForHost('shared', 'local') + + expect( + store.getWorkspaceSession().lastVisitedAtByWorktreeId?.['|shared::/repo/wt-orphan'] + ).toBeUndefined() + }) + it('removeProjectForHost prunes only the removed host when a third host also shares the owner key', async () => { const store = await createStore() // Same repo id + path on local and two SSH hosts, so the owner key diff --git a/src/main/persistence/restoring-sessions/session-owner-fields.ts b/src/main/persistence/restoring-sessions/session-owner-fields.ts index ee06ada964b..c18d50ae612 100644 --- a/src/main/persistence/restoring-sessions/session-owner-fields.ts +++ b/src/main/persistence/restoring-sessions/session-owner-fields.ts @@ -1,6 +1,10 @@ import type { TerminalTab } from '../../../shared/terminal-tab-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' +import { + getWorktreeIdFromHostIdentity, + isWorktreeHostIdentity +} from '../../../shared/worktree/host-qualified-identity' export function createMinimalPersistedTerminalTab(args: { worktreeId: string @@ -93,7 +97,12 @@ export function deleteOwnerKeyedSessionFields( delete next.activeGroupIdByWorktree[ownerKey] } if (next.lastVisitedAtByWorktreeId) { - delete next.lastVisitedAtByWorktreeId[ownerKey] + for (const key of Object.keys(next.lastVisitedAtByWorktreeId)) { + const rawId = isWorktreeHostIdentity(key) ? getWorktreeIdFromHostIdentity(key) : key + if (key === ownerKey || rawId === ownerKey) { + delete next.lastVisitedAtByWorktreeId[key] + } + } } if (next.defaultTerminalTabsAppliedByWorktreeId) { delete next.defaultTerminalTabsAppliedByWorktreeId[ownerKey] diff --git a/src/main/persistence/tracking-repos/repo-worktree-pruning.ts b/src/main/persistence/tracking-repos/repo-worktree-pruning.ts index f12da044c15..50c4fe5f745 100644 --- a/src/main/persistence/tracking-repos/repo-worktree-pruning.ts +++ b/src/main/persistence/tracking-repos/repo-worktree-pruning.ts @@ -3,6 +3,10 @@ import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/e import { parseWorkspaceKey } from '../../../shared/workspace-scope' import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import { removeWorkspaceSessionOwners } from '../restoring-sessions/session-owner-removal' +import { + getWorktreeIdFromHostIdentity, + isWorktreeHostIdentity +} from '../../../shared/worktree/host-qualified-identity' export function pruneWorktreeStateForRepo( state: StoreOwnedPersistedState, @@ -36,7 +40,18 @@ export function pruneWorktreeStateForRepo( const ownerKeysToPrune = new Set() const collectPrefixedKeys = (keys: Iterable): void => { for (const key of keys) { - if (key.startsWith(prefix)) { + const rawKey = isWorktreeHostIdentity(key) ? getWorktreeIdFromHostIdentity(key) : key + const keyHost = isWorktreeHostIdentity(key) ? key.slice(0, key.indexOf('|')) : null + // Why: bare keys are scoped by the per-partition gating below; only host-qualified + // keys can sit in another host's partition and need host-matching at collection time. + // Why the empty-host arm: the host split parks the canonical unknown-host form (`|`) in + // the local partition, so a local prune must claim it or the orphan restores next launch. + const belongsToPrunedHost = + hostId === null || + keyHost === null || + keyHost === hostId || + (keyHost === '' && hostId === LOCAL_EXECUTION_HOST_ID) + if (belongsToPrunedHost && rawKey.startsWith(prefix)) { ownerKeysToPrune.add(key) } } diff --git a/src/main/persistence/tracking-repos/worktree-identity-migration.ts b/src/main/persistence/tracking-repos/worktree-identity-migration.ts index f8256c0c4fe..a9d3397aa7c 100644 --- a/src/main/persistence/tracking-repos/worktree-identity-migration.ts +++ b/src/main/persistence/tracking-repos/worktree-identity-migration.ts @@ -1,6 +1,10 @@ import type { WorkspaceKey } from '../../../shared/folder-workspace-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' +import { + getWorktreeIdFromHostIdentity, + isWorktreeHostIdentity +} from '../../../shared/worktree/host-qualified-identity' import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' /** @@ -94,7 +98,29 @@ export function migrateWorktreeIdentity( moveSessionKey(session.tabGroups, (groups) => groups.map(withNewWorktreeId)) || sessionChanged sessionChanged = moveSessionKey(session.tabGroupLayouts) || sessionChanged sessionChanged = moveSessionKey(session.activeGroupIdByWorktree) || sessionChanged - sessionChanged = moveSessionKey(session.lastVisitedAtByWorktreeId) || sessionChanged + if (session.lastVisitedAtByWorktreeId) { + const nextRecency = { ...session.lastVisitedAtByWorktreeId } + let recencyChanged = false + for (const [key, value] of Object.entries(session.lastVisitedAtByWorktreeId)) { + const rawId = isWorktreeHostIdentity(key) ? getWorktreeIdFromHostIdentity(key) : key + if (rawId !== oldWorktreeId) { + continue + } + const nextKey = isWorktreeHostIdentity(key) + ? `${key.slice(0, key.length - rawId.length)}${newWorktreeId}` + : newWorktreeId + // Why max: a partial migration leaves both identities behind; taking the older one would + // regress Cmd+J recency after restart. + const existing = nextRecency[nextKey] + nextRecency[nextKey] = existing === undefined ? value : Math.max(existing, value) + delete nextRecency[key] + recencyChanged = true + } + if (recencyChanged) { + session.lastVisitedAtByWorktreeId = nextRecency + sessionChanged = true + } + } sessionChanged = moveSessionKey(session.defaultTerminalTabsAppliedByWorktreeId) || sessionChanged if (session.activeWorktreeIdsOnShutdown?.includes(oldWorktreeId)) { diff --git a/src/main/providers/ssh-git-provider-remote-sync.test.ts b/src/main/providers/ssh-git-provider-remote-sync.test.ts index 969448516ef..4a25d54b8a6 100644 --- a/src/main/providers/ssh-git-provider-remote-sync.test.ts +++ b/src/main/providers/ssh-git-provider-remote-sync.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, beforeEach } from 'vitest' import { SshGitProvider } from './ssh-git-provider' import { createMockMux, type MockMultiplexer } from './ssh-git-provider-test-harness' +import { REBASE_FROM_BASE_RPC_TIMEOUT_MS } from '../../shared/git-rebase-source' describe('SshGitProvider', () => { let mux: MockMultiplexer @@ -101,10 +102,14 @@ describe('SshGitProvider', () => { it('rebaseFromBase sends git.rebaseFromBase request', async () => { await provider.rebaseFromBase('/home/user/repo', 'upstream/main') - expect(mux.request).toHaveBeenCalledWith('git.rebaseFromBase', { - worktreePath: '/home/user/repo', - baseRef: 'upstream/main' - }) + expect(mux.request).toHaveBeenCalledWith( + 'git.rebaseFromBase', + { + worktreePath: '/home/user/repo', + baseRef: 'upstream/main' + }, + { timeoutMs: REBASE_FROM_BASE_RPC_TIMEOUT_MS } + ) }) it('fetchRemote sends git.fetch request', async () => { diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 6aa09b2f8ec..8086548ed5d 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -33,6 +33,7 @@ import { import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe' import { gitExecMutatesRepository } from '../../shared/git-exec-mutation' import { GitStatusReadLeaseOwner } from '../git/git-status-read-lease-owner' +import { REBASE_FROM_BASE_RPC_TIMEOUT_MS } from '../../shared/git-rebase-source' import { GitUpstreamStatusReadOwner } from '../git/git-upstream-status-read-owner' type NonInteractiveExecQueueEntry = { @@ -565,7 +566,11 @@ export class SshGitProvider implements IGitProvider { async rebaseFromBase(worktreePath: string, baseRef: string): Promise { await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.rebaseFromBase', { worktreePath, baseRef }) + await this.mux.request( + 'git.rebaseFromBase', + { worktreePath, baseRef }, + { timeoutMs: REBASE_FROM_BASE_RPC_TIMEOUT_MS } + ) }) } diff --git a/src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts b/src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts new file mode 100644 index 00000000000..ee874ef5942 --- /dev/null +++ b/src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts @@ -0,0 +1,140 @@ +import { mkdtempSync, 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 * as Win32Utils from '../win32-utils' +import { isCodexAuthError } from '../../shared/codex-auth-errors' + +const { getSpawnArgsForWindowsMock, ptySpawnMock, resolveCodexCommandMock } = vi.hoisted(() => ({ + getSpawnArgsForWindowsMock: vi.fn(), + ptySpawnMock: vi.fn(), + resolveCodexCommandMock: vi.fn() +})) + +vi.mock('../win32-utils', async (importOriginal) => ({ + ...(await importOriginal()), + getSpawnArgsForWindows: getSpawnArgsForWindowsMock +})) + +vi.mock('../codex-cli/command', () => ({ + resolveCodexCommand: resolveCodexCommandMock +})) + +vi.mock('node-pty', () => ({ + spawn: ptySpawnMock +})) + +vi.mock('./codex-auth-presence', () => ({ + probeCodexAuthPresence: vi.fn(() => 'present') +})) + +vi.mock('../codex/codex-state-db', () => ({ + isCodexStateDbBackfillPending: vi.fn(() => false) +})) + +vi.mock('../codex/codex-state-db-backfill-recovery', () => ({ + startCodexStateDbBackfillRecoveryInBackground: vi.fn(() => Promise.resolve(null)) +})) + +import { fetchCodexRateLimits } from './codex-fetcher' + +// Dies the way a real app-server does: complains on stderr, then exits nonzero +// once the client has spoken, so the initialize write can never race an EPIPE. +const STUB_CODEX_SOURCE = ` +process.stdin.on('data', () => { + process.stderr.write(process.env.ORCA_STUB_CODEX_STDERR ?? '') + process.exit(Number(process.env.ORCA_STUB_CODEX_EXIT_CODE ?? '1')) +}) +` + +let tempRoot: string +let stubPath: string + +function runStub(stderr: string, exitCode: number): Promise<{ error: string | null }> { + process.env.ORCA_STUB_CODEX_STDERR = stderr + process.env.ORCA_STUB_CODEX_EXIT_CODE = String(exitCode) + return fetchCodexRateLimits({ allowPtyFallback: false }) +} + +describe('Codex RPC exit diagnostics', () => { + beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), 'orca-codex-rpc-exit-')) + stubPath = join(tempRoot, 'codex-exit-stub.cjs') + writeFileSync(stubPath, STUB_CODEX_SOURCE) + resolveCodexCommandMock.mockReturnValue('codex') + getSpawnArgsForWindowsMock.mockImplementation((_command: string, args: string[]) => ({ + spawnCmd: process.execPath, + spawnArgs: [stubPath, ...args] + })) + }) + + afterEach(() => { + delete process.env.ORCA_STUB_CODEX_STDERR + delete process.env.ORCA_STUB_CODEX_EXIT_CODE + rmSync(tempRoot, { recursive: true, force: true }) + vi.clearAllMocks() + }) + + it('names the exit code and what the child reported', async () => { + const result = await runStub( + "error: invalid value 'untrusted' for '--ask-for-approval '\n" + + " [possible values: on-request, never]\n\nFor more information, try '--help'.\n", + 2 + ) + + expect(result.error).toContain('Codex RPC process exited (exit code 2)') + expect(result.error).toContain("invalid value 'untrusted'") + expect(result.error).toContain("try '--help'") + }) + + it('names the exit code when the child says nothing', async () => { + await expect(runStub('', 1)).resolves.toMatchObject({ + error: 'Codex RPC process exited (exit code 1)', + status: 'error' + }) + }) + + it('classifies a dead refresh token so the re-auth warning fires', async () => { + const result = await runStub( + '2026-08-20T18:04:11.221Z INFO codex_core::auth: refreshing ChatGPT tokens\n' + + 'ERROR: The ChatGPT access token could not be refreshed; please sign in again.\n', + 1 + ) + + expect(result.error).toBe('Your ChatGPT session could not be refreshed. Please sign in again.') + // The renderer's re-auth warning gates on exactly this predicate; + // codex-account-auth-warning.test.ts asserts the surfaced string end-to-end. + expect(isCodexAuthError(result.error)).toBe(true) + }) + + it('keeps the re-auth phrase that path redaction would swallow', async () => { + const result = await runStub( + `ERROR: /Users/haris/.codex/auth.json is stale — please sign in again.\n`, + 1 + ) + + expect(isCodexAuthError(result.error)).toBe(true) + }) + + it('redacts paths and secrets a dying child prints, and bounds the line', async () => { + const result = await runStub( + `ERROR: could not read /Users/moveoplus/.codex/auth.json (token=sk-${'a'.repeat(40)})\n` + + `${'x'.repeat(5_000)}\n`, + 3 + ) + + expect(result.error).not.toContain('moveoplus') + expect(result.error).not.toContain('sk-aaaa') + expect(result.error?.length ?? 0).toBeLessThan(500) + }) + + it('keeps the final failure after startup banners', async () => { + const result = await runStub( + 'Welcome to WSL\nLoading shell profile\nERROR: unknown option --obsolete\n', + 2 + ) + + expect(result.error).toContain('Welcome to WSL') + expect(result.error).toContain('ERROR: unknown option --obsolete') + }) +}) diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index 62cab4b94bb..dc18e715fdd 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -25,6 +25,11 @@ import { getCmdExePath, getSpawnArgsForWindows } from '../win32-utils' import { cleanupHiddenRateLimitPty, registerHiddenRateLimitPty } from './hidden-pty-cleanup' import { parseWslUncPath } from '../../shared/wsl-paths' import { extractCodexAuthError, isCodexAuthError } from '../../shared/codex-auth-errors' +import { + excerptAgentFailureOutput, + sanitizeAgentFailureDetail +} from '../../shared/commit-message-agent-output' +import { redactString } from '../observability/redactor' import { buildWslExecArgs, buildWslLoginShellCommand } from '../../shared/wsl-login-shell-command' import { getHiddenRateLimitWslCwdSetupCommands, @@ -863,19 +868,42 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise void, + launchAgent: TuiAgent = 'aider' +): Promise<{ runtime: OrcaRuntimeService; handle: string; writes: string[] }> { + const runtime = new OrcaRuntimeService(makeStore() as never) + const writes: string[] = [] + runtime.setPtyController({ + spawn: async () => ({ id: 'pty-prompt' }), + write: (_ptyId, data) => { + writes.push(data) + onWrite(runtime, data, writes.length) + return true + }, + kill: () => true, + getForegroundProcess: async () => null + }) + const terminal = await runtime.createTerminal(`path:${AGENT_PROMPT_TEST_WORKTREE_PATH}`, { + launchAgent + }) + return { runtime, handle: terminal.handle, writes } +} diff --git a/src/main/runtime/agent-prompt-submission-runtime.test.ts b/src/main/runtime/agent-prompt-submission-runtime.test.ts index 711e887b5c5..f26ff8030b8 100644 --- a/src/main/runtime/agent-prompt-submission-runtime.test.ts +++ b/src/main/runtime/agent-prompt-submission-runtime.test.ts @@ -3,10 +3,14 @@ import { AGENT_PROMPT_BRACKETED_PASTE_END, AGENT_PROMPT_SUBMIT_DELAY_MS } from '../../shared/agent-prompt-injection' +import { + AGENT_PROMPT_TEST_WORKTREE_PATH, + createAgentPromptSubmissionRuntime +} from './agent-prompt-submission-runtime-test-fixture' import { OrcaRuntimeService } from './orca-runtime' import { makeStore } from './runtime-rpc-worktree-store-fixtures' -const WORKTREE_PATH = '/tmp/worktree-a' +const createPromptRuntime = createAgentPromptSubmissionRuntime vi.mock('../git/worktree', () => ({ listWorktrees: vi.fn().mockResolvedValue([ @@ -29,37 +33,18 @@ vi.mock('../git/worktree', () => ({ ]) })) -async function createPromptRuntime( - onWrite: (runtime: OrcaRuntimeService, data: string, writeIndex: number) => void -): Promise<{ runtime: OrcaRuntimeService; handle: string; writes: string[] }> { - const runtime = new OrcaRuntimeService(makeStore() as never) - const writes: string[] = [] - runtime.setPtyController({ - spawn: vi.fn().mockResolvedValue({ id: 'pty-prompt' }), - write: (_ptyId, data) => { - writes.push(data) - onWrite(runtime, data, writes.length) - return true - }, - kill: () => true, - getForegroundProcess: async () => null - }) - const terminal = await runtime.createTerminal(`path:${WORKTREE_PATH}`, { - launchAgent: 'aider' - }) - return { runtime, handle: terminal.handle, writes } -} - describe('agent prompt submission runtime', () => { afterEach(() => vi.useRealTimers()) it('submits exactly once after an observed lifecycle transition', async () => { vi.useFakeTimers() - const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { - if (data === '\r') { - runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) + const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime( + (runtime, data) => { + if (data === '\r') { + runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) + } } - }) + ) const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') await vi.runAllTimersAsync() @@ -374,8 +359,11 @@ describe('agent prompt submission runtime', () => { kill: () => true, getForegroundProcess: async () => null }) - handle = (await runtime.createTerminal(`path:${WORKTREE_PATH}`, { launchAgent: 'aider' })) - .handle + handle = ( + await runtime.createTerminal(`path:${AGENT_PROMPT_TEST_WORKTREE_PATH}`, { + launchAgent: 'aider' + }) + ).handle runtime.onPtyData( 'pty-prompt', 'Permission required\nAllow once\nAllow always\nReject\n' + diff --git a/src/main/runtime/orca-runtime-terminal-close-continuity.test.ts b/src/main/runtime/orca-runtime-terminal-close-continuity.test.ts index c3a3e747b01..320f27d6c03 100644 --- a/src/main/runtime/orca-runtime-terminal-close-continuity.test.ts +++ b/src/main/runtime/orca-runtime-terminal-close-continuity.test.ts @@ -10,13 +10,62 @@ const WORKTREE_ID = `${REPO_ID}::${WORKTREE_PATH}` const TAB_ID = 'tab-close-continuity' const LEAF_ID = '11111111-1111-4111-8111-111111111111' const SIBLING_LEAF_ID = '33333333-3333-4333-8333-333333333333' +const CANARY_TAB_ID = 'tab-close-continuity-canary' +const CANARY_LEAF_ID = '55555555-5555-4555-8555-555555555555' const PTY_ID = 'pty-close-continuity' const RUNTIME_OWNED_PTY_ID = 'serve-close-continuity' const SIBLING_PTY_ID = 'pty-close-continuity-sibling' +const CANARY_PTY_ID = 'pty-close-continuity-canary' const INCARNATION_ID = '22222222-2222-4222-8222-222222222222' const SIBLING_INCARNATION_ID = '44444444-4444-4444-8444-444444444444' +const CANARY_INCARNATION_ID = '66666666-6666-4666-8666-666666666666' +const canarySessionTab = { + id: CANARY_TAB_ID, + ptyId: CANARY_PTY_ID, + worktreeId: WORKTREE_ID, + title: 'Canary shell', + customTitle: null, + color: null, + sortOrder: 1, + createdAt: 2 +} +const canarySessionLayout = { + root: { type: 'leaf' as const, leafId: CANARY_LEAF_ID }, + activeLeafId: CANARY_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [CANARY_LEAF_ID]: CANARY_PTY_ID } +} +const canarySyncedTab = { + tabId: CANARY_TAB_ID, + worktreeId: WORKTREE_ID, + title: 'Canary shell', + activeLeafId: CANARY_LEAF_ID, + layout: { type: 'leaf' as const, leafId: CANARY_LEAF_ID } +} +const canarySyncedLeaf = { + tabId: CANARY_TAB_ID, + worktreeId: WORKTREE_ID, + leafId: CANARY_LEAF_ID, + paneRuntimeId: 9, + ptyId: CANARY_PTY_ID +} +const canaryMobileTab = { + type: 'terminal' as const, + id: `${CANARY_TAB_ID}::${CANARY_LEAF_ID}`, + parentTabId: CANARY_TAB_ID, + leafId: CANARY_LEAF_ID, + ptyId: CANARY_PTY_ID, + title: 'Canary shell', + isActive: false +} +const canaryProcess = { + id: CANARY_PTY_ID, + incarnationId: CANARY_INCARNATION_ID, + cwd: WORKTREE_PATH, + title: 'Canary shell' +} -function makeSession(ptyId = PTY_ID): WorkspaceSessionState { +function makeSession(ptyId = PTY_ID, includeCanary = false): WorkspaceSessionState { return { ...getDefaultWorkspaceSession(), tabsByWorktree: { @@ -30,7 +79,8 @@ function makeSession(ptyId = PTY_ID): WorkspaceSessionState { color: null, sortOrder: 0, createdAt: 1 - } + }, + ...(includeCanary ? [canarySessionTab] : []) ] }, terminalLayoutsByTabId: { @@ -39,10 +89,14 @@ function makeSession(ptyId = PTY_ID): WorkspaceSessionState { activeLeafId: LEAF_ID, expandedLeafId: null, ptyIdsByLeafId: { [LEAF_ID]: ptyId } - } + }, + ...(includeCanary ? { [CANARY_TAB_ID]: canarySessionLayout } : {}) }, terminalPtyIncarnationsByPaneKey: { - [makePaneKey(TAB_ID, LEAF_ID)]: INCARNATION_ID + [makePaneKey(TAB_ID, LEAF_ID)]: INCARNATION_ID, + ...(includeCanary + ? { [makePaneKey(CANARY_TAB_ID, CANARY_LEAF_ID)]: CANARY_INCARNATION_ID } + : {}) } } } @@ -56,13 +110,19 @@ function makeDeferred() { } function createHarness( - options: { ptyId?: string; publishMobileSurface?: boolean; registerPtyBacked?: boolean } = {} + options: { + ptyId?: string + publishMobileSurface?: boolean + registerPtyBacked?: boolean + includeCanary?: boolean + } = {} ) { const ptyId = options.ptyId ?? PTY_ID - let session = makeSession(ptyId) + let session = makeSession(ptyId, options.includeCanary) let sessionAvailable = true let incarnationId = INCARNATION_ID let includeSiblingPty = false + let victimPtyListed = true const repo = { id: REPO_ID, path: WORKTREE_PATH, @@ -80,29 +140,40 @@ function createHarness( getWorkspaceSession: () => (sessionAvailable ? session : undefined), setWorkspaceSession: (next: WorkspaceSessionState) => { session = next - } + }, + flushOrThrow: () => {} } const acknowledged = makeDeferred() let closeTerminalTabError: Error | null = null + let closeTerminalTabAction: (() => void | Promise) | null = null const closeTerminal = vi.fn() - const closeTerminalTab = vi.fn(() => - closeTerminalTabError ? Promise.reject(closeTerminalTabError) : acknowledged.promise - ) + const closeTerminalTab = vi.fn(() => { + if (closeTerminalTabError) { + return Promise.reject(closeTerminalTabError) + } + return closeTerminalTabAction ? Promise.resolve(closeTerminalTabAction()) : acknowledged.promise + }) const kill = vi.fn(() => true) let verifiedStopResult: boolean | Error = false - const stopAndWait = vi.fn(async () => { + let stopAndWaitAction: ((stoppingPtyId: string) => void | Promise) | null = null + const stopAndWait = vi.fn(async (stoppingPtyId: string) => { + await stopAndWaitAction?.(stoppingPtyId) if (verifiedStopResult instanceof Error) { throw verifiedStopResult } return verifiedStopResult }) const listProcesses = vi.fn(async () => [ - { - id: ptyId, - incarnationId, - cwd: WORKTREE_PATH, - title: 'Fixture shell' - }, + ...(victimPtyListed + ? [ + { + id: ptyId, + incarnationId, + cwd: WORKTREE_PATH, + title: 'Fixture shell' + } + ] + : []), ...(includeSiblingPty ? [ { @@ -112,7 +183,8 @@ function createHarness( title: 'Fixture sibling shell' } ] - : []) + : []), + ...(options.includeCanary ? [canaryProcess] : []) ]) const runtime = new OrcaRuntimeService(store as never) runtime.setNotifier({ closeTerminal, closeTerminalTab } as never) @@ -134,7 +206,8 @@ function createHarness( title: 'Fixture shell', activeLeafId: LEAF_ID, layout: { type: 'leaf', leafId: LEAF_ID } - } + }, + ...(options.includeCanary ? [canarySyncedTab] : []) ], leaves: [ { @@ -143,7 +216,8 @@ function createHarness( leafId: LEAF_ID, paneRuntimeId: 7, ptyId - } + }, + ...(options.includeCanary ? [canarySyncedLeaf] : []) ], ...(options.publishMobileSurface ? { @@ -164,13 +238,34 @@ function createHarness( ptyId, title: 'Fixture shell', isActive: true - } + }, + ...(options.includeCanary ? [canaryMobileTab] : []) ] } ] } : {}) }) + const syncCanaryGraph = () => + runtime.syncWindowGraph(1, { + tabs: [canarySyncedTab], + leaves: [canarySyncedLeaf], + ...(options.publishMobileSurface + ? { + mobileSessionTabs: [ + { + worktree: WORKTREE_ID, + publicationEpoch: 'renderer:close-continuity', + snapshotVersion: 2, + activeGroupId: null, + activeTabId: `${CANARY_TAB_ID}::${CANARY_LEAF_ID}`, + activeTabType: 'terminal' as const, + tabs: [{ ...canaryMobileTab, isActive: true }] + } + ] + } + : {}) + }) const syncEmptyGraph = () => runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) const syncFixtureTabWithoutLeaf = () => runtime.syncWindowGraph(1, { @@ -181,9 +276,10 @@ function createHarness( title: 'Fixture shell', activeLeafId: LEAF_ID, layout: { type: 'leaf', leafId: LEAF_ID } - } + }, + ...(options.includeCanary ? [canarySyncedTab] : []) ], - leaves: [] + leaves: options.includeCanary ? [canarySyncedLeaf] : [] }) const syncSplitFixtureGraph = () => { includeSiblingPty = true @@ -245,6 +341,13 @@ function createHarness( leafId: LEAF_ID, incarnationId: INCARNATION_ID }) + if (options.includeCanary) { + runtime.registerPty(CANARY_PTY_ID, WORKTREE_ID, null, { + tabId: CANARY_TAB_ID, + leafId: CANARY_LEAF_ID, + incarnationId: CANARY_INCARNATION_ID + }) + } } syncFixtureGraph() return { @@ -254,6 +357,7 @@ function createHarness( closeTerminalTab, kill, stopAndWait, + syncCanaryGraph, syncEmptyGraph, syncFixtureGraph, syncFixtureTabWithoutLeaf, @@ -262,20 +366,41 @@ function createHarness( makeSessionUnavailable: () => { sessionAvailable = false }, + removeVictimFromInventory: () => { + victimPtyListed = false + }, retirePersistedTab: () => { + const victimPaneKey = makePaneKey(TAB_ID, LEAF_ID) session = { ...session, - tabsByWorktree: { ...session.tabsByWorktree, [WORKTREE_ID]: [] }, - terminalLayoutsByTabId: {}, - terminalPtyIncarnationsByPaneKey: {} + tabsByWorktree: { + ...session.tabsByWorktree, + [WORKTREE_ID]: (session.tabsByWorktree[WORKTREE_ID] ?? []).filter( + (tab) => tab.id !== TAB_ID + ) + }, + terminalLayoutsByTabId: Object.fromEntries( + Object.entries(session.terminalLayoutsByTabId).filter(([tabId]) => tabId !== TAB_ID) + ), + terminalPtyIncarnationsByPaneKey: Object.fromEntries( + Object.entries(session.terminalPtyIncarnationsByPaneKey ?? {}).filter( + ([paneKey]) => paneKey !== victimPaneKey + ) + ) } }, + setCloseTerminalTabAction: (action: () => void | Promise) => { + closeTerminalTabAction = action + }, rejectTerminalTabClose: (error: Error) => { closeTerminalTabError = error }, setVerifiedStopResult: (result: boolean | Error) => { verifiedStopResult = result }, + setStopAndWaitAction: (action: (stoppingPtyId: string) => void | Promise) => { + stopAndWaitAction = action + }, replaceIncarnation: (next: string) => { incarnationId = next } @@ -317,6 +442,79 @@ describe('terminal close and handle incarnation continuity', () => { expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toEqual([]) }) + it('fences final-pane exit ordering until exact retirement commits', async () => { + const harness = createHarness({ + includeCanary: true, + publishMobileSurface: true, + registerPtyBacked: true + }) + harness.syncFixtureTabWithoutLeaf() + const closeMobileSessionTab = vi.spyOn(harness.runtime, 'closeMobileSessionTab') + const order: string[] = [] + harness.setCloseTerminalTabAction(() => { + order.push('session-retirement') + harness.retirePersistedTab() + harness.syncCanaryGraph() + order.push('committed-graph-removal') + }) + harness.setVerifiedStopResult(true) + harness.setStopAndWaitAction((stoppingPtyId) => { + order.push('pty-stop') + harness.removeVictimFromInventory() + harness.syncCanaryGraph() + order.push('idempotent-graph-removal') + harness.runtime.onPtyExit(stoppingPtyId, 0, INCARNATION_ID) + order.push('idempotent-session-retirement') + }) + const terminals = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals + const victim = terminals.find((terminal) => terminal.ptyId === PTY_ID)! + const canary = terminals.find((terminal) => terminal.ptyId === CANARY_PTY_ID)! + + await expect(harness.runtime.closeTerminal(victim.handle)).resolves.toMatchObject({ + handle: victim.handle, + tabId: TAB_ID, + ptyKilled: true + }) + + expect(order).toEqual([ + 'session-retirement', + 'committed-graph-removal', + 'pty-stop', + 'idempotent-graph-removal', + 'idempotent-session-retirement' + ]) + expect(harness.stopAndWait).toHaveBeenCalledTimes(1) + expect(harness.stopAndWait).toHaveBeenCalledWith(PTY_ID, { + deadlineMs: expect.any(Number) + }) + expect(harness.kill).not.toHaveBeenCalled() + expect(closeMobileSessionTab).toHaveBeenCalledTimes(1) + expect(closeMobileSessionTab).toHaveBeenCalledWith(`id:${WORKTREE_ID}`, TAB_ID, { + localPtyTeardownOwnedExternally: true + }) + expect(harness.closeTerminalTab).toHaveBeenCalledTimes(1) + expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toEqual([ + expect.objectContaining({ id: CANARY_TAB_ID, ptyId: CANARY_PTY_ID }) + ]) + expect(harness.getSession().terminalLayoutsByTabId).toEqual({ + [CANARY_TAB_ID]: expect.objectContaining({ + ptyIdsByLeafId: { [CANARY_LEAF_ID]: CANARY_PTY_ID } + }) + }) + expect(harness.getSession().terminalPtyIncarnationsByPaneKey).toEqual({ + [makePaneKey(CANARY_TAB_ID, CANARY_LEAF_ID)]: CANARY_INCARNATION_ID + }) + const survivors = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals + expect(survivors).toHaveLength(1) + const [survivor] = survivors + expect(survivor).toMatchObject({ ptyId: CANARY_PTY_ID }) + await expect(harness.runtime.readTerminal(survivor.handle)).resolves.toMatchObject({ + handle: survivor.handle, + status: 'running' + }) + expect(canary.ptyId).toBe(survivor.ptyId) + }) + it('does not kill the final PTY when durable tab retirement is rejected', async () => { const harness = createHarness() const [{ handle }] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 3688fec9c39..243fed1191f 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1282,6 +1282,13 @@ function setInMemoryOrchestrationMessages( runtime.setOrchestrationDb(db as unknown as OrchestrationDb) } +function pendingMailPointerRepoints(runtime: OrcaRuntimeService): number { + const internals = runtime as unknown as { + mailPointerRepointScheduler: { pendingCount: number } + } + return internals.mailPointerRepointScheduler.pendingCount +} + function bindSinglePtyRun(db: InMemoryOrchestrationMessages, terminalHandle: string): string { db.setRun({ id: 'run_test', @@ -35712,7 +35719,7 @@ describe('OrcaRuntimeService', () => { await vi.advanceTimersByTimeAsync(20_000) expect(pendingReads).not.toHaveBeenCalled() - expect(vi.getTimerCount()).toBe(0) + expect(pendingMailPointerRepoints(runtime)).toBe(0) runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) expect(pendingReads).toHaveBeenCalledTimes(1) @@ -35799,7 +35806,7 @@ describe('OrcaRuntimeService', () => { typeof payload === 'string' && payload.includes('orca orchestration check') ) ).toHaveLength(1) - expect(vi.getTimerCount()).toBe(0) + expect(pendingMailPointerRepoints(runtime)).toBe(0) db.close() } finally { vi.useRealTimers() @@ -37711,6 +37718,261 @@ describe('OrcaRuntimeService', () => { expect(summary).toMatchObject({ hasHostSidebarActivity: true, status: 'working' }) }) + it('projects monitoring for folder workspaces without assuming a git worktree', async () => { + const now = Date.now() + const folderWorkspace = makeFolderWorkspace({ name: 'GG' }) + const projectGroup = makeFolderProjectGroup({ name: 'Store' }) + const runtime = new OrcaRuntimeService( + createFolderWorkspaceRuntimeStore(folderWorkspace, projectGroup) as never, + undefined, + { + getAgentStatusSnapshot: () => [ + { + paneKey: 'folder-pane', + worktreeId: TEST_FOLDER_WORKSPACE_KEY, + state: 'working', + workingMode: 'monitoring', + prompt: 'watch tests', + agentType: 'claude', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 100 + } + ] + } + ) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_FOLDER_WORKSPACE_KEY) + + expect(summary).toMatchObject({ + workspaceKind: 'folder-workspace', + status: 'working', + workingMode: 'monitoring' + }) + }) + it('projects monitoring over a title-derived working status', async () => { + const now = Date.now() + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: 'tab-1:1', + worktreeId: TEST_WORKTREE_ID, + tabId: 'tab-1', + state: 'working', + workingMode: 'monitoring', + prompt: 'watch tests', + agentType: 'claude', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 100 + } + ] + }) + syncSinglePty(runtime, 'pty-1', { paneTitle: 'claude working' }) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary).toMatchObject({ status: 'working', workingMode: 'monitoring' }) + }) + it('keeps hook monitoring mode when a newer mode-less OSC row reports the same work', async () => { + const now = Date.now() + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: 'tab-1:1', + worktreeId: TEST_WORKTREE_ID, + tabId: 'tab-1', + state: 'working', + workingMode: 'monitoring', + prompt: 'watch tests', + agentType: 'claude', + connectionId: null, + receivedAt: now - 100, + stateStartedAt: now - 200 + } + ] + }) + syncSinglePty(runtime) + runtime.onPtyData( + 'pty-1', + '\x1b]9999;{"state":"working","prompt":"watch tests","agentType":"claude"}\x07', + 1 + ) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary).toMatchObject({ status: 'working', workingMode: 'monitoring' }) + expect(summary?.agents).toEqual([ + expect.objectContaining({ + state: 'working', + workingMode: 'monitoring', + prompt: 'watch tests' + }) + ]) + }) + it('does not carry hook monitoring mode into a newer OSC turn', async () => { + const now = Date.now() + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: 'tab-1:1', + worktreeId: TEST_WORKTREE_ID, + tabId: 'tab-1', + state: 'working', + workingMode: 'monitoring', + prompt: 'watch tests', + agentType: 'claude', + connectionId: null, + receivedAt: now - 100, + stateStartedAt: now - 200 + } + ] + }) + syncSinglePty(runtime) + runtime.onPtyData( + 'pty-1', + '\x1b]9999;{"state":"working","prompt":"fix tests","agentType":"claude"}\x07', + 1 + ) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary).toMatchObject({ status: 'working' }) + expect(summary).not.toHaveProperty('workingMode') + expect(summary?.agents).toEqual([ + expect.objectContaining({ state: 'working', prompt: 'fix tests' }) + ]) + }) + it('keeps title-only foreground work ahead of monitoring in another pane', async () => { + const now = Date.now() + const monitoringLeafId = '33333333-3333-4333-8333-333333333333' + const foregroundLeafId = '44444444-4444-4444-8444-444444444444' + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: `monitoring-tab:${monitoringLeafId}`, + worktreeId: TEST_WORKTREE_ID, + tabId: 'monitoring-tab', + state: 'working', + workingMode: 'monitoring', + prompt: 'watch tests', + agentType: 'claude', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 100 + } + ] + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'monitoring-tab', + worktreeId: TEST_WORKTREE_ID, + title: 'Claude', + activeLeafId: monitoringLeafId, + layout: null + }, + { + tabId: 'foreground-tab', + worktreeId: TEST_WORKTREE_ID, + title: 'Claude', + activeLeafId: foregroundLeafId, + layout: null + } + ], + leaves: [ + { + tabId: 'monitoring-tab', + worktreeId: TEST_WORKTREE_ID, + leafId: monitoringLeafId, + paneRuntimeId: 1, + ptyId: 'monitoring-pty', + paneTitle: 'claude' + }, + { + tabId: 'foreground-tab', + worktreeId: TEST_WORKTREE_ID, + leafId: foregroundLeafId, + paneRuntimeId: 2, + ptyId: 'foreground-pty', + paneTitle: 'claude working' + } + ] + }) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary).toMatchObject({ status: 'working' }) + expect(summary).not.toHaveProperty('workingMode') + expect(summary?.agents).toEqual([ + expect.objectContaining({ paneKey: `monitoring-tab:${monitoringLeafId}` }) + ]) + }) + it('keeps title-only foreground work ahead of monitoring in another split pane', async () => { + const now = Date.now() + const tabId = 'split-tab' + const monitoringLeafId = '33333333-3333-4333-8333-333333333333' + const foregroundLeafId = '44444444-4444-4444-8444-444444444444' + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: `${tabId}:${monitoringLeafId}`, + worktreeId: TEST_WORKTREE_ID, + tabId, + state: 'working', + workingMode: 'monitoring', + prompt: 'watch tests', + agentType: 'claude', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 100 + } + ] + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId, + worktreeId: TEST_WORKTREE_ID, + title: 'Claude', + activeLeafId: monitoringLeafId, + layout: null + } + ], + leaves: [ + { + tabId, + worktreeId: TEST_WORKTREE_ID, + leafId: monitoringLeafId, + paneRuntimeId: 1, + ptyId: 'monitoring-pty', + paneTitle: 'claude' + }, + { + tabId, + worktreeId: TEST_WORKTREE_ID, + leafId: foregroundLeafId, + paneRuntimeId: 2, + ptyId: 'foreground-pty', + paneTitle: 'claude working' + } + ] + }) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary).toMatchObject({ status: 'working' }) + expect(summary).not.toHaveProperty('workingMode') + }) + it('suppresses restored-unconfirmed hook rows from worktree.ps', async () => { const leafId = '33333333-3333-4333-8333-333333333333' const now = Date.now() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index aac73f5e7f4..d571939534c 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -55,6 +55,7 @@ import { type AgentStatusOrchestrationContext, type AgentStatusEntry } from '../../shared/agent-status-types' +import { terminalStatusPayloadMatchesHook } from '../../shared/agent-terminal-status-equivalence' import { indexAgentStatusRowsByPaneKey } from '../agent-hooks/agent-status-pane-index' import type { AgentHookAuthorityAttestation } from '../agent-hooks/server' import type { @@ -1752,6 +1753,32 @@ type RuntimeAgentRowSnapshot = { updatedAt: number } +type RuntimeWorkingTerminalEvidence = { + paneKey: string | null + ptyId: string | null + tabId: string | null +} + +type RuntimeWorktreeAgentSource = { + paneKey: string + ptyId?: string + tabId?: string + worktreeId?: string + connectionId: string | null + payload: ParsedAgentStatusPayload + state: ParsedAgentStatusPayload['state'] + workingMode?: ParsedAgentStatusPayload['workingMode'] + agentType: string | null + prompt: string + lastAssistantMessage: string | null + toolName: string | null + toolInput: string | null + interrupted: boolean + stateStartedAt: number + updatedAt: number + restoredUnconfirmed?: boolean +} + /** A hook row narrowed to what `session.tabs` publishes, shaped like the retained OSC * snapshot so one projection branch can consume either carrier. */ type HookLiveAgentRow = Pick< @@ -3632,6 +3659,9 @@ export class OrcaRuntimeService { }) => AgentHookAuthorityAttestation | null) | null private readonly retireAgentHookCompatibilityAuthorityFn: ((paneKey: string) => void) | null + private readonly reconcileAgentStatusForEndedProcessFn: + | ((paneKeys: Iterable) => void) + | null private readonly canRecoverPersistentLocalPtysFn: () => boolean private readonly buildAgentHookPtyEnv: (() => Record) | null private readonly getDesktopWindowStatusFn: () => RuntimeDesktopWindowStatus @@ -3713,6 +3743,7 @@ export class OrcaRuntimeService { terminalProvenance: 'current_runtime' | 'restored' }) => AgentHookAuthorityAttestation | null retireAgentHookCompatibilityAuthority?: (paneKey: string) => void + reconcileAgentStatusForEndedProcess?: (paneKeys: Iterable) => void canRecoverPersistentLocalPtys?: () => boolean // Why: codex-home paths for the Agent Session History scan must be sourced // here, not via the window-only registerCoreHandlers path — that path never @@ -3755,6 +3786,7 @@ export class OrcaRuntimeService { deps?.attestAgentHookCompatibilityAuthority ?? null this.retireAgentHookCompatibilityAuthorityFn = deps?.retireAgentHookCompatibilityAuthority ?? null + this.reconcileAgentStatusForEndedProcessFn = deps?.reconcileAgentStatusForEndedProcess ?? null this.canRecoverPersistentLocalPtysFn = deps?.canRecoverPersistentLocalPtys ?? (() => true) // Why: configure the shared AiVault scan cache from a serve-mode-reachable // seam so the aiVault.listSessions RPC includes managed-Codex + WSL sessions @@ -12155,6 +12187,7 @@ export class OrcaRuntimeService { return ( !previous || previous.payload.state !== payload.state || + previous.payload.workingMode !== payload.workingMode || previous.payload.prompt !== payload.prompt || (previous.payload.agentType ?? null) !== (payload.agentType ?? null) || (previous.payload.toolName ?? null) !== (payload.toolName ?? null) || @@ -13962,6 +13995,27 @@ export class OrcaRuntimeService { } } + /** Every pane key this PTY could be addressed by. Independent of launch authority: an ordinary + * restored PTY has neither a launch token nor a receipt, and those are exactly the panes whose + * spawn-time `ptyPaneKey` mapping teardown could not resolve. */ + private collectPaneKeysForPty(ptyId: string): Set { + const paneKeys = new Set() + const pty = this.ptysById.get(ptyId) + if (pty?.paneKey && parsePaneKey(pty.paneKey)) { + paneKeys.add(pty.paneKey) + } + const receipt = this.restoredOrchestrationAuthorityByPtyId.get(ptyId) + if (receipt?.paneKey && parsePaneKey(receipt.paneKey)) { + paneKeys.add(receipt.paneKey) + } + for (const leaf of this.getLeavesForPty(ptyId)) { + if (isValidTerminalTabId(leaf.tabId) && isTerminalLeafId(leaf.leafId)) { + paneKeys.add(makePaneKey(leaf.tabId, leaf.leafId)) + } + } + return paneKeys + } + private retirePtyAgentLaunchAuthority(ptyId: string): void { const pty = this.ptysById.get(ptyId) if (!pty) { @@ -13971,21 +14025,10 @@ export class OrcaRuntimeService { if (!pty.launchToken && !receipt) { return } + const paneKeys = this.collectPaneKeysForPty(ptyId) this.restoredOrchestrationAuthorityByPtyId.delete(ptyId) pty.launchToken = null pty.launchIncarnationId = null - const paneKeys = new Set() - if (pty.paneKey && parsePaneKey(pty.paneKey)) { - paneKeys.add(pty.paneKey) - } - if (receipt?.paneKey && parsePaneKey(receipt.paneKey)) { - paneKeys.add(receipt.paneKey) - } - for (const leaf of this.getLeavesForPty(ptyId)) { - if (isValidTerminalTabId(leaf.tabId) && isTerminalLeafId(leaf.leafId)) { - paneKeys.add(makePaneKey(leaf.tabId, leaf.leafId)) - } - } for (const paneKey of paneKeys) { this.retireAgentHookCompatibilityAuthorityFn?.(paneKey) } @@ -15201,7 +15244,14 @@ export class OrcaRuntimeService { ptyId: string, exitCode: number, exitIncarnationId?: PtyIncarnationId, - options?: { hostExitConfirmed?: boolean; cause?: TerminalExitCause } + options?: { + hostExitConfirmed?: boolean + cause?: TerminalExitCause + /** The provider's own physical-exit callback fired. Separate from `hostExitConfirmed`, which + * also drives the SSH surface decision and the liveness verdict: node-pty reports real exits + * as -1, so the numeric code alone cannot tell a dead process from a failed stop. */ + providerExitObserved?: boolean + } ): void { const pty = this.ptysById.get(ptyId) if (exitIncarnationId && pty?.incarnationId && exitIncarnationId !== pty.incarnationId) { @@ -15227,6 +15277,9 @@ export class OrcaRuntimeService { pty?.connectionId != null && exitCode < 0 && options?.hostExitConfirmed !== true + // Why: collect before retirePtyAgentLaunchAuthority, which deletes the restored-authority + // receipt a receipt-only pane's key comes from. + const exitPaneKeys = this.collectPaneKeysForPty(ptyId) if (preservesAbnormalSshSurface) { if (this.getPtyLivenessVerdict(ptyId)?.status !== 'unverifiable') { this.markPtyLivenessUnverifiable(ptyId, SSH_EXIT_UNCONFIRMED_REASON) @@ -15235,6 +15288,15 @@ export class OrcaRuntimeService { } else { this.retirePtyAgentLaunchAuthority(ptyId) } + // Why: a synthetic -1 from a failed or unverified stop is not a death certificate (the PTY can + // have survived it), while a real exit can also report -1 — so neither the numeric code nor the + // SSH surface predicate is sufficient on its own. Decided separately from + // preservesAbnormalSshSurface, which a host-confirmed negative SSH exit would otherwise skip. + const processDeathCertified = + exitCode >= 0 || options?.hostExitConfirmed === true || options?.providerExitObserved === true + if (processDeathCertified && exitPaneKeys.size > 0) { + this.reconcileAgentStatusForEndedProcessFn?.(exitPaneKeys) + } const incarnationId = exitIncarnationId ?? pty?.incarnationId ?? @@ -19986,6 +20048,7 @@ export class OrcaRuntimeService { const repoById = new Map((this.store?.getRepos() ?? []).map((repo) => [repo.id, repo])) const platformByRepoId = resolvedWorktreeSnapshot.platformByRepoId const summaries = new Map() + const workingTerminalEvidenceByWorktreeId = new Map() // Why: the GitHub cache is keyed by `repoPath::branch` (no refs/heads/ prefix), // matching how the renderer's fetchPRForBranch stores entries. We look up cached @@ -20169,10 +20232,15 @@ export class OrcaRuntimeService { summary.liveTerminalCount += 1 summary.hasAttachedPty = true summary.lastOutputAt = maxTimestamp(summary.lastOutputAt, leaf.lastOutputAt) - summary.status = mergeWorktreeStatus( - summary.status, - getLeafWorktreeStatus(leaf, this.tabs.get(leaf.tabId)?.title ?? null) - ) + const leafStatus = getLeafWorktreeStatus(leaf, this.tabs.get(leaf.tabId)?.title ?? null) + if (leafStatus === 'working') { + addRuntimeWorkingTerminalEvidence(workingTerminalEvidenceByWorktreeId, summary.worktreeId, { + paneKey: this.makeRuntimePaneKey(leaf), + ptyId: leaf.ptyId, + tabId: leaf.tabId + }) + } + mergeWorktreeSummaryStatus(summary, leafStatus) if ( leaf.preview && (summary.preview.length === 0 || (leaf.lastOutputAt ?? -1) >= (previousLastOutputAt ?? -1)) @@ -20234,10 +20302,15 @@ export class OrcaRuntimeService { summary.hasAttachedPty = true summary.hasHostSidebarActivity = true summary.lastOutputAt = maxTimestamp(summary.lastOutputAt, pty.lastOutputAt) - summary.status = mergeWorktreeStatus( - summary.status, - getSavedTabWorktreeStatus(owner.title, true) - ) + const ptyStatus = getSavedTabWorktreeStatus(owner.title, true) + if (ptyStatus === 'working') { + addRuntimeWorkingTerminalEvidence(workingTerminalEvidenceByWorktreeId, summary.worktreeId, { + paneKey: pty.paneKey, + ptyId: pty.ptyId, + tabId: pty.tabId ?? persistedTabId ?? null + }) + } + mergeWorktreeSummaryStatus(summary, ptyStatus) if ( pty.preview && (summary.preview.length === 0 || (pty.lastOutputAt ?? -1) >= (previousLastOutputAt ?? -1)) @@ -20338,7 +20411,8 @@ export class OrcaRuntimeService { runtimeWorktreeSummaryPathIndex, missingRuntimeWorktreeIds, mirroredWorktreeIdByTabId, - connectedPtyEvidence + connectedPtyEvidence, + workingTerminalEvidenceByWorktreeId ) const sorted = [...summaries.values()].sort(compareWorktreePs) @@ -20362,30 +20436,17 @@ export class OrcaRuntimeService { tabIds: ReadonlySet paneKeys: ReadonlySet ptyIds: ReadonlySet - } + }, + workingTerminalEvidenceByWorktreeId: ReadonlyMap< + string, + readonly RuntimeWorkingTerminalEvidence[] + > ): void { // Why: most agents report via hooks (agent-hooks/server), not OSC, so the // hook snapshot is the primary source — same one the desktop sidebar reads. // OSC-only entries (no hook) are merged in as a fallback, keyed by paneKey. - const rowSources = new Map< - string, - { - paneKey: string - ptyId?: string - tabId?: string - worktreeId?: string - connectionId: string | null - state: ParsedAgentStatusPayload['state'] - agentType: string | null - prompt: string - lastAssistantMessage: string | null - toolName: string | null - toolInput: string | null - interrupted: boolean - stateStartedAt: number - updatedAt: number - } - >() + const rowSources = new Map() + const now = Date.now() for (const snapshot of this.latestAgentStatusByPaneKey.values()) { const { payload } = snapshot rowSources.set(snapshot.paneKey, { @@ -20394,7 +20455,9 @@ export class OrcaRuntimeService { tabId: snapshot.tabId, worktreeId: snapshot.worktreeId, connectionId: snapshot.connectionId, + payload, state: payload.state, + ...(payload.workingMode ? { workingMode: payload.workingMode } : {}), agentType: payload.agentType ?? null, prompt: payload.prompt, lastAssistantMessage: payload.lastAssistantMessage ?? null, @@ -20411,9 +20474,22 @@ export class OrcaRuntimeService { continue } const existing = rowSources.get(entry.paneKey) + const hookPayload = pickParsedAgentStatusPayload(entry) // Why: hook rows win ties, but an older cached hook must not replace a // fresh OSC status and make a running mobile workspace look inactive. if (existing && existing.updatedAt > entry.receivedAt) { + if ( + entry.workingMode === 'monitoring' && + // restoredUnconfirmed rows already `continue` above. + now - entry.receivedAt <= AGENT_STATUS_STALE_AFTER_MS && + terminalStatusPayloadMatchesHook(hookPayload, existing.payload) + ) { + // Why: older OSC reporters cannot express hook-authoritative monitoring mode. + existing.workingMode = 'monitoring' + if (existing.payload.workingMode === undefined) { + existing.payload = { ...existing.payload, workingMode: 'monitoring' } + } + } continue } rowSources.set(entry.paneKey, { @@ -20424,7 +20500,9 @@ export class OrcaRuntimeService { tabId: entry.tabId, worktreeId: entry.worktreeId, connectionId: entry.connectionId, + payload: hookPayload, state: entry.state, + ...(entry.workingMode ? { workingMode: entry.workingMode } : {}), agentType: entry.agentType ?? null, prompt: entry.prompt, lastAssistantMessage: entry.lastAssistantMessage ?? null, @@ -20440,7 +20518,6 @@ export class OrcaRuntimeService { } const orchestrationByPaneKey = this.buildAgentOrchestrationByPaneKey() const rowsByWorktree = new Map() - const now = Date.now() for (const src of rowSources.values()) { // Why: hooks retain launch-time attribution across automatic workspace // renames; the tab's current mirrored owner is authoritative when present. @@ -20487,6 +20564,7 @@ export class OrcaRuntimeService { paneKey: src.paneKey, parentPaneKey: orchestrationByPaneKey?.[src.paneKey]?.parentPaneKey ?? null, state: src.state, + ...(src.workingMode ? { workingMode: src.workingMode } : {}), agentType: src.agentType, prompt: src.prompt, taskTitle, @@ -20513,6 +20591,8 @@ export class OrcaRuntimeService { const summary = summaries.get(worktreeId) if (summary) { summary.agents = rows + let hasForegroundWorkingAgent = false + const monitoringSources: RuntimeWorktreeAgentSource[] = [] for (const row of rows) { if (!isFreshNonDoneAgentStatus(row, now)) { continue @@ -20520,9 +20600,31 @@ export class OrcaRuntimeService { // Why: worktree.ps is mobile's host-sidebar parity source, so a live // agent must survive the same temporary PTY gaps as desktop. summary.hasHostSidebarActivity = true - summary.status = mergeWorktreeStatus( - summary.status, - row.state === 'working' ? 'working' : 'permission' + if (row.state === 'working') { + if (row.workingMode === 'monitoring') { + const source = rowSources.get(row.paneKey) + if (source) { + monitoringSources.push(source) + } + } else { + hasForegroundWorkingAgent = true + } + } else { + mergeWorktreeSummaryStatus(summary, 'permission') + } + } + if (hasForegroundWorkingAgent || monitoringSources.length > 0) { + const hasIndependentWorkingTerminal = ( + workingTerminalEvidenceByWorktreeId.get(worktreeId) ?? [] + ).some((evidence) => + monitoringSources.every( + (source) => !runtimeWorkingTerminalEvidenceMatchesSource(evidence, source) + ) + ) + mergeWorktreeSummaryStatus( + summary, + 'working', + hasForegroundWorkingAgent || hasIndependentWorkingTerminal ? undefined : 'monitoring' ) } } @@ -30307,6 +30409,12 @@ export class OrcaRuntimeService { }) } + // Why: a leader handle that never binds to a PTY (lost pane race) has no exit + // or close path to evict its team, so the abandoning caller must release it. + releaseClaudeAgentTeamsLeaderForHandle(handle: string): void { + this.claudeAgentTeams.removeTeamForLeaderHandle(handle) + } + private waitForNewLeafInTab( tabId: string, existingLeafKeys: Set, @@ -35600,23 +35708,36 @@ export class OrcaRuntimeService { * later transition. A provider screen that is still working when this fires * resolves through the poll, not here. */ private startTuiIdleVisibleReadProbe(waiter: TerminalWaiter, waiterTimeoutMs: number): void { - const snapshotTimeoutMs = Math.min( - VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS, - Math.max(0, waiterTimeoutMs - 1) + const settleMarginMs = Math.min( + TUI_IDLE_VISIBLE_PROBE_SETTLE_MARGIN_MS, + Math.max(1, Math.floor(waiterTimeoutMs / 3)) ) + const probeTimeoutMs = Math.min( + VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS + settleMarginMs, + Math.max(0, waiterTimeoutMs - settleMarginMs) + ) + const providerTimeoutMs = Math.min( + VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS, + Math.max(0, probeTimeoutMs - settleMarginMs) + ) + // Node clamps sub-millisecond timers to 1ms, so no distinct retirement deadline exists. + if (providerTimeoutMs < 1) { + return + } + // Retire the provider before the detached probe and waiter can settle. void withTimeout( this.readTerminal( waiter.handle, {}, { - timeoutMs: snapshotTimeoutMs, + timeoutMs: providerTimeoutMs, retireOnTimeout: true, // Why: the ready banner stays in scrollback for the whole session, so // classifying history would call a working agent idle (#15569 review). visibleScreenOnly: true } ), - snapshotTimeoutMs, + probeTimeoutMs, null ) .then((read) => { @@ -38641,6 +38762,7 @@ const MAX_TERMINAL_PREVIEW_CHARS = 32 * 1024 export const AUTHORITATIVE_TERMINAL_SNAPSHOT_TIMEOUT_MS = 8_000 const VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS = 750 const VISIBLE_TERMINAL_SNAPSHOT_RETRY_MS = 1_000 +const TUI_IDLE_VISIBLE_PROBE_SETTLE_MARGIN_MS = 10 const MAX_PREVIEW_LINES = 6 const MAX_PREVIEW_CHARS = 300 const WORKTREE_STATUS_PRIORITY: Record = { @@ -40931,11 +41053,58 @@ function mapExplicitAgentStateToRuntimeTerminalStatus( } } -function mergeWorktreeStatus( - current: RuntimeWorktreeStatus, - next: RuntimeWorktreeStatus -): RuntimeWorktreeStatus { - return WORKTREE_STATUS_PRIORITY[next] > WORKTREE_STATUS_PRIORITY[current] ? next : current +function addRuntimeWorkingTerminalEvidence( + evidenceByWorktreeId: Map, + worktreeId: string, + evidence: RuntimeWorkingTerminalEvidence +): void { + const existing = evidenceByWorktreeId.get(worktreeId) + if (existing) { + existing.push(evidence) + } else { + evidenceByWorktreeId.set(worktreeId, [evidence]) + } +} + +function runtimeWorkingTerminalEvidenceMatchesSource( + evidence: RuntimeWorkingTerminalEvidence, + source: RuntimeWorktreeAgentSource +): boolean { + if (evidence.paneKey) { + return ( + evidence.paneKey === source.paneKey || + Boolean(evidence.ptyId && source.ptyId && evidence.ptyId === source.ptyId) + ) + } + if (evidence.ptyId && source.ptyId) { + return evidence.ptyId === source.ptyId + } + return Boolean(evidence.tabId && evidence.tabId === source.tabId) +} + +function mergeWorktreeSummaryStatus( + summary: RuntimeWorktreePsSummary, + next: RuntimeWorktreeStatus, + nextWorkingMode?: RuntimeWorktreePsSummary['workingMode'] +): void { + const currentPriority = WORKTREE_STATUS_PRIORITY[summary.status] + const nextPriority = WORKTREE_STATUS_PRIORITY[next] + if (nextPriority > currentPriority) { + summary.status = next + if (next === 'working' && nextWorkingMode === 'monitoring') { + summary.workingMode = 'monitoring' + } else { + delete summary.workingMode + } + return + } + if (nextPriority === currentPriority && next === 'working') { + if (nextWorkingMode === 'monitoring') { + summary.workingMode = 'monitoring' + } else { + delete summary.workingMode + } + } } function normalizeTerminalChunk( diff --git a/src/main/runtime/orchestration/mail-pointer-repoint-scheduler.ts b/src/main/runtime/orchestration/mail-pointer-repoint-scheduler.ts index 5d495d920c4..cf2af89d240 100644 --- a/src/main/runtime/orchestration/mail-pointer-repoint-scheduler.ts +++ b/src/main/runtime/orchestration/mail-pointer-repoint-scheduler.ts @@ -20,6 +20,11 @@ export class MailPointerRepointScheduler { this.timersByHandle.set(handle, timer) } + /** Handles still awaiting a repoint; a quiet scheduler holds none. */ + get pendingCount(): number { + return this.timersByHandle.size + } + clear(): void { for (const timer of this.timersByHandle.values()) { clearTimeout(timer) diff --git a/src/main/runtime/pty-exit-agent-status-reconciliation.test.ts b/src/main/runtime/pty-exit-agent-status-reconciliation.test.ts new file mode 100644 index 00000000000..f516e4f5b6d --- /dev/null +++ b/src/main/runtime/pty-exit-agent-status-reconciliation.test.ts @@ -0,0 +1,91 @@ +/** + * A PTY that exits without a resolvable spawn-time pane key never reaches + * `agentHookServer.clearPaneState`, so its row and every Claude latch outlive the process with + * nothing left to retire them (STA-4612). `onPtyExit` knows the pane keys that teardown could not + * resolve, so it reconciles them — but only on a real death certificate. + */ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { makePaneKey } from '../../shared/stable-pane-id' + +const LEAF = '11111111-1111-4111-8111-111111111111' +const PANE = makePaneKey('tab-1', LEAF) +const PTY = 'wt-1__pty-1' + +type RuntimeInternals = { + ptysById: Map +} + +function runtimeWithBoundPane( + reconcile: (paneKeys: Iterable) => void, + options: { connectionId?: string } = {} +): OrcaRuntimeService { + const runtime = new OrcaRuntimeService(null, undefined, { + reconcileAgentStatusForEndedProcess: reconcile + }) + const ptys = (runtime as unknown as RuntimeInternals).ptysById + ptys.set(PTY, { + ptyId: PTY, + paneKey: PANE, + worktreeId: 'wt-1', + tabId: 'tab-1', + ...(options.connectionId ? { connectionId: options.connectionId } : {}) + } as never) + return runtime +} + +function reconciledPaneKeys(calls: Iterable[]): string[] { + return calls.flatMap((keys) => [...keys]) +} + +describe('onPtyExit agent-status reconciliation', () => { + it('reconciles on a normal zero exit code', () => { + const reconcile = vi.fn() + runtimeWithBoundPane(reconcile).onPtyExit(PTY, 0) + + expect(reconciledPaneKeys(reconcile.mock.calls.map(([keys]) => keys))).toContain(PANE) + }) + + it('reconciles a physical -1 exit reported by the provider callback', () => { + // node-pty forwards real exits as -1 (local-pty-provider-shutdown.test.ts pins this), so the + // numeric code alone cannot separate a dead process from a failed stop. + const reconcile = vi.fn() + runtimeWithBoundPane(reconcile).onPtyExit(PTY, -1, undefined, { providerExitObserved: true }) + + expect(reconciledPaneKeys(reconcile.mock.calls.map(([keys]) => keys))).toContain(PANE) + }) + + it('reconciles a host-confirmed negative exit', () => { + const reconcile = vi.fn() + runtimeWithBoundPane(reconcile).onPtyExit(PTY, -1, undefined, { hostExitConfirmed: true }) + + expect(reconciledPaneKeys(reconcile.mock.calls.map(([keys]) => keys))).toContain(PANE) + }) + + it('reconciles a provider-observed -1 on a pane bound to a transport, where the SSH surface is preserved', () => { + // A WSL pane carries a `wsl:*` connectionId, so a negative exit keeps the abnormal-SSH surface + // (liveness unverifiable, launch authority not retired) — but the daemon provider's own exit + // callback still witnessed the process die. The two decisions are deliberately independent; + // without this the connectionId arm of the certificate is never exercised. + const reconcile = vi.fn() + runtimeWithBoundPane(reconcile, { connectionId: 'wsl:Ubuntu' }).onPtyExit(PTY, -1, undefined, { + providerExitObserved: true + }) + + expect(reconciledPaneKeys(reconcile.mock.calls.map(([keys]) => keys))).toContain(PANE) + }) + + it('does NOT reconcile a transport-bound synthetic -1, whose remote PTY is designed to survive it', () => { + const reconcile = vi.fn() + runtimeWithBoundPane(reconcile, { connectionId: 'ssh-conn-1' }).onPtyExit(PTY, -1) + + expect(reconcile).not.toHaveBeenCalled() + }) + + it('does NOT reconcile a synthetic -1 from a failed stop, whose PTY may have survived', () => { + const reconcile = vi.fn() + runtimeWithBoundPane(reconcile).onPtyExit(PTY, -1) + + expect(reconcile).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/relay/desktop-relay-service-broker-liveness.test.ts b/src/main/runtime/relay/desktop-relay-service-broker-liveness.test.ts new file mode 100644 index 00000000000..7eac4f1f324 --- /dev/null +++ b/src/main/runtime/relay/desktop-relay-service-broker-liveness.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrcaCloudAuthConfig } from '../../orca-profiles/profile-cloud-auth-config' +import type { OrcaRuntimeRpcServer } from '../runtime-rpc' + +const fakes = vi.hoisted(() => ({ + readRelayAuthContext: vi.fn(), + brokers: [] as { live: boolean }[] +})) + +vi.mock('./relay-auth-context', () => ({ readRelayAuthContext: fakes.readRelayAuthContext })) + +vi.mock('./relay-session-broker', () => { + // Mirrors the real broker: a control that died rejects with relay_control_not_active. + class RelaySessionBroker { + live = true + readonly hostId = 'relay-host-1' + readonly ownerIdentityKey = 'user-1\0profile-1\0org-1' + readonly endpoint = { v: 1 as const, relayHostId: 'relay-host-1' } + isLive(): boolean { + return this.live + } + closeNow(): void { + this.live = false + } + async createPairingRelay(relayDeviceId: string): Promise { + if (!this.live) { + throw new Error('relay_control_not_active') + } + return { v: 1, relayHostId: this.hostId, relayDeviceId, inviteExpiresAt: 0 } + } + static connect = vi.fn(async () => { + const broker = new RelaySessionBroker() + fakes.brokers.push(broker) + return broker + }) + } + return { RelaySessionBroker } +}) + +import { DesktopRelayService } from './desktop-relay-service' + +function service(): DesktopRelayService { + fakes.brokers.length = 0 + fakes.readRelayAuthContext.mockResolvedValue({ + identity: { userId: 'user-1', profileId: 'profile-1', organizationId: 'org-1' }, + accessToken: 'access-1', + relayEntitled: true + }) + const runtimeRpc = { + getE2EEKeypair: () => ({ + publicKey: new Uint8Array(32).fill(7), + secretKey: new Uint8Array(32).fill(9), + publicKeyB64: 'x' + }), + getMobileSocketWiring: () => ({ attachTransport: () => () => {} }), + getRelayRevokeOutbox: () => ({ pendingFor: () => [], remove: vi.fn() }), + getDeviceRegistry: () => ({ + listDevices: () => [], + getDevice: () => ({ deviceId: 'device-1', scope: 'mobile' }), + getMobilePairingConnectionMode: () => 'automatic' + }) + } as unknown as OrcaRuntimeRpcServer + return new DesktopRelayService({ + authConfig: { + relayDirectorUrl: 'https://relay.example.test', + relayTokenEndpoint: 'https://login.example.test/relay-token' + } as OrcaCloudAuthConfig, + userDataPath: '/tmp/orca-relay-liveness-test', + appVersion: '1.4.188', + runtimeRpc, + onStatus: () => {} + }) +} + +describe('DesktopRelayService broker liveness', () => { + it('pairs through a replacement when the owned broker control died', async () => { + // Why: ownership stays 'valid' after a control socket dies, so the stale + // handle otherwise reaches create_pairing_relay and fails the pairing. + const relayService = service() + try { + await expect(relayService.createPairingRelay('device-1')).resolves.toMatchObject({ + binding: { relayDeviceId: 'device-1' } + }) + expect(fakes.brokers).toHaveLength(1) + + fakes.brokers[0]!.live = false + + await expect(relayService.createPairingRelay('device-2')).resolves.toMatchObject({ + binding: { relayDeviceId: 'device-2' } + }) + expect(fakes.brokers).toHaveLength(2) + expect(fakes.brokers[1]!.live).toBe(true) + } finally { + relayService.stop() + } + }) + + it('keeps using a live broker instead of replacing it', async () => { + const relayService = service() + try { + await relayService.createPairingRelay('device-1') + await relayService.createPairingRelay('device-2') + expect(fakes.brokers).toHaveLength(1) + } finally { + relayService.stop() + } + }) +}) diff --git a/src/main/runtime/relay/desktop-relay-service.ts b/src/main/runtime/relay/desktop-relay-service.ts index 6ce62270750..55b993bd5a6 100644 --- a/src/main/runtime/relay/desktop-relay-service.ts +++ b/src/main/runtime/relay/desktop-relay-service.ts @@ -297,8 +297,7 @@ export class DesktopRelayService { } private async activeBrokerForDemand(): Promise { - const broker = - this.coordinator.getActiveBroker() ?? (await this.coordinator.waitForActiveBroker()) + const broker = this.coordinator.getLiveBroker() ?? (await this.coordinator.waitForLiveBroker()) return broker instanceof RelaySessionBroker ? broker : null } diff --git a/src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts b/src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts index 8bfc71688e0..1cbdb600568 100644 --- a/src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts +++ b/src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts @@ -259,6 +259,40 @@ describe('RelayAuthCoordinator transient recovery', () => { }) describe('RelayAuthCoordinator liveness safety net', () => { + it('withholds a dead broker from control work while identity matching still sees it', async () => { + vi.useFakeTimers() + const dead = { closeNow: vi.fn(), isLive: () => false } + const coordinator = new RelayAuthCoordinator({ + readContext: async () => context, + openBroker: vi.fn().mockResolvedValue(dead), + onStatus: vi.fn(), + random: () => 0.5 + }) + + coordinator.reconcile() + await vi.advanceTimersByTimeAsync(0) + expect(coordinator.getActiveBroker()).toBe(dead) + expect(coordinator.getLiveBroker()).toBeNull() + }) + + it('treats a broker that cannot report liveness as usable, not dead', async () => { + // Why: unverifiable is not exited — only a broker that proves its control + // died is withheld; silence must never cost a working relay. + vi.useFakeTimers() + const unverifiable = { closeNow: vi.fn() } + const coordinator = new RelayAuthCoordinator({ + readContext: async () => context, + openBroker: vi.fn().mockResolvedValue(unverifiable), + onStatus: vi.fn(), + random: () => 0.5 + }) + + coordinator.reconcile() + await vi.advanceTimersByTimeAsync(0) + expect(coordinator.getLiveBroker()).toBe(unverifiable) + await expect(coordinator.waitForLiveBroker()).resolves.toBe(unverifiable) + }) + it('reopens a broker that died leaving no retry timer behind', async () => { // Why: an auth refresh failing past token expiry closes the broker with // no timer; only ensureLive (periodic/power-resume) can revive it. diff --git a/src/main/runtime/relay/relay-auth-coordinator.test.ts b/src/main/runtime/relay/relay-auth-coordinator.test.ts index 005b33fb8df..819ccf15da5 100644 --- a/src/main/runtime/relay/relay-auth-coordinator.test.ts +++ b/src/main/runtime/relay/relay-auth-coordinator.test.ts @@ -26,7 +26,7 @@ describe('RelayAuthCoordinator', () => { onStatus: (status) => statuses.push(status) }) coordinator.reconcile() - await coordinator.waitForActiveBroker() + await coordinator.waitForLiveBroker() expect(openBroker).not.toHaveBeenCalled() expect(statuses.at(-1)).toBe('standby') }) @@ -43,10 +43,10 @@ describe('RelayAuthCoordinator', () => { lingerMs: 250 }) coordinator.reconcile() - await coordinator.waitForActiveBroker() + await coordinator.waitForLiveBroker() demanded = true coordinator.reconcile() - await expect(coordinator.waitForActiveBroker()).resolves.toBe(broker) + await expect(coordinator.waitForLiveBroker()).resolves.toBe(broker) demanded = false coordinator.reconcile() await vi.waitFor(() => expect(statuses.at(-1)).toBe('standby')) @@ -65,7 +65,7 @@ describe('RelayAuthCoordinator', () => { lingerMs: 20 }) coordinator.reconcile() - await expect(coordinator.waitForActiveBroker()).resolves.toBe(broker) + await expect(coordinator.waitForLiveBroker()).resolves.toBe(broker) demanded = false coordinator.reconcile() demanded = true @@ -85,10 +85,10 @@ describe('RelayAuthCoordinator', () => { lingerMs: 10_000 }) coordinator.reconcile() - await expect(coordinator.waitForActiveBroker()).resolves.toBe(broker) + await expect(coordinator.waitForLiveBroker()).resolves.toBe(broker) current = { ...context, identity: { ...context.identity, profileId: 'profile-2' } } coordinator.reconcile() - await coordinator.waitForActiveBroker() + await coordinator.waitForLiveBroker() expect(broker.closeNow).toHaveBeenCalledOnce() }) diff --git a/src/main/runtime/relay/relay-auth-coordinator.ts b/src/main/runtime/relay/relay-auth-coordinator.ts index f6539842f8a..ae439a320d9 100644 --- a/src/main/runtime/relay/relay-auth-coordinator.ts +++ b/src/main/runtime/relay/relay-auth-coordinator.ts @@ -88,10 +88,18 @@ export class RelayAuthCoordinator { this.options.onStatus('offline') } + // Raw ownership handle for identity matching (revoke routing); control work uses getLiveBroker. getActiveBroker(): CoordinatedRelayBroker | null { return this.ownership?.valid ? this.ownership.broker : null } + // Why: ownership stays valid across a control death, so control work must + // apply the same liveness gate reconcile does; unprovable liveness stays usable. + getLiveBroker(): CoordinatedRelayBroker | null { + const broker = this.getActiveBroker() + return broker && (broker.isLive?.() ?? true) ? broker : null + } + // Why: some broker deaths end with no retry timer — an auth refresh that // fails past token expiry (laptop sleep), or a transient context read that // returned null at open. Periodic/power-resume callers use this as a @@ -108,16 +116,16 @@ export class RelayAuthCoordinator { this.beginReconcile(false) } - async waitForActiveBroker(): Promise { + async waitForLiveBroker(): Promise { while (!this.stopped) { - const broker = this.getActiveBroker() + const broker = this.getLiveBroker() if (broker) { return broker } const pending = this.latestReconcile await pending if (pending === this.latestReconcile) { - return this.getActiveBroker() + return this.getLiveBroker() } } return null diff --git a/src/main/runtime/rpc/methods/ai-vault.test.ts b/src/main/runtime/rpc/methods/ai-vault.test.ts index 0eb9535a097..c46c077b768 100644 --- a/src/main/runtime/rpc/methods/ai-vault.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault.test.ts @@ -79,6 +79,14 @@ function makeDispatcher(): RpcDispatcher { return new RpcDispatcher({ runtime, methods: AI_VAULT_METHODS }) } +function makeFailingDispatcher(error: Error): RpcDispatcher { + const runtime = { + getRuntimeId: () => 'test-runtime', + listAiVaultSessions: vi.fn().mockRejectedValue(error) + } as unknown as OrcaRuntimeService + return new RpcDispatcher({ runtime, methods: AI_VAULT_METHODS }) +} + describe('aiVault.resolveSessionTitles handler', () => { beforeEach(() => { resolveAiVaultSessionTitlesInWorker.mockReset() @@ -229,6 +237,36 @@ describe('aiVault.listSessions handler + shared cache', () => { expect(response).toMatchObject({ ok: true, result: makeResult() }) }) + it('humanizes scanner supervision errors for remote clients', async () => { + const dispatcher = makeFailingDispatcher(new Error('AI Vault service restart circuit is open.')) + + await expect( + dispatcher.dispatch(makeRequest('aiVault.listSessions', { limit: 500 })) + ).resolves.toMatchObject({ + ok: false, + error: { message: 'Session scanning paused after repeated failures. Refresh to try again.' } + }) + }) + + it('preserves structured runtime error metadata while humanizing its message', async () => { + const error = Object.assign(new Error('AI Vault service restart circuit is open.'), { + code: 'runtime_timeout', + data: { retryAfterMs: 5000 } + }) + const dispatcher = makeFailingDispatcher(error) + + await expect( + dispatcher.dispatch(makeRequest('aiVault.listSessions', { limit: 500 })) + ).resolves.toMatchObject({ + ok: false, + error: { + code: 'runtime_timeout', + message: 'Session scanning paused after repeated failures. Refresh to try again.', + data: { retryAfterMs: 5000 } + } + }) + }) + it('passes only the first 64 scopePaths to the scanner when a request exceeds the cap', async () => { const dispatcher = makeDispatcher() const scopePaths = Array.from({ length: 65 }, (_, index) => `/p/${index}`) diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index 9578c903eb2..ce4f05b9f76 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -5,6 +5,7 @@ import { restampAiVaultListResult } from '../../../ai-vault/session-list-results import { AI_VAULT_AGENTS, AI_VAULT_SCOPE_PATHS_MAX_COUNT } from '../../../../shared/ai-vault-types' import { AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT } from '../../../../shared/ai-vault-session-title' import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../../shared/execution-host' +import { describeAiVaultScanError } from '../../../../shared/ai-vault-scan-error-message' // Why: bound limit + scopePaths so a client cannot force an unbounded scan. // Each scopePath is a host-local match prefix (validated/capped, never used for @@ -83,12 +84,21 @@ export const AI_VAULT_METHODS: RpcMethod[] = [ name: 'aiVault.listSessions', params: AiVaultListSessionsParams, handler: async (params, { runtime }) => { - const result = await runtime.listAiVaultSessions({ - limit: params.unlimited ? undefined : params.limit, - unlimited: params.unlimited, - force: params.force, - scopePaths: params.scopePaths - }) + let result + try { + result = await runtime.listAiVaultSessions({ + limit: params.unlimited ? undefined : params.limit, + unlimited: params.unlimited, + force: params.force, + scopePaths: params.scopePaths + }) + } catch (error) { + if (error instanceof Error) { + error.message = describeAiVaultScanError(error.message) + throw error + } + throw new Error(describeAiVaultScanError(String(error))) + } // Why: web clients consume this response directly (no parent-side retag), // so sessions must come back stamped as the runtime host they addressed. return params.executionHostId diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-prompt-contract.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-prompt-contract.test.ts new file mode 100644 index 00000000000..89455f9cc0a --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-prompt-contract.test.ts @@ -0,0 +1,277 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AGENT_PROMPT_BRACKETED_PASTE_END } from '../../../../shared/agent-prompt-injection' +import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../shared/protocol-version' +import { + AGENT_PROMPT_TEST_WORKTREE_ID, + createAgentPromptSubmissionRuntime +} from '../../agent-prompt-submission-runtime-test-fixture' +import { OrchestrationDb } from '../../orchestration/db' +import type { RpcRequest } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { ORCHESTRATION_METHODS } from './orchestration' + +vi.mock('../../../git/worktree', () => ({ + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/prompt-contract', + isBare: false, + isMainWorktree: false + } + ]), + listWorktreesStrict: vi.fn().mockResolvedValue([ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/prompt-contract', + isBare: false, + isMainWorktree: false + } + ]) +})) + +const COORDINATOR_PANE_KEY = 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +const WORKER_PANE_KEY = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +const REQUEST_ID = 'worker_start_prompt_contract' +const openDatabases: OrchestrationDb[] = [] +const temporaryRoots: string[] = [] + +type PromptContractHarness = { + db: OrchestrationDb + dbPath: string + dispatcher: RpcDispatcher + request: RpcRequest + requestId: string + taskId: string + submittedTurns: () => number + startedTurns: () => number + prematureSubmits: () => number + writes: string[] +} + +async function createPromptContractHarness( + outcome: 'accepted' | 'swallowed' +): Promise { + let composerReady = false + let submittedTurns = 0 + let startedTurns = 0 + let prematureSubmits = 0 + const fixture = await createAgentPromptSubmissionRuntime((runtime, data) => { + if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) { + setTimeout(() => runtime.onPtyData('pty-prompt', 'partial composer frame', Date.now()), 650) + setTimeout(() => runtime.onPtyData('pty-prompt', '\x1b[?25h', Date.now()), 750) + setTimeout(() => { + composerReady = true + runtime.onPtyData('pty-prompt', 'final composer frame', Date.now()) + }, 1_000) + return + } + if (data !== '\r') { + return + } + submittedTurns += 1 + if (!composerReady) { + prematureSubmits += 1 + } + if (outcome === 'accepted') { + startedTurns += 1 + runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) + } + }, 'codex') + const { runtime, handle } = fixture + runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now()) + + const temporaryRoot = mkdtempSync(join(tmpdir(), 'orca-worker-prompt-contract-')) + temporaryRoots.push(temporaryRoot) + const dbPath = join(temporaryRoot, 'orchestration.db') + const db = new OrchestrationDb(dbPath) + openDatabases.push(db) + runtime.setOrchestrationDb(db) + const run = db.createRun({ + objective: 'Worker prompt contract', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: COORDINATOR_PANE_KEY + }) + const task = db.createTask({ spec: 'start exactly one worker turn', runId: run.id }) + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((candidate) => + candidate === 'term_coord' + ? COORDINATOR_PANE_KEY + : candidate === handle + ? WORKER_PANE_KEY + : null + ) + vi.spyOn(runtime, 'getTerminalProcessIncarnation').mockImplementation((candidate) => + candidate === handle ? `runtime_test:${handle}:1` : null + ) + vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {}) + vi.spyOn(runtime, 'showTerminal').mockResolvedValue({ + handle: 'term_coord', + worktreeId: 'repo::parent', + status: 'running' + } as never) + vi.spyOn(runtime, 'showManagedWorktree').mockResolvedValue({ + id: 'repo::parent', + repoId: 'repo-1' + } as never) + vi.spyOn(runtime, 'showRepo').mockResolvedValue({ id: 'repo-1', kind: 'git' } as never) + vi.spyOn(runtime, 'createManagedWorktree').mockResolvedValue({ + worktree: { id: AGENT_PROMPT_TEST_WORKTREE_ID, repoId: 'repo-1' }, + startupTerminal: { spawned: true, handle }, + setupReceipt: { + requested: 'run', + hookFound: false, + startupPolicy: 'start-immediately', + state: 'not_configured' + } + } as never) + vi.spyOn(runtime, 'getTerminalOrchestrationCliCommand').mockReturnValue('orca') + + return { + db, + dbPath, + dispatcher: new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }), + request: { + id: `rpc_${outcome}`, + authToken: 'caller-token', + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: `${REQUEST_ID}_${outcome}`, + method: 'orchestration.workerStart', + params: { + task: task.id, + from: 'term_coord', + worktree: 'new-child', + name: `prompt-contract-${outcome}`, + agent: 'codex' + } + }, + requestId: `${REQUEST_ID}_${outcome}`, + taskId: task.id, + submittedTurns: () => submittedTurns, + startedTurns: () => startedTurns, + prematureSubmits: () => prematureSubmits, + writes: fixture.writes + } +} + +function reopenPromptContractDb(harness: PromptContractHarness): OrchestrationDb { + const index = openDatabases.indexOf(harness.db) + if (index !== -1) { + openDatabases.splice(index, 1) + } + harness.db.close() + harness.db = new OrchestrationDb(harness.dbPath) + openDatabases.push(harness.db) + return harness.db +} + +describe('orchestration worker-start prompt contract', () => { + afterEach(() => { + for (const db of openDatabases.splice(0)) { + db.close() + } + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } + vi.useRealTimers() + }) + + it('durably accepts exactly one submitted and started turn', async () => { + vi.useFakeTimers() + const harness = await createPromptContractHarness('accepted') + const pending = harness.dispatcher.dispatch(harness.request) + + await vi.runAllTimersAsync() + const response = await pending + expect(response).toMatchObject({ + ok: true, + result: { + state: 'ready', + stage: 'input_accepted', + mutation: { requestId: harness.requestId, replayed: false }, + effects: expect.arrayContaining([ + expect.objectContaining({ kind: 'dispatch_input', state: 'accepted' }) + ]) + } + }) + if (!response.ok) { + throw new Error(response.error.message) + } + const dispatchId = (response.result as { dispatchId: string }).dispatchId + expect(harness.submittedTurns()).toBe(1) + expect(harness.startedTurns()).toBe(1) + expect(harness.prematureSubmits()).toBe(0) + expect(harness.writes.filter((data) => data === '\r')).toHaveLength(1) + const persisted = reopenPromptContractDb(harness) + expect(persisted.getTask(harness.taskId)?.status).toBe('dispatched') + expect(persisted.getDispatchContextById(dispatchId)).toMatchObject({ + status: 'dispatched', + capability_hash: expect.any(String), + capability_revoked_at: null + }) + expect(persisted.getWorkerDispatch(dispatchId)).toMatchObject({ + state: 'ready', + stage: 'input_accepted', + last_error: null + }) + const callerFingerprint = persisted.getOrCreateLocalMutationCallerFingerprint() + const receipt = persisted.getMutationReceipt(callerFingerprint, harness.requestId) + expect(receipt).toMatchObject({ state: 'completed' }) + expect(JSON.parse(receipt?.receipt ?? 'null')).toMatchObject({ + dispatchId, + state: 'ready', + stage: 'input_accepted' + }) + }) + + it('reports a swallowed Enter as stalled without sending a rescue Enter', async () => { + vi.useFakeTimers() + const harness = await createPromptContractHarness('swallowed') + const pending = harness.dispatcher.dispatch(harness.request) + + await vi.runAllTimersAsync() + const response = await pending + expect(response).toMatchObject({ + ok: true, + result: { + state: 'failed', + failedStage: 'dispatch_input', + lastError: 'agent_prompt_stalled', + mutation: { requestId: harness.requestId, replayed: false } + } + }) + if (!response.ok) { + throw new Error(response.error.message) + } + const dispatchId = (response.result as { dispatchId: string }).dispatchId + await vi.advanceTimersByTimeAsync(20_000) + expect(harness.submittedTurns()).toBe(1) + expect(harness.startedTurns()).toBe(0) + expect(harness.prematureSubmits()).toBe(0) + expect(harness.writes.filter((data) => data === '\r')).toHaveLength(1) + const persisted = reopenPromptContractDb(harness) + expect(persisted.getTask(harness.taskId)?.status).toBe('failed') + expect(persisted.getDispatchContextById(dispatchId)).toMatchObject({ + status: 'failed', + last_failure: 'agent_prompt_stalled', + capability_revoked_at: expect.any(String) + }) + expect(persisted.getWorkerDispatch(dispatchId)).toMatchObject({ + state: 'failed', + stage: 'dispatch_input', + last_error: 'agent_prompt_stalled' + }) + const callerFingerprint = persisted.getOrCreateLocalMutationCallerFingerprint() + const receipt = persisted.getMutationReceipt(callerFingerprint, harness.requestId) + expect(receipt).toMatchObject({ state: 'completed' }) + expect(JSON.parse(receipt?.receipt ?? 'null')).toMatchObject({ + dispatchId, + state: 'failed', + failedStage: 'dispatch_input', + lastError: 'agent_prompt_stalled' + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/paired-caller-host-id.test.ts b/src/main/runtime/rpc/methods/paired-caller-host-id.test.ts new file mode 100644 index 00000000000..551e5f5cacb --- /dev/null +++ b/src/main/runtime/rpc/methods/paired-caller-host-id.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import { resolvePairedCallerHostId } from './paired-caller-host-id' + +function repos(...hostIds: (string | undefined)[]): Repo[] { + return hostIds.map((executionHostId, index) => ({ + id: 'repo-1', + path: `/repo-${index}`, + executionHostId + })) as unknown as Repo[] +} + +const SELECTOR = 'id:repo-1::/repo/wt' + +describe('resolvePairedCallerHostId', () => { + it('passes through our own vocabulary and an absent qualifier', () => { + expect(resolvePairedCallerHostId(() => repos('local'), SELECTOR, 'local')).toBe('local') + expect(resolvePairedCallerHostId(() => repos('ssh:a'), SELECTOR, 'ssh:a')).toBe('ssh:a') + expect(resolvePairedCallerHostId(() => repos('local'), SELECTOR, undefined)).toBeUndefined() + }) + + it('answers with `local` when nothing carries the client-minted stamp', () => { + expect(resolvePairedCallerHostId(() => repos('local'), SELECTOR, 'runtime:env-1')).toBe('local') + expect(resolvePairedCallerHostId(() => repos('ssh:a'), SELECTOR, 'runtime:env-1')).toBe('local') + expect(resolvePairedCallerHostId(() => [], SELECTOR, 'runtime:env-1')).toBe('local') + }) + + it('keeps a stamp this store still uses for its own repo', () => { + expect(resolvePairedCallerHostId(() => repos('runtime:env-1'), SELECTOR, 'runtime:env-1')).toBe( + 'runtime:env-1' + ) + }) + + it('ignores another repo id carrying the other spelling', () => { + const otherRepo = { id: 'repo-2', path: '/other' } as unknown as Repo + expect( + resolvePairedCallerHostId( + () => [...repos('runtime:env-1'), otherRepo], + SELECTOR, + 'runtime:env-1' + ) + ).toBe('runtime:env-1') + }) + + it('refuses to guess when one repo id carries both spellings', () => { + expect(() => + resolvePairedCallerHostId(() => repos('local', 'runtime:env-1'), SELECTOR, 'runtime:env-1') + ).toThrow('ambiguous across hosts') + }) + + it('falls back to every repo when the selector names no repo id', () => { + expect( + resolvePairedCallerHostId(() => repos('runtime:env-1'), 'feature/x', 'runtime:env-1') + ).toBe('runtime:env-1') + expect(resolvePairedCallerHostId(() => repos('local'), 'feature/x', 'runtime:env-1')).toBe( + 'local' + ) + }) +}) diff --git a/src/main/runtime/rpc/methods/paired-caller-host-id.ts b/src/main/runtime/rpc/methods/paired-caller-host-id.ts new file mode 100644 index 00000000000..da418c57479 --- /dev/null +++ b/src/main/runtime/rpc/methods/paired-caller-host-id.ts @@ -0,0 +1,34 @@ +import { + getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' +import type { Repo } from '../../../../shared/repo-types' +import { splitWorktreeId } from '../../../../shared/worktree/id' + +/** Maps a paired client's runtime-local host spelling to this host's repo spelling. */ +export function resolvePairedCallerHostId( + getRepos: () => readonly Repo[], + worktreeSelector: string, + callerHostId: ExecutionHostId | undefined +): ExecutionHostId | undefined { + if (!callerHostId || parseExecutionHostId(callerHostId)?.kind !== 'runtime') { + return callerHostId + } + const repoId = splitWorktreeId( + worktreeSelector.startsWith('id:') ? worktreeSelector.slice(3) : worktreeSelector + )?.repoId + const spellings = new Set( + getRepos() + .filter((repo) => (repoId ? repo.id === repoId : true)) + .map((repo) => getRepoExecutionHostId(repo)) + .filter((hostId) => hostId === LOCAL_EXECUTION_HOST_ID || hostId === callerHostId) + ) + if (spellings.size > 1) { + throw new Error( + `Workspace identity is ambiguous across hosts: ${worktreeSelector}. Retry with an explicit host.` + ) + } + return [...spellings][0] ?? LOCAL_EXECUTION_HOST_ID +} diff --git a/src/main/runtime/rpc/methods/terminal-legacy-stream-id-characterization.test.ts b/src/main/runtime/rpc/methods/terminal-legacy-stream-id-characterization.test.ts new file mode 100644 index 00000000000..3f3630b0ffa --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal-legacy-stream-id-characterization.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RuntimeTerminalWait } from '../../../../shared/runtime-types' +import { createSubscriptionRegistryDouble } from '../subscription-registry-test-double' +import { TERMINAL_METHODS } from './terminal' + +function request(params: unknown): RpcRequest { + return { + id: 'request', + authToken: 'token', + method: 'terminal.subscribe', + params + } +} + +describe('legacy terminal subscription stream IDs', () => { + it('allocates binary and JSON subscriptions from one monotonically increasing owner', async () => { + const registry = createSubscriptionRegistryDouble() + const runtime = { + getRuntimeId: () => 'runtime', + resolveLeafForHandle: vi.fn(() => ({ ptyId: 'pty' })), + readTerminal: vi.fn(async () => ({ tail: [], truncated: false })), + serializeTerminalBuffer: vi.fn(async () => ({ data: '', cols: 80, rows: 24 })), + getTerminalSize: vi.fn(() => ({ cols: 80, rows: 24 })), + getMobileDisplayMode: vi.fn(() => 'auto'), + getLayout: vi.fn(() => ({ seq: 1 })), + subscribeToTerminalData: vi.fn(() => () => {}), + registerRemoteTerminalViewSubscriber: vi.fn(() => () => {}), + subscribeToTerminalResize: vi.fn(() => () => {}), + subscribeToFitOverrideChanges: vi.fn(() => () => {}), + subscribeToPtyExit: vi.fn(() => () => {}), + registerSubscriptionCleanup: vi.fn(registry.registerSubscriptionCleanup), + registerOwnedSubscriptionCleanup: vi.fn(registry.registerOwnedSubscriptionCleanup), + cleanupSubscription: vi.fn(registry.cleanupSubscription), + waitForTerminal: vi.fn(() => new Promise(() => {})) + } as unknown as Partial + const dispatcher = new RpcDispatcher({ + runtime: runtime as OrcaRuntimeService, + methods: TERMINAL_METHODS + }) + + const subscribeBinary = async (terminal: string, clientId: string) => { + const messages: string[] = [] + const pending = dispatcher.dispatchStreaming( + request({ + terminal, + client: { id: clientId, type: 'desktop' }, + capabilities: { terminalBinaryStream: 1 } + }), + (message) => messages.push(message), + { connectionId: `connection-${clientId}`, sendBinary: () => {} } + ) + await vi.waitFor(() => + expect(messages.some((message) => JSON.parse(message).result?.type === 'subscribed')).toBe( + true + ) + ) + const subscribed = messages + .map((message) => JSON.parse(message)) + .find((message) => message.result?.type === 'subscribed') + const streamId = subscribed?.result?.streamId + if (typeof streamId !== 'number') { + throw new Error('Missing legacy binary stream ID') + } + runtime.cleanupSubscription?.(`${terminal}:${clientId}`) + await pending + return streamId + } + + const first = await subscribeBinary('terminal-a', 'client-a') + + const jsonMessages: string[] = [] + const jsonPending = dispatcher.dispatchStreaming( + request({ terminal: 'terminal-json', client: { id: 'client-json', type: 'desktop' } }), + (message) => jsonMessages.push(message), + { connectionId: 'connection-json' } + ) + await vi.waitFor(() => + expect( + jsonMessages.some((message) => JSON.parse(message).result?.type === 'scrollback') + ).toBe(true) + ) + runtime.cleanupSubscription?.('terminal-json:client-json') + await jsonPending + + const second = await subscribeBinary('terminal-b', 'client-b') + expect(second).toBe(first + 2) + }) +}) diff --git a/src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts b/src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts new file mode 100644 index 00000000000..daa25165d3d --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { TERMINAL_METHODS } from './terminal' +import { + TerminalMultiplexLegacyAckFrame, + TerminalMultiplexSourceRangeAckFrame, + TerminalMultiplexSubscribeFrame +} from './terminal/stream-schemas' + +const METHOD_CASES: readonly (readonly [string, unknown, boolean])[] = [ + ['terminal.list', {}, false], + ['terminal.resolveActive', {}, false], + ['terminal.resolvePane', { paneKey: 'pane' }, false], + ['terminal.recoverPane', { paneKey: 'pane', worktreeId: 'worktree' }, false], + ['terminal.show', { terminal: 'term' }, false], + ['terminal.read', { terminal: 'term' }, false], + ['terminal.inspectProcess', { terminal: 'term' }, false], + ['terminal.isRunningAgent', { terminal: 'term' }, false], + ['terminal.agentStatus', { terminal: 'term' }, false], + ['terminal.rename', { terminal: 'term', title: null }, false], + ['terminal.clearBuffer', { terminal: 'term' }, false], + ['terminal.send', { terminal: 'term', text: 'x' }, false], + ['terminal.wait', { terminal: 'term', for: 'exit' }, false], + ['terminal.create', {}, false], + ['terminal.split', { terminal: 'term' }, false], + ['terminal.stop', { worktree: 'worktree' }, false], + ['terminal.sleep', { worktree: 'worktree' }, false], + ['terminal.stopExact', { worktree: 'worktree', expectedPtyIds: ['pty'] }, false], + ['terminal.resizeForClient', { terminal: 'term', mode: 'restore', clientId: 'client' }, false], + ['terminal.focus', { terminal: 'term' }, false], + ['terminal.close', { terminal: 'term' }, false], + ['terminal.closeTab', { terminal: 'term' }, false], + ['agentTeams.tmuxCompat', { teamId: 'team', token: 'token', envPane: 'pane', argv: [] }, false], + ['agentTeams.prepareLaunch', { paneKey: 'pane' }, false], + ['terminal.setDisplayMode', { terminal: 'term', mode: 'auto' }, false], + ['terminal.restoreFit', { terminal: 'term' }, false], + ['terminal.getDisplayMode', { terminal: 'term' }, false], + [ + 'terminal.updateViewport', + { terminal: 'term', client: { id: 'client' }, viewport: { cols: 80, rows: 24 } }, + false + ], + ['terminal.multiplex', {}, true], + ['terminal.subscribe', { terminal: 'term' }, true], + ['terminal.unsubscribe', { subscriptionId: 'term' }, false], + ['terminal.getAutoRestoreFit', {}, false], + ['terminal.setAutoRestoreFit', { ms: null }, false] +] + +function schemaFor(name: string) { + const method = TERMINAL_METHODS.find((candidate) => candidate.name === name) + if (!method?.params) { + throw new Error(`Missing terminal schema: ${name}`) + } + return method.params +} +async function invoke(name: string, params: unknown, runtime: Partial) { + const method = TERMINAL_METHODS.find((candidate) => candidate.name === name) + if (!method?.params || 'stream' in method) { + throw new Error(`Missing unary terminal method: ${name}`) + } + return method.handler(method.params.parse(params), { runtime: runtime as OrcaRuntimeService }) +} + +describe('terminal RPC manifest characterization', () => { + it('preserves all method names, order, streaming flags, and parseable minimum inputs', () => { + expect(TERMINAL_METHODS).toHaveLength(33) + expect(TERMINAL_METHODS.map((method) => [method.name, 'stream' in method])).toEqual( + METHOD_CASES.map(([name, _params, stream]) => [name, stream]) + ) + expect(new Set(TERMINAL_METHODS.map((method) => method.name)).size).toBe(33) + for (const [name, params] of METHOD_CASES) { + expect(() => schemaFor(name).parse(params), name).not.toThrow() + } + }) + + it('keeps legacy coercion and viewport-boundary differences', () => { + expect(schemaFor('terminal.list').parse({ limit: 'invalid' })).toEqual({}) + expect(schemaFor('terminal.split').parse({ terminal: 'term', direction: 'diagonal' })).toEqual({ + terminal: 'term' + }) + expect(() => schemaFor('terminal.rename').parse({ terminal: 'term' })).toThrow() + expect(schemaFor('terminal.rename').parse({ terminal: 'term', title: '' })).toEqual({ + terminal: 'term', + title: '' + }) + expect(() => + schemaFor('terminal.updateViewport').parse({ + terminal: 'term', + client: { id: 'client' }, + viewport: { cols: 241, rows: 120 } + }) + ).toThrow() + expect(() => + schemaFor('terminal.subscribe').parse({ + terminal: 'term', + viewport: { cols: 1000, rows: 500 } + }) + ).not.toThrow() + expect(() => + schemaFor('terminal.setDisplayMode').parse({ + terminal: 'term', + mode: 'auto', + viewport: { cols: 1001, rows: 501 } + }) + ).not.toThrow() + }) + + it('keeps multiplex control objects strict while subscribe remains skew-tolerant', () => { + expect(TerminalMultiplexLegacyAckFrame.safeParse({ bytes: 1, future: true }).success).toBe( + false + ) + expect( + TerminalMultiplexSourceRangeAckFrame.safeParse({ + streamGeneration: 'generation', + ackedEndByte: 1, + future: true + }).success + ).toBe(false) + expect( + TerminalMultiplexSubscribeFrame.parse({ + streamId: 1, + terminal: 'term', + futureCapability: 1 + }) + ).toEqual({ streamId: 1, terminal: 'term' }) + }) + + it('returns execution-host authority from lifecycle handlers without reinterpretation', async () => { + const pane = { + handle: 'term-pane', + executionHostId: 'ssh-host', + hostPlatform: 'win32' + } + const recovered = { + handle: 'term-recovered', + executionHostId: 'folder-host', + hostPlatform: 'linux' + } + const shown = { + handle: 'term-shown', + executionHostId: 'ssh-host', + hostPlatform: 'win32' + } + const split = { + handle: 'term-split', + executionHostId: 'folder-host', + hostPlatform: 'linux' + } + const runtime = { + resolveTerminalPane: vi.fn(() => pane), + recoverTerminalPane: vi.fn(async () => recovered), + showTerminal: vi.fn(async () => shown), + splitTerminal: vi.fn(async () => split) + } as unknown as Partial + + await expect(invoke('terminal.resolvePane', { paneKey: 'pane' }, runtime)).resolves.toEqual({ + terminal: pane + }) + await expect( + invoke('terminal.recoverPane', { paneKey: 'pane', worktreeId: 'folder-worktree' }, runtime) + ).resolves.toEqual({ terminal: recovered }) + await expect(invoke('terminal.show', { terminal: 'term' }, runtime)).resolves.toEqual({ + terminal: shown + }) + await expect(invoke('terminal.split', { terminal: 'term' }, runtime)).resolves.toEqual({ + split + }) + }) + + it('keeps remote and folder selectors opaque through terminal.create', async () => { + const created = { + handle: 'term-created', + executionHostId: 'ssh-host', + hostPlatform: 'win32' + } + const createTerminal = vi.fn(async () => created) + const runtime = { + dedupeTerminalCreate: vi.fn( + async ( + _owner: string, + selector: string | undefined, + _mutationId: string | undefined, + _reconcile: boolean, + create: (selector: string | undefined, preAllocatedHandle?: string) => Promise + ) => create(selector, 'term-preallocated') + ), + createTerminal + } as unknown as Partial + const selector = 'ssh://windows-host/C:/Users/dev/repo' + + await expect( + invoke( + 'terminal.create', + { worktree: selector, clientMutationId: 'mutation', command: 'provider resume-token' }, + runtime + ) + ).resolves.toEqual({ terminal: created }) + expect(createTerminal).toHaveBeenCalledWith( + selector, + expect.objectContaining({ + command: 'provider resume-token', + preAllocatedHandle: 'term-preallocated' + }) + ) + }) +}) diff --git a/src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts b/src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts new file mode 100644 index 00000000000..db05c6b3fe5 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, vi } from 'vitest' +import { + TerminalStreamOpcode, + decodeTerminalStreamJson, + encodeTerminalStreamFrame +} from '../../../../shared/terminal-stream-protocol' +import { sendSnapshotFrames } from './terminal/terminal-snapshot-publication' +import { TerminalSourceRangeRegistry } from '../terminal-source-range-registry' +import { initializeMultiplexStream } from './terminal/terminal-multiplex-stream-initialization' +import type { TerminalMultiplexConnection } from './terminal/terminal-multiplex-connection' +import type { OrcaRuntimeService } from '../../orca-runtime' + +// These assertions protect the permanent binary publication seam while its state machine +// lives in concrete terminal domain modules. +describe('terminal stream extraction characterization', () => { + it('publishes an empty snapshot as Start then End without a synthetic chunk', () => { + const frames: { + opcode: TerminalStreamOpcode + payload?: Uint8Array + }[] = [] + const stats = sendSnapshotFrames( + (opcode, payload) => { + frames.push({ opcode, payload }) + }, + { + kind: 'scrollback', + cols: 80, + rows: 24, + data: '', + seq: 41, + truncated: false, + truncatedByByteBudget: false + } + ) + + expect(frames.map((frame) => frame.opcode)).toEqual([ + TerminalStreamOpcode.SnapshotStart, + TerminalStreamOpcode.SnapshotEnd + ]) + expect(decodeTerminalStreamJson(frames[0]!.payload ?? new Uint8Array())).toEqual({ + kind: 'scrollback', + cols: 80, + rows: 24, + seq: 41, + truncated: false, + truncatedByByteBudget: false + }) + expect(stats).toEqual({ bytes: 0, chunks: 0, published: true }) + }) + + it('stops publication immediately when SnapshotStart is rejected', () => { + const opcodes: TerminalStreamOpcode[] = [] + const stats = sendSnapshotFrames( + (opcode) => { + opcodes.push(opcode) + return false + }, + { + kind: 'scrollback', + cols: 80, + rows: 24, + data: 'unpublished' + } + ) + + expect(opcodes).toEqual([TerminalStreamOpcode.SnapshotStart]) + expect(stats).toEqual({ bytes: 0, chunks: 0, published: false }) + }) + + it('keeps the permanent v1 header bytes and opcode assignments', () => { + const seq = 2 ** 32 + 0x89abcdef + const bytes = encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.WriteUnavailable, + streamId: 0x78563412, + seq, + payload: new Uint8Array([0xaa]) + }) + const header = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + + expect(Array.from(bytes.slice(0, 4))).toEqual([0x74, 1, 17, 0]) + expect(header.getUint32(4, true)).toBe(0x78563412) + expect(header.getUint32(8, true)).toBe(1) + expect(header.getUint32(12, true)).toBe(0x89abcdef) + expect(bytes[16]).toBe(0xaa) + expect([ + TerminalStreamOpcode.Output, + TerminalStreamOpcode.SnapshotStart, + TerminalStreamOpcode.SnapshotChunk, + TerminalStreamOpcode.SnapshotEnd, + TerminalStreamOpcode.Resized, + TerminalStreamOpcode.Error, + TerminalStreamOpcode.Input, + TerminalStreamOpcode.Resize, + TerminalStreamOpcode.Subscribe, + TerminalStreamOpcode.Unsubscribe, + TerminalStreamOpcode.SnapshotRequest, + TerminalStreamOpcode.Metadata, + TerminalStreamOpcode.Ack, + TerminalStreamOpcode.ClaimViewport, + TerminalStreamOpcode.OutputSpan, + TerminalStreamOpcode.SetOutputPaused, + TerminalStreamOpcode.WriteUnavailable + ]).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17]) + }) + + it('lets a slot-handler registration throw escape before per-stream catch ownership begins', async () => { + const streams = new Map() + const runtime = { + attachRemoteTerminalSourceRangeConsumer: vi.fn(() => false) + } as unknown as OrcaRuntimeService + const state = { + runtime, + connectionId: 'connection', + streams, + sourceRangeRegistry: new TerminalSourceRangeRegistry(), + registerBinaryStreamHandler: vi.fn(() => { + throw new Error('registrar-failed') + }), + sendFrame: vi.fn(), + queueOrSendOutput: vi.fn() + } as unknown as TerminalMultiplexConnection + const installed = vi.fn() + + await expect( + initializeMultiplexStream(state, { streamId: 7, terminal: 'terminal' }, 'pty', installed) + ).rejects.toThrow('registrar-failed') + expect(installed).not.toHaveBeenCalled() + expect(streams.has(7)).toBe(true) + }) +}) diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 5dee92a7676..e80d07773dc 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -1,3826 +1,22 @@ -/* oxlint-disable max-lines -- Why: terminal RPC methods are co-located for discoverability; splitting would scatter related handlers across files. */ -import { z } from 'zod' -import { randomUUID } from 'node:crypto' +import type { RpcAnyMethod } from '../core' +import { TERMINAL_LIFECYCLE_METHODS } from './terminal/terminal-lifecycle-methods' +import { TERMINAL_MULTIPLEX_METHODS } from './terminal/terminal-multiplex-method' +import { TERMINAL_QUERY_METHODS } from './terminal/terminal-query-methods' +import { TERMINAL_SEND_METHODS } from './terminal/terminal-send-method' +import { TERMINAL_SUBSCRIBE_METHODS } from './terminal/terminal-subscribe-method' import { - InvalidArgumentError, - defineMethod, - defineStreamingMethod, - type RpcAnyMethod -} from '../core' -import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' -import type { DriverState, OrcaRuntimeService, SubscriptionRegistration } from '../../orca-runtime' -import { - TerminalStreamOpcode, - decodeTerminalStreamJson, - decodeTerminalStreamText, - encodeTerminalStreamFrame, - encodeTerminalStreamJson, - encodeTerminalStreamText, - type TerminalStreamFrame -} from '../../../../shared/terminal-stream-protocol' -import { - iterateTerminalOutputFrameChunks, - sliceTerminalOutputSourceRanges, - type TerminalOutputFrameChunk, - type TerminalOutputMeta -} from '../terminal-output-frame-chunks' -import { TERMINAL_PANE_SPLIT_SOURCES } from '../../../../shared/feature-education-telemetry' -import type { TerminalOscLinkRange } from '../../../../shared/terminal-osc-link-ranges' -import { - TERMINAL_INPUT_MAX_BYTES, - TERMINAL_INPUT_TOO_LARGE_ERROR, - isTerminalInputTooLargeWithYield -} from '../../../../shared/terminal-input' -import { - measureTerminalStreamByteLength, - terminalStreamByteLength, - terminalStreamByteLengthExceeds -} from '../terminal-stream-byte-length' -import { isTuiAgent } from '../../../../shared/tui-agent-config' -import { isTerminalQueryReply } from '../../../../shared/terminal-query-reply' -import { - EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE, - scanTerminalReplyQuerySequences, - type TerminalReplyQuerySequence, - type TerminalReplyQueryScanState -} from '../../../../shared/terminal-reply-query-scan' -import { - MOBILE_SNAPSHOT_BYTE_BUDGET, - MOBILE_SUBSCRIBE_SCROLLBACK_ROWS -} from '../../scrollback-limits' -import { assertTerminalAgentSendable } from '../terminal-agent-send-guard' -import { - navigationTargetsHost, - resolveRuntimeNavigationTarget -} from '../../../../shared/runtime-navigation' -import { - TERMINAL_MULTIPLEX_ACK_STREAM_INITIAL_WINDOW_BYTES, - TERMINAL_MULTIPLEX_ACK_STREAM_MAX_WINDOW_BYTES, - TERMINAL_MULTIPLEX_ACK_TOTAL_INITIAL_WINDOW_BYTES, - TERMINAL_MULTIPLEX_ACK_TOTAL_MAX_WINDOW_BYTES, - TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION, - TERMINAL_MULTIPLEX_MAX_PENDING_PTY_WAITS_PER_CONNECTION, - TERMINAL_MULTIPLEX_PENDING_MAX_BYTES, - TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR, - TERMINAL_OUTPUT_BATCH_MAX_BYTES -} from '../../../../shared/terminal-multiplex-flow-control' -import { drainTerminalMultiplexRoundRobin } from '../terminal-multiplex-round-robin' -import type { TerminalSourceRangeLedger } from '../terminal-source-range-ledger' -import { TerminalSourceRangeRegistry } from '../terminal-source-range-registry' -import { - sameTerminalOutputSourceIdentity, - type TerminalOutputSourceRange -} from '../../../../shared/terminal-output-source-range' -import type { TerminalSnapshotUnavailableReason } from '../../../../shared/terminal-snapshot-unavailability' -import type { RemoteTerminalSourceRangeReplacementReservation } from '../../remote-terminal-source-range-consumer' -import { withTerminalCloseAttribution } from '../terminal-close-attribution' - -const REQUESTED_SNAPSHOT_BYTE_BUDGET = 2 * 1024 * 1024 -const TERMINAL_OUTPUT_FLUSH_MS = 5 -const TERMINAL_QUERY_REPLAY_MAX_CHARS = 16 * 1024 -// Why: bound initial subscribe latency; readiness after this deadline triggers an in-stream recovery snapshot. -const MOBILE_RENDERER_MOUNT_READY_TIMEOUT_MS = 3_000 -let nextTerminalStreamId = 1 - -type SnapshotFrameOptions = { - kind: 'scrollback' | 'resized' - cols: number - rows: number - data: string - requestId?: number - displayMode?: string - reason?: string - seq?: number - cwd?: string | null - truncated?: boolean - truncatedByByteBudget?: boolean - // Why: distinguishes "I could not answer right now" from a genuinely empty buffer; omitted on success. - unavailable?: TerminalSnapshotUnavailableReason - source?: 'headless' | 'renderer' - oscLinks?: TerminalOscLinkRange[] - pendingEscapeTailAnsi?: string - /** Effective kitty flags proven at this frame's own `seq`. */ - kittyKeyboardFlags?: number -} - -type SerializedSnapshot = { - data: string - scrollbackAnsi?: string - cols: number - rows: number - seq?: number - cwd?: string | null - source?: 'headless' | 'renderer' - oscLinks?: TerminalOscLinkRange[] - scrollbackRows: number - truncatedByByteBudget: boolean - pendingEscapeTailAnsi?: string - kittyKeyboardFlags?: number -} | null - -type TerminalViewportClient = { - id: string - type?: 'mobile' | 'desktop' -} - -type TerminalMultiplexStream = { - streamId: number - terminal: string - ptyId: string - client: TerminalViewportClient | undefined - isMobile: boolean - ackOutput: boolean - ackOutputSourceRanges: boolean - streamGeneration: string - sourceRangeLedger: TerminalSourceRangeLedger | null - sourceRangeConsumerAttached: boolean - sourceRangeReplacement: RemoteTerminalSourceRangeReplacementReservation | null - ackInFlightBytes: number - ackWindowBytes: number - supportsOutputPause: boolean - supportsWriteUnavailable: boolean - outputPaused: boolean - supportsDesktopViewportClaims: boolean - desktopClaimTail: Promise - // Whether THIS stream registered the width driver, so detach won't release a peer stream's floor. - registeredRemoteDesktopDriver: boolean - remoteDesktopSubscriptionKey: string - pendingRemoteDesktopViewport: { cols: number; rows: number } | null - buffering: boolean - ackPendingOutput: TerminalOutputFrameChunk[] - ackPendingOutputBytes: number - ackPendingOutputOverflowed: boolean - ackRecoverySnapshotInFlight: boolean - pendingOutput: TerminalOutputChunk[] - pendingOutputBytes: number - pendingOutputOverflowed: boolean - // Cols the mobile client last rewrapped to; re-stream full scrollback only when width actually changes. - lastResizeCols: number | undefined - resizeGeneration: number - outputBatcher: ReturnType - unsubscribeData: () => void - unsubscribeResize: () => void - unsubscribeFit: () => void - unsubscribeDriver: () => void - unregisterBinaryHandler: () => void - // Why: the runtime drops the exit-waiter only on real PTY exit; abort on detach so a never-exiting agent terminal doesn't leak the waiter. - exitWaiterAbort: AbortController -} - -type TerminalOutputChunk = { - data: string - bytes: number - meta?: TerminalOutputMeta -} - -function createTerminalOutputBatcher(onFlush: (data: string, meta?: TerminalOutputMeta) => void): { - push: (data: string, meta?: TerminalOutputMeta) => void - flush: () => void - dispose: () => void -} { - let chunks: string[] = [] - let bytes = 0 - let lastSeq: number | undefined - let pendingCwd: string | undefined - let pendingRawLength = 0 - let pendingSourceRanges: TerminalOutputSourceRange[] = [] - let timer: ReturnType | null = null - - const clearTimer = (): void => { - if (!timer) { - return - } - clearTimeout(timer) - timer = null - } - - const flush = (): void => { - clearTimer() - if (chunks.length === 0 && pendingRawLength === 0) { - return - } - const data = chunks.length === 1 ? chunks[0]! : chunks.join('') - const meta = - typeof lastSeq === 'number' || pendingCwd !== undefined || pendingSourceRanges.length > 0 - ? { - ...(typeof lastSeq === 'number' ? { seq: lastSeq, rawLength: pendingRawLength } : {}), - ...(pendingCwd !== undefined ? { cwd: pendingCwd } : {}), - ...(pendingSourceRanges.length > 0 - ? { sourceRanges: Object.freeze(pendingSourceRanges.slice()) } - : {}) - } - : undefined - chunks = [] - bytes = 0 - lastSeq = undefined - pendingCwd = undefined - pendingRawLength = 0 - pendingSourceRanges = [] - onFlush(data, meta) - } - - return { - push(data: string, meta?: TerminalOutputMeta): void { - const rawLength = meta?.rawLength ?? data.length - if (!data && rawLength === 0) { - return - } - if (meta?.transformed || rawLength !== data.length) { - flush() - onFlush(data, { ...meta, rawLength, transformed: true }) - return - } - const nextSourceRanges = meta?.sourceRanges ?? [] - const lastSourceRange = pendingSourceRanges.at(-1) - const firstNextSourceRange = nextSourceRanges[0] - if ( - chunks.length > 0 && - (pendingSourceRanges.length > 0 !== nextSourceRanges.length > 0 || - (lastSourceRange && - firstNextSourceRange && - (!sameTerminalOutputSourceIdentity(lastSourceRange, firstNextSourceRange) || - lastSourceRange.displayEnd !== firstNextSourceRange.displayStart))) - ) { - flush() - } - if (meta?.cwd !== undefined) { - flush() - pendingCwd = meta.cwd - } - chunks.push(data) - pendingRawLength += rawLength - pendingSourceRanges.push(...nextSourceRanges) - const remainingBudget = Math.max(1, TERMINAL_OUTPUT_BATCH_MAX_BYTES - bytes) - const measurement = measureTerminalStreamByteLength(data, { - stopAfterBytes: remainingBudget - }) - bytes += measurement.byteLength - if (typeof meta?.seq === 'number') { - lastSeq = meta.seq - } - if (measurement.exceededLimit || bytes >= TERMINAL_OUTPUT_BATCH_MAX_BYTES) { - flush() - return - } - if (!timer) { - // Why: coalesce stream output before it crosses the network; desktop subscribers share the same burst boundary. - timer = setTimeout(flush, TERMINAL_OUTPUT_FLUSH_MS) - if (typeof timer.unref === 'function') { - timer.unref() - } - } - }, - flush, - dispose(): void { - clearTimer() - chunks = [] - bytes = 0 - pendingRawLength = 0 - pendingSourceRanges = [] - } - } -} - -function isTerminalInputLockedForClient( - runtime: OrcaRuntimeService, - ptyId: string, - client: TerminalViewportClient | undefined -): boolean { - if (client?.type === 'mobile') { - return false - } - // Why: pre-refactor mobile builds sent no client metadata, so treat a missing client as legacy mobile (unlocked). - if (!client) { - return false - } - return runtime.getDriver(ptyId).kind === 'mobile' -} - -async function assertTerminalSendTextWithinLimit(text: string | undefined): Promise { - if (!text) { - return - } - // Why: sends can be paste-sized; validate outside Zod so large input yields before runtime dispatch. - if (await isTerminalInputTooLargeWithYield(text, TERMINAL_INPUT_MAX_BYTES)) { - throw new InvalidArgumentError(TERMINAL_INPUT_TOO_LARGE_ERROR) - } -} - -function resolveMobileFloorClientId( - driver: DriverState | null, - client: TerminalViewportClient | undefined -): string | null { - if (client?.type === 'mobile') { - return client.id - } - if (!client && driver?.kind === 'mobile') { - return driver.clientId - } - return null -} - -type TerminalStreamInputOutcome = 'delivered' | 'rejected' | 'failed' - -function watchSubscriptionLifetime( - runtime: OrcaRuntimeService, - ptyId: string, - signal: AbortSignal | undefined, - registration: SubscriptionRegistration -): () => void { - let unsubscribeExit: (() => void) | null = null - let removeAbort: (() => void) | null = null - let stopped = false - const stop = (): void => { - stopped = true - unsubscribeExit?.() - removeAbort?.() - } - const release = (): void => { - registration.releaseIfCurrent() - stop() - } - unsubscribeExit = runtime.subscribeToPtyExit(ptyId, release) - if (stopped) { - unsubscribeExit() - return stop - } - if (!signal) { - return stop - } - if (signal.aborted) { - release() - return stop - } - const onAbort = (): void => release() - removeAbort = () => signal.removeEventListener('abort', onAbort) - signal.addEventListener('abort', onAbort, { once: true }) - if (stopped) { - removeAbort() - } - return stop -} - -function isTerminalStreamInputRejection(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error) - return message.includes('terminal_not_writable') || message.includes('terminal_handle_stale') -} - -async function sendTerminalStreamInput( - runtime: OrcaRuntimeService, - args: { - terminal: string - text: string - client: TerminalViewportClient | undefined - isMobile: boolean - } -): Promise { - const action = { text: args.text, enter: false, interrupt: false } - const clientId = args.isMobile ? args.client?.id : undefined - const floorClaim: MobileInputFloorClaimHolder = { current: null } - try { - if (!clientId) { - const result = await runtime.sendTerminal(args.terminal, action) - return result.accepted ? 'delivered' : 'rejected' - } - const result = await runtime.sendTerminal(args.terminal, action, { - reserveWrite: (writePtyId) => { - const claim = runtime.beginMobileInputFloor(writePtyId, clientId) - if (!claim) { - throw new Error('mobile_input_floor_unavailable') - } - floorClaim.current = claim - }, - afterWrite: () => commitMobileInputFloorClaim(floorClaim) - }) - if (!result.accepted) { - floorClaim.current?.rollback() - return 'rejected' - } - return 'delivered' - } catch (error) { - floorClaim.current?.rollback() - return isTerminalStreamInputRejection(error) ? 'rejected' : 'failed' - } -} - -type MobileInputFloorClaimHolder = { - current: ReturnType -} - -async function commitMobileInputFloorClaim(claim: MobileInputFloorClaimHolder): Promise { - const current = claim.current - if (!current) { - return - } - try { - await current.commit() - } finally { - // Why: the runtime may yield before the next write, which then needs a fresh reservation if desktop reclaimed the floor. - if (claim.current === current) { - claim.current = null - } - } -} - -function getTerminalSendGuardRefusedReason(error: unknown): 'no-agent' | 'permission' | undefined { - const message = error instanceof Error ? error.message : String(error) - if (message.includes('terminal_guard_permission')) { - return 'permission' - } - if (message.includes('terminal_guard_no_agent')) { - return 'no-agent' - } - return undefined -} - -function isTerminalSendGuardNotWritable(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error) - return message.includes('terminal_guard_not_writable') -} - -function assertTerminalSendExactPtyBinding( - runtime: OrcaRuntimeService, - handle: string, - expectedPtyId: string | undefined -): void { - try { - if (expectedPtyId && runtime.resolveLiveLeafForHandle(handle)?.ptyId === expectedPtyId) { - return - } - } catch { - // Fall through to the stable guarded-send result below. - } - throw new Error('terminal_guard_not_writable') -} - -function appendPendingMultiplexOutput( - stream: TerminalMultiplexStream, - data: string, - meta?: TerminalOutputMeta -): void { - const remainingBudget = Math.max( - 1, - TERMINAL_MULTIPLEX_PENDING_MAX_BYTES - stream.pendingOutputBytes - ) - const measurement = measureTerminalStreamByteLength(data, { - stopAfterBytes: remainingBudget - }) - stream.pendingOutput.push({ data, bytes: measurement.byteLength, meta }) - stream.pendingOutputBytes += measurement.byteLength - const trimmed = trimPendingOutputToBudget(stream.pendingOutput, stream.pendingOutputBytes) - stream.pendingOutputBytes = trimmed.bytes - stream.pendingOutputOverflowed ||= trimmed.overflowed -} - -function getOutputAfterSnapshotSeq( - chunk: TerminalOutputChunk, - snapshotSeq: number | undefined -): TerminalOutputChunk | null { - if ( - typeof snapshotSeq !== 'number' || - typeof chunk.meta?.seq !== 'number' || - typeof chunk.meta.rawLength !== 'number' - ) { - return chunk - } - if (chunk.meta.seq <= snapshotSeq) { - return null - } - const chunkStartSeq = chunk.meta.seq - chunk.meta.rawLength - if (chunkStartSeq >= snapshotSeq) { - return chunk - } - if (chunk.meta.transformed) { - return null - } - const offset = snapshotSeq - chunkStartSeq - return { - data: chunk.data.slice(offset), - bytes: chunk.bytes, - meta: { - ...chunk.meta, - rawLength: chunk.meta.rawLength - offset, - sourceRanges: sliceTerminalOutputSourceRanges( - chunk.meta.sourceRanges, - offset, - chunk.data.length - ) - } - } -} - -function stripSnapshotBoundaryQuerySuffixes( - data: string, - dataStartSeq: number, - snapshotSeq: number, - queries: TerminalReplyQuerySequence[] -): string { - let output = '' - let offset = 0 - for (const query of queries) { - if (query.startSeq >= snapshotSeq || query.endSeq <= snapshotSeq) { - continue - } - const removeStart = Math.max(0, query.startSeq - dataStartSeq) - const removeEnd = Math.min(data.length, query.endSeq - dataStartSeq) - if (removeEnd <= offset || removeStart >= data.length) { - continue - } - output += data.slice(offset, removeStart) - offset = removeEnd - } - return output + data.slice(offset) -} - -function appendAckPendingOutput( - stream: TerminalMultiplexStream, - chunk: TerminalOutputFrameChunk -): void { - stream.ackPendingOutput.push(chunk) - stream.ackPendingOutputBytes += chunk.bytes.byteLength - let omittedChunkCount = 0 - while ( - stream.ackPendingOutputBytes > TERMINAL_MULTIPLEX_PENDING_MAX_BYTES && - omittedChunkCount < stream.ackPendingOutput.length - ) { - stream.ackPendingOutputBytes -= stream.ackPendingOutput[omittedChunkCount]!.bytes.byteLength - omittedChunkCount += 1 - } - if (omittedChunkCount > 0) { - stream.ackPendingOutput.splice(0, omittedChunkCount) - stream.ackPendingOutputOverflowed = true - } -} - -function trimPendingOutputToBudget( - pendingOutput: TerminalOutputChunk[], - pendingOutputBytes: number -): { bytes: number; overflowed: boolean } { - let omittedChunkCount = 0 - while ( - pendingOutputBytes > TERMINAL_MULTIPLEX_PENDING_MAX_BYTES && - omittedChunkCount < pendingOutput.length - ) { - const chunk = pendingOutput[omittedChunkCount] - pendingOutputBytes -= chunk.bytes - omittedChunkCount += 1 - } - if (omittedChunkCount > 0) { - pendingOutput.splice(0, omittedChunkCount) - } - return { bytes: pendingOutputBytes, overflowed: omittedChunkCount > 0 } -} - -function trimPendingOutputCoveredBySnapshot( - pendingOutput: TerminalOutputChunk[], - snapshotSeq: number | undefined -): { chunks: TerminalOutputChunk[]; bytes: number } { - if (typeof snapshotSeq !== 'number') { - return { - chunks: pendingOutput, - bytes: pendingOutput.reduce((sum, chunk) => sum + chunk.bytes, 0) - } - } - const chunks: TerminalOutputChunk[] = [] - let bytes = 0 - for (const chunk of pendingOutput) { - const chunkSeq = chunk.meta?.seq - const rawLength = chunk.meta?.rawLength ?? chunk.data.length - if (typeof chunkSeq !== 'number' || rawLength !== chunk.data.length) { - chunks.push(chunk) - bytes += chunk.bytes - continue - } - const startSeq = chunkSeq - rawLength - if (snapshotSeq >= chunkSeq) { - continue - } - if (snapshotSeq <= startSeq) { - chunks.push(chunk) - bytes += chunk.bytes - continue - } - const data = chunk.data.slice(snapshotSeq - startSeq) - const slicedBytes = terminalStreamByteLength(data) - chunks.push({ data, bytes: slicedBytes, meta: undefined }) - bytes += slicedBytes - } - return { chunks, bytes } -} - -function* iterateTerminalStreamTextPayloads(data: string): Generator> { - if (!data) { - return - } - for (const chunk of iterateTerminalOutputFrameChunks(data)) { - yield chunk.bytes - } -} - -function isTerminalReadPayloadIncomplete(read: { truncated: boolean; limited?: boolean }): boolean { - // Why: a limited preview is an incomplete payload even when the retained buffer wasn't truncated. - return read.truncated || read.limited === true -} - -function normalizeMultiplexSnapshotScrollbackRows(value: number | undefined): number | undefined { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return undefined - } - return Math.max(0, Math.min(50_000, Math.floor(value))) -} - -function requestedSnapshotScrollbackCandidates(requestedRows: number | undefined): number[] { - const candidates = [requestedRows ?? 0, 1000, 500, 250, 100, 25, 0] - .filter((rows): rows is number => typeof rows === 'number') - .map((rows) => Math.max(0, Math.min(50_000, Math.floor(rows)))) - return [...new Set(candidates)] -} - -async function serializeBudgetedRequestedSnapshot( - runtime: OrcaRuntimeService, - ptyId: string, - scrollbackRows: number | undefined -): Promise { - const requestedRows = scrollbackRows ?? 0 - for (const rows of requestedSnapshotScrollbackCandidates(scrollbackRows)) { - const serialized = await runtime.serializeAuthoritativeTerminalBuffer(ptyId, { - scrollbackRows: rows - }) - if (!serialized) { - return null - } - const scrollbackAnsi = - 'scrollbackAnsi' in serialized && typeof serialized.scrollbackAnsi === 'string' - ? serialized.scrollbackAnsi - : '' - const data = scrollbackAnsi + serialized.data - const overByteBudget = terminalStreamByteLengthExceeds(data, REQUESTED_SNAPSHOT_BYTE_BUDGET) - if (!overByteBudget || rows === 0) { - return { - ...serialized, - data, - scrollbackRows: rows, - truncatedByByteBudget: rows < requestedRows || overByteBudget - } - } - } - return null -} - -function sendSnapshotFrames( - sendFrame: ( - opcode: TerminalStreamOpcode, - payload?: Uint8Array - ) => boolean | void, - options: SnapshotFrameOptions -): { bytes: number; chunks: number; published: boolean } { - if ( - sendFrame( - TerminalStreamOpcode.SnapshotStart, - encodeTerminalStreamJson({ - kind: options.kind, - cols: options.cols, - rows: options.rows, - requestId: options.requestId, - displayMode: options.displayMode, - reason: options.reason, - unavailable: options.unavailable, - seq: options.seq, - cwd: options.cwd, - source: options.source, - oscLinks: options.oscLinks, - pendingEscapeTailAnsi: options.pendingEscapeTailAnsi, - // Why conditional and additive: old clients ignore the unknown field, - // and a new client must read absence as unknown rather than zero, so - // no opcode or capability negotiation is involved (Rule 1 of - // docs/reference/remote-wire-compatibility.md). - // Why `seq` is required: the flags are only proven at this frame's own - // seq, so without a replay boundary the client cannot order them. - ...(typeof options.seq === 'number' && options.kittyKeyboardFlags !== undefined - ? { kittyKeyboardFlags: options.kittyKeyboardFlags } - : {}), - truncated: options.truncated === true, - truncatedByByteBudget: options.truncatedByByteBudget === true - }) - ) === false - ) { - return { bytes: 0, chunks: 0, published: false } - } - let chunks = 0 - let bytes = 0 - for (const chunk of iterateTerminalStreamTextPayloads(options.data)) { - if (sendFrame(TerminalStreamOpcode.SnapshotChunk, chunk) === false) { - return { bytes, chunks, published: false } - } - chunks++ - bytes += chunk.byteLength - } - const published = sendFrame(TerminalStreamOpcode.SnapshotEnd) !== false - return { bytes, chunks, published } -} - -async function serializeBudgetedMobileSnapshot( - runtime: OrcaRuntimeService, - ptyId: string, - isMobile: boolean -): Promise { - if (!isMobile) { - const serialized = await runtime.serializeTerminalBuffer(ptyId, { scrollbackRows: 0 }) - return serialized - ? { - ...serialized, - data: (serialized.scrollbackAnsi ?? '') + serialized.data, - scrollbackRows: 0, - truncatedByByteBudget: false - } - : null - } - const candidates = [MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, 500, 250, 100, 25, 0] - for (const rows of candidates) { - const serialized = await runtime.serializeTerminalBuffer(ptyId, { scrollbackRows: rows }) - if (!serialized) { - return null - } - const data = (serialized.scrollbackAnsi ?? '') + serialized.data - const overByteBudget = terminalStreamByteLengthExceeds(data, MOBILE_SNAPSHOT_BYTE_BUDGET) - if (!overByteBudget || rows === 0) { - return { - ...serialized, - data, - scrollbackRows: rows, - truncatedByByteBudget: rows < MOBILE_SUBSCRIBE_SCROLLBACK_ROWS || overByteBudget - } - } - } - return null -} - -async function serializeStableMobileRendererSnapshot( - runtime: OrcaRuntimeService, - ptyId: string -): Promise { - const candidates = [MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, 500, 250, 100, 25, 0] - let candidateIndex = 0 - for (let attempt = 0; attempt < candidates.length; attempt += 1) { - // Why: advance toward zero scrollback each retry so the final attempt always has a bounded payload. - candidateIndex = Math.max(candidateIndex, attempt) - const rows = candidates[candidateIndex] - const outputSequenceBefore = runtime.getPtyOutputSequence(ptyId) - const serialized = await runtime.serializeRendererTerminalBuffer(ptyId, { - scrollbackRows: rows - }) - const outputSequenceAfter = runtime.getPtyOutputSequence(ptyId) - if (outputSequenceBefore !== outputSequenceAfter) { - continue - } - if (!serialized) { - return null - } - const overByteBudget = terminalStreamByteLengthExceeds( - serialized.data, - MOBILE_SNAPSHOT_BYTE_BUDGET - ) - if (!overByteBudget || rows === 0) { - return { - ...serialized, - scrollbackRows: rows, - truncatedByByteBudget: rows < MOBILE_SUBSCRIBE_SCROLLBACK_ROWS || overByteBudget - } - } - candidateIndex += 1 - } - return null -} - -// Why: mobile xterm can't rewrap the HARD newlines baked into a restored snapshot, so a real reflow re-serializes and replays the FULL buffer at the new cols. -async function sendMobileResizeRestream( - runtime: OrcaRuntimeService, - ptyId: string, - sendFrame: (opcode: TerminalStreamOpcode, payload?: Uint8Array) => void, - event: { cols: number; rows: number; displayMode: string; reason: string; seq?: number }, - shouldSend?: () => boolean -): Promise { - // Why: only a true geometry reflow rewraps scrollback; a dimensionless mode-change would re-send the whole buffer for nothing. - if (event.reason !== 'apply-layout' || runtime.isTerminalAlternateScreen(ptyId)) { - return false - } - const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, true) - if (!serialized) { - return false - } - if (shouldSend && !shouldSend()) { - return true - } - sendSnapshotFrames(sendFrame, { - kind: 'resized', - cols: serialized.cols, - rows: serialized.rows, - displayMode: event.displayMode, - reason: event.reason, - seq: event.seq ?? serialized.seq, - source: serialized.source, - cwd: serialized.cwd, - oscLinks: serialized.oscLinks, - truncated: false, - truncatedByByteBudget: serialized.truncatedByByteBudget, - data: serialized.data - }) - return true -} - -async function updateViewportForClient( - runtime: OrcaRuntimeService, - ptyId: string, - subscriptionKey: string, - client: TerminalViewportClient, - viewport: { cols: number; rows: number }, - defaultType: 'mobile' | 'desktop', - // Why: the one-shot RPC has no disconnect hook, so 'refresh' only updates a stream-owned floor; stream paths that own cleanup 'register'. - registration: 'register' | 'refresh' = 'register', - claim = false -): Promise<{ updated: boolean; applied: boolean }> { - const type = client.type ?? defaultType - if (type === 'mobile') { - return runtime.updateMobileViewport(ptyId, client.id, viewport) - } - // Why: stream attachment observes geometry without taking control; a later claim frame makes it authoritative. - const updated = - registration === 'refresh' - ? await runtime.refreshRemoteDesktopViewer( - ptyId, - client.id, - viewport.cols, - viewport.rows, - claim - ) - : await runtime.updateRemoteDesktopViewer( - ptyId, - subscriptionKey, - client.id, - viewport.cols, - viewport.rows, - claim - ) - return { updated, applied: updated } -} - -const TerminalHandle = z.object({ - terminal: requiredString('Missing terminal handle') -}) - -const TerminalFocus = TerminalHandle.extend({ - navigation: z.enum(['caller', 'host']).optional() -}) - -const TerminalListParams = z.object({ - worktree: OptionalString, - limit: OptionalFiniteNumber, - handles: z - .array(requiredString('Missing terminal handle').pipe(z.string().max(256))) - .max(64) - .optional(), - requireFreshPtyLiveness: z.boolean().optional(), - // Why: layouts are ~31% of a large listing and only the human CLI formatter - // reads them. Absent means "include" so pre-flag clients keep rendering them. - includeVisualLayouts: z.boolean().optional() -}) - -const TerminalResolveActive = z.object({ - worktree: OptionalString -}) - -const TerminalResolvePane = z.object({ - paneKey: requiredString('Missing pane key'), - worktreeId: OptionalString -}) - -const TerminalRecoverPane = z.object({ - paneKey: requiredString('Missing pane key'), - worktreeId: requiredString('Missing worktree ID'), - expectedTerminal: requiredString('Missing expected terminal handle').optional() -}) - -const TerminalRead = TerminalHandle.extend({ - cursor: z - .unknown() - .transform((value) => { - if (value === undefined) { - return undefined - } - if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) { - return Number.NaN - } - return value - }) - .pipe( - z - .number() - .optional() - .refine((v) => v === undefined || Number.isFinite(v), { - message: 'Cursor must be a non-negative integer' - }) - ) - .optional(), - limit: OptionalFiniteNumber, - // Why: optional so an older host that does not understand it simply drops the key and answers - // with its usual stream read; the response's `source` is what tells the caller which it got. - screen: z.literal(true).optional() -}).refine((params) => !(params.screen === true && params.cursor !== undefined), { - // Why: a cursor pages through accumulated output; a screen is the current frame with nothing - // behind it. Honoring both would answer with rendered lines carrying the stream's pagination - // metadata — two frames of reference in one payload, which is the confusion `source` exists to - // remove. The CLI already refuses the pair, but the RPC is reachable without it. - message: 'Cursor cannot be combined with a screen read' -}) - -// Why: preserve the legacy contract — `title: string | null` only, `undefined` rejected, so the CLI's "reset" signal stays distinct. -const TerminalRename = TerminalHandle.extend({ - title: z.custom((value) => value === null || typeof value === 'string', { - message: 'Missing --title (pass empty string or null to reset)' - }) -}) - -const TerminalSend = TerminalHandle.extend({ - text: OptionalString, - enter: z.unknown().optional(), - interrupt: z.unknown().optional(), - // Why: older hosts strip this optional intent and retain their direct-send behavior. - agentPrompt: z.literal(true).optional(), - resolvedLaunchDraft: z - .object({ - text: z.string(), - createdAt: z.number().finite() - }) - .optional(), - requireAgentStatus: z.enum(['sendable']).optional(), - // Why: terminal-generated replies are valid input but must not transfer the shared terminal floor. - inputKind: z.enum(['query-reply']).optional(), - // Why: identifies the caller for the driver state machine; when absent (older clients) the server falls back to the most recent mobile actor (docs/mobile-presence-lock.md). - client: z - .object({ - id: requiredString('Missing client ID'), - type: z.enum(['mobile', 'desktop']).default('desktop').optional() - }) - .optional(), - viewport: z - .object({ - cols: z.number().int().min(1).max(1000), - rows: z.number().int().min(1).max(500) - }) - .optional(), - claimViewport: z.literal(true).optional() -}) - -const TerminalViewport = z.object({ - cols: z.number().int().min(1).max(1000), - rows: z.number().int().min(1).max(500) -}) - -const TerminalWait = TerminalHandle.extend({ - for: z.custom<'exit' | 'tui-idle'>((value) => value === 'exit' || value === 'tui-idle', { - message: 'Invalid --for value. Supported: exit, tui-idle' - }), - timeoutMs: OptionalFiniteNumber -}) - -const TerminalCreateParams = z.object({ - worktree: OptionalString, - clientMutationId: z.string().min(1).max(128).optional(), - reconcileExisting: z.boolean().optional(), - command: OptionalString, - startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(), - env: z.record(z.string(), z.string()).optional(), - envToDelete: z.array(z.string().min(1).max(256)).max(32).optional(), - launchConfig: z - .object({ - agentCommand: z.string().optional(), - agentArgs: z.string(), - agentEnv: z.record(z.string(), z.string()), - ompResumeFilePath: z - .string() - .min(1) - .max(32 * 1024) - .optional() - }) - .optional(), - resumeProviderSession: z - .object({ - key: z.enum(['session_id', 'conversation_id']), - id: z.string().min(1).max(512), - transcriptPath: z.string().min(1).max(32_768).optional() - }) - .optional(), - launchToken: OptionalString, - launchAgent: z.string().refine(isTuiAgent).optional(), - terminalColorQueryReplies: z - .object({ - foreground: z.string().max(128).optional(), - background: z.string().max(128).optional() - }) - .optional(), - title: OptionalString, - focus: z.unknown().optional(), - rendererBacked: z.unknown().optional(), - activate: z.unknown().optional(), - presentation: z.enum(['background', 'focused']).optional(), - tabId: OptionalString, - leafId: OptionalString -}) - -const TerminalSplit = TerminalHandle.extend({ - direction: z - .unknown() - .transform((v) => (v === 'vertical' || v === 'horizontal' ? v : undefined)) - .pipe(z.union([z.enum(['vertical', 'horizontal']), z.undefined()])) - .optional(), - command: OptionalString, - env: z.record(z.string(), z.string()).optional(), - telemetrySource: z.enum(TERMINAL_PANE_SPLIT_SOURCES).optional() -}) - -const TerminalStop = z.object({ - worktree: requiredString('Missing worktree selector') -}) - -const TerminalSleep = TerminalStop - -const TerminalStopExact = TerminalStop.extend({ - expectedPtyIds: z.array(requiredString('Missing PTY ID')).min(1), - keepHistory: z.boolean().optional(), - targetOnly: z.boolean().optional() -}) - -const AgentTeamsTmuxCompat = z.object({ - teamId: requiredString('Missing agent team ID'), - token: requiredString('Missing agent team token'), - envPane: requiredString('Missing tmux pane identity'), - cwd: OptionalString, - argv: z.array(z.string()) -}) - -const AgentTeamsPrepareLaunch = z.object({ - paneKey: requiredString('Missing pane key'), - env: z.record(z.string(), z.string()).optional() -}) - -const TerminalResizeForClient = z.discriminatedUnion('mode', [ - z.object({ - terminal: requiredString('Missing terminal handle'), - mode: z.literal('mobile-fit'), - cols: z.number().finite().positive(), - rows: z.number().finite().positive(), - clientId: requiredString('Missing client ID') - }), - z.object({ - terminal: requiredString('Missing terminal handle'), - mode: z.literal('restore'), - clientId: requiredString('Missing client ID') - }) -]) - -const TerminalSubscribe = TerminalHandle.extend({ - client: z - .object({ - id: requiredString('Missing client ID'), - type: z.enum(['mobile', 'desktop']).default('desktop') - }) - .optional(), - viewport: TerminalViewport.optional(), - capabilities: z - .object({ - terminalBinaryStream: z.literal(1).optional(), - desktopViewportClaims: z.literal(1).optional(), - mobileInputLeaseOnly: z.literal(1).optional(), - writeUnavailable: z.literal(1).optional() - }) - .optional() -}) - -const TerminalMultiplex = z.object({}) - -const TerminalMultiplexSubscribeFrame = TerminalHandle.extend({ - streamId: z.number().int().min(1), - client: z - .object({ - id: requiredString('Missing client ID'), - type: z.enum(['mobile', 'desktop']).default('desktop') - }) - .optional(), - viewport: TerminalViewport.optional(), - capabilities: z - .object({ - ackOutput: z.literal(1).optional(), - ackOutputSourceRanges: z.literal(1).optional(), - desktopViewportClaims: z.literal(1).optional(), - outputPause: z.literal(1).optional(), - writeUnavailable: z.literal(1).optional() - }) - .optional() -}) - -const TerminalMultiplexLegacyAckFrame = z - .object({ - bytes: z.number().int().nonnegative() - }) - .strict() - -const TerminalMultiplexSourceRangeAckFrame = z - .object({ - streamGeneration: z.string().min(1), - ackedEndByte: z.number().int().nonnegative() - }) - .strict() - -const TerminalMultiplexSnapshotRequestFrame = z.object({ - requestId: z.number().int().positive().optional(), - scrollbackRows: z.number().finite().optional() -}) - -const TerminalSetDisplayMode = TerminalHandle.extend({ - // Why: 'auto' = mobile drives dims while subscribed (desktop restores on last-leave); 'desktop' = no resize, mobile scales to fit. - mode: z.enum(['auto', 'desktop']), - // Why: identifies the caller for the driver state machine; optional for older mobile clients. - client: z - .object({ - id: requiredString('Missing client ID'), - type: z.enum(['mobile', 'desktop']).default('desktop').optional() - }) - .optional(), - // Why: carries the measured viewport so an 'auto' toggle on a viewport-less record can phone-fit instead of no-op'ing. - viewport: z - .object({ - cols: z.number().int().positive(), - rows: z.number().int().positive() - }) - .optional() -}) - -const TerminalUnsubscribe = z.object({ - subscriptionId: requiredString('Missing subscription ID'), - // Why: lets the server rebuild the composite `${terminal}:${clientId}` cleanup key when older clients pass a bare subscriptionId (docs/mobile-presence-lock.md). - client: z - .object({ - id: requiredString('Missing client ID') - }) - .optional() -}) - -// Why: in-place update avoids an unsubscribe→resubscribe that flashed the lock banner and stranded the PTY at phone dims (docs/mobile-presence-lock.md). -const TerminalUpdateViewport = TerminalHandle.extend({ - client: z.object({ - id: requiredString('Missing client ID'), - type: z.enum(['mobile', 'desktop']).default('mobile').optional() - }), - viewport: z.object({ - cols: z.number().int().min(20).max(240), - rows: z.number().int().min(8).max(120) - }), - claim: z.boolean().optional() -}) - -// Why: phone-fit auto-restore preference (docs/mobile-fit-hold.md); `null` = Indefinite, finite ms clamped to [5_000, 60min] server-side. -const TerminalSetAutoRestoreFit = z.object({ - ms: z.number().nullable() -}) + TERMINAL_VIEWPORT_METHODS_AFTER_STREAMS, + TERMINAL_VIEWPORT_METHODS_BEFORE_STREAMS +} from './terminal/terminal-viewport-methods' +// The manifest order is part of the released RPC contract. Keep composition here so the +// public entry point owns registration rather than forwarding an aggregated child export. export const TERMINAL_METHODS: RpcAnyMethod[] = [ - defineMethod({ - name: 'terminal.list', - params: TerminalListParams, - handler: async (params, { runtime }) => - runtime.listTerminals(params.worktree, params.limit, { - handles: params.handles, - requireFreshPtyLiveness: params.requireFreshPtyLiveness, - includeVisualLayouts: params.includeVisualLayouts - }) - }), - defineMethod({ - name: 'terminal.resolveActive', - params: TerminalResolveActive, - handler: async (params, { runtime }) => ({ - handle: await runtime.resolveActiveTerminal(params.worktree) - }) - }), - defineMethod({ - name: 'terminal.resolvePane', - params: TerminalResolvePane, - handler: async (params, { runtime }) => ({ - terminal: runtime.resolveTerminalPane(params.paneKey, params.worktreeId) - }) - }), - defineMethod({ - name: 'terminal.recoverPane', - params: TerminalRecoverPane, - handler: async (params, { runtime }) => ({ - terminal: await runtime.recoverTerminalPane( - params.paneKey, - params.worktreeId, - params.expectedTerminal - ) - }) - }), - defineMethod({ - name: 'terminal.show', - params: TerminalHandle, - handler: async (params, { runtime }) => ({ - terminal: await runtime.showTerminal(params.terminal) - }) - }), - defineMethod({ - name: 'terminal.read', - params: TerminalRead, - handler: async (params, { runtime }) => ({ - terminal: await runtime.readTerminal(params.terminal, { - cursor: params.cursor, - limit: params.limit, - screen: params.screen - }) - }) - }), - defineMethod({ - name: 'terminal.inspectProcess', - params: TerminalHandle, - handler: async (params, { runtime }) => ({ - process: await runtime.inspectTerminalProcess(params.terminal) - }) - }), - defineMethod({ - name: 'terminal.isRunningAgent', - params: TerminalHandle, - handler: async (params, { runtime }) => ({ - isRunningAgent: await runtime.isTerminalRunningAgent(params.terminal) - }) - }), - defineMethod({ - name: 'terminal.agentStatus', - params: TerminalHandle, - handler: async (params, { runtime }) => ({ - agentStatus: await runtime.getTerminalAgentStatus(params.terminal) - }) - }), - defineMethod({ - name: 'terminal.rename', - params: TerminalRename, - handler: async (params, { runtime }) => ({ - rename: await runtime.renameTerminal(params.terminal, params.title || null) - }) - }), - defineMethod({ - name: 'terminal.clearBuffer', - params: TerminalHandle, - handler: async (params, { runtime }) => ({ - clear: await runtime.clearTerminalBuffer(params.terminal) - }) - }), - defineMethod({ - name: 'terminal.send', - params: TerminalSend, - handler: async (params, { runtime, clientId, signal }) => { - await assertTerminalSendTextWithinLimit(params.text) - await assertTerminalSendTextWithinLimit(params.resolvedLaunchDraft?.text) - const queryReplyClientId = clientId ?? params.client?.id - if ( - params.inputKind === 'query-reply' && - (!params.text || - !isTerminalQueryReply(params.text) || - params.enter === true || - params.interrupt === true || - params.agentPrompt === true || - params.requireAgentStatus !== undefined || - params.client?.type !== 'mobile' || - !queryReplyClientId || - (clientId !== undefined && params.client.id !== clientId)) - ) { - throw new InvalidArgumentError('Invalid terminal query reply') - } - // Why: a stale handle must fail with terminal_handle_stale, not evaluate driver/lock state against the wrong PTY (#7718). - const leaf = runtime.resolveLiveLeafForHandle(params.terminal) - const driver = leaf?.ptyId ? runtime.getDriver(leaf.ptyId) : null - if ( - params.inputKind === 'query-reply' && - leaf?.ptyId && - !runtime.isMobileTerminalQueryReplyAuthority(leaf.ptyId, queryReplyClientId!) - ) { - return { - send: { - handle: params.terminal, - accepted: false, - bytesWritten: 0 - } - } - } - if (leaf?.ptyId && isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) { - return { - send: { - handle: params.terminal, - accepted: false, - bytesWritten: 0 - } - } - } - if ( - leaf?.ptyId && - params.client?.type === 'desktop' && - params.claimViewport === true && - params.viewport - ) { - const claim = await updateViewportForClient( - runtime, - leaf.ptyId, - `send:${params.client.id}`, - params.client, - params.viewport, - 'desktop', - 'refresh', - true - ) - // Why: a stream-less request can't safely create ownership, so never write at stale geometry. - if (!claim.updated || isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) { - return { - send: { - handle: params.terminal, - accepted: false, - bytesWritten: 0 - } - } - } - } - const hasText = typeof params.text === 'string' && params.text.length > 0 - const hasSuffix = params.enter === true || params.interrupt === true - if (params.requireAgentStatus === 'sendable' && hasText && hasSuffix) { - // Why: guarded sends are two-phase; reject combined payload + submit so a guard flip can't cause partial delivery. - return { - send: { - handle: params.terminal, - accepted: false, - bytesWritten: 0 - } - } - } - // Why: recheck permission/no-agent state immediately before accepting the PTY write. - const assertSendPreconditions = - params.requireAgentStatus === 'sendable' - ? async (ptyId?: string): Promise => { - await assertTerminalAgentSendable({ - runtime, - handle: params.terminal, - assertWritable: () => { - assertTerminalSendExactPtyBinding(runtime, params.terminal, ptyId) - if (ptyId && isTerminalInputLockedForClient(runtime, ptyId, params.client)) { - throw new Error('terminal_guard_not_writable') - } - } - }) - } - : undefined - if (params.requireAgentStatus === 'sendable') { - try { - await assertSendPreconditions?.(leaf?.ptyId ?? undefined) - } catch (error) { - if (isTerminalSendGuardNotWritable(error)) { - return { - send: { - handle: params.terminal, - accepted: false, - bytesWritten: 0 - } - } - } - const refusedReason = getTerminalSendGuardRefusedReason(error) - if (!refusedReason) { - throw error - } - return { - send: { - handle: params.terminal, - accepted: false, - bytesWritten: 0, - refusedReason - } - } - } - } - const mobileFloorClientId = resolveMobileFloorClientId(driver, params.client) - const mobileFloorClaim: MobileInputFloorClaimHolder = { current: null } - const beforeWrite = assertSendPreconditions - const useSettledAgentPrompt = - params.agentPrompt === true && - hasText && - params.enter === true && - params.interrupt !== true && - params.client?.type === 'desktop' && - (await runtime.isTerminalRunningSettledPromptAgent(params.terminal)) - const reserveWrite = - params.inputKind !== 'query-reply' && leaf?.ptyId && mobileFloorClientId - ? (ptyId: string): void => { - const claim = runtime.beginMobileInputFloor(ptyId, mobileFloorClientId) - if (!claim) { - throw new Error('mobile_input_floor_unavailable') - } - mobileFloorClaim.current = claim - } - : undefined - let result - try { - result = useSettledAgentPrompt - ? await runtime.sendTerminalAgentPrompt(params.terminal, params.text!, { - beforeWrite, - signal - }) - : await runtime.sendTerminal( - params.terminal, - { - text: params.text, - enter: params.enter === true, - interrupt: params.interrupt === true - }, - { - beforeWrite, - ...(reserveWrite ? { reserveWrite } : {}), - ...(params.inputKind !== 'query-reply' && mobileFloorClientId - ? { afterWrite: () => commitMobileInputFloorClaim(mobileFloorClaim) } - : {}) - } - ) - } catch (error) { - mobileFloorClaim.current?.rollback() - const refusedReason = getTerminalSendGuardRefusedReason(error) - if (refusedReason) { - return { - send: { - handle: params.terminal, - accepted: false, - bytesWritten: 0, - refusedReason - } - } - } - if (isTerminalSendGuardNotWritable(error)) { - return { - send: { - handle: params.terminal, - accepted: false, - bytesWritten: 0 - } - } - } - throw error - } - if (result.accepted !== true) { - mobileFloorClaim.current?.rollback() - } - if ( - result.accepted === true && - params.enter === true && - params.client?.type === 'mobile' && - params.resolvedLaunchDraft - ) { - runtime.notifyNativeChatLaunchDraftResolved(params.terminal, params.resolvedLaunchDraft) - } - // Why: deliberate mobile input takes the floor (drives `* → mobile{clientId}`); clientless sends fall back to the current mobile driver. - return { send: result } - } - }), - defineMethod({ - name: 'terminal.wait', - params: TerminalWait, - handler: async (params, { runtime, signal }) => ({ - wait: await runtime.waitForTerminal(params.terminal, { - condition: params.for, - timeoutMs: params.timeoutMs, - signal - }) - }) - }), - defineMethod({ - name: 'terminal.create', - params: TerminalCreateParams, - handler: async (params, { runtime, pairedDeviceId, clientId }) => ({ - terminal: await runtime.dedupeTerminalCreate( - pairedDeviceId ?? clientId ?? 'local', - params.worktree, - params.clientMutationId, - params.reconcileExisting === true, - (canonicalWorktreeSelector, preAllocatedHandle) => - runtime.createTerminal(canonicalWorktreeSelector, { - command: params.command, - startupCommandDelivery: params.startupCommandDelivery, - env: params.env, - envToDelete: params.envToDelete, - ...(params.launchConfig ? { launchConfig: params.launchConfig } : {}), - ...(params.resumeProviderSession - ? { resumeProviderSession: params.resumeProviderSession } - : {}), - ...(params.launchToken ? { launchToken: params.launchToken } : {}), - ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), - ...(params.terminalColorQueryReplies - ? { terminalColorQueryReplies: params.terminalColorQueryReplies } - : {}), - title: params.title, - focus: params.focus === true, - rendererBacked: params.rendererBacked === true, - activate: params.activate === true, - presentation: params.presentation, - tabId: params.tabId, - leafId: params.leafId, - ...(preAllocatedHandle ? { preAllocatedHandle } : {}) - }) - ) - }) - }), - defineMethod({ - name: 'terminal.split', - params: TerminalSplit, - handler: async (params, { runtime }) => ({ - split: await runtime.splitTerminal(params.terminal, { - direction: params.direction, - command: params.command, - env: params.env, - telemetrySource: params.telemetrySource - }) - }) - }), - defineMethod({ - name: 'terminal.stop', - params: TerminalStop, - handler: async (params, { runtime }) => runtime.stopTerminalsForWorktree(params.worktree) - }), - defineMethod({ - name: 'terminal.sleep', - params: TerminalSleep, - handler: async (params, { runtime }) => runtime.sleepTerminalsForWorktree(params.worktree) - }), - defineMethod({ - name: 'terminal.stopExact', - params: TerminalStopExact, - handler: async (params, { runtime }) => - runtime.stopExactTerminalsForWorktree(params.worktree, params.expectedPtyIds, { - keepHistory: params.keepHistory, - targetOnly: params.targetOnly - }) - }), - defineMethod({ - name: 'terminal.resizeForClient', - params: TerminalResizeForClient, - handler: async (params, { runtime }) => { - // Why: a stale handle must fail with terminal_handle_stale, not resize the wrong PTY (#7718). - const leaf = runtime.resolveLiveLeafForHandle(params.terminal) - if (!leaf?.ptyId) { - throw new Error('no_connected_pty') - } - const result = await runtime.resizeForClient( - leaf.ptyId, - params.mode, - params.clientId, - params.mode === 'mobile-fit' ? params.cols : undefined, - params.mode === 'mobile-fit' ? params.rows : undefined - ) - return { - terminal: { - handle: params.terminal, - ...result - } - } - } - }), - defineMethod({ - name: 'terminal.focus', - params: TerminalFocus, - handler: async (params, { runtime, clientKind }) => ({ - focus: await runtime.focusTerminal(params.terminal, { - navigateHost: navigationTargetsHost( - resolveRuntimeNavigationTarget({ navigation: params.navigation, clientKind }) - ) - }) - }) - }), - defineMethod({ - name: 'terminal.close', - params: TerminalHandle, - handler: async (params, context) => ({ - close: await withTerminalCloseAttribution( - 'terminal.close', - context, - 'terminal', - params.terminal, - () => context.runtime.closeTerminal(params.terminal) - ) - }) - }), - defineMethod({ - name: 'terminal.closeTab', - params: TerminalHandle, - handler: async (params, context) => ({ - close: await withTerminalCloseAttribution( - 'terminal.closeTab', - context, - 'terminal-tab', - params.terminal, - () => context.runtime.closeTerminalTab(params.terminal) - ) - }) - }), - defineMethod({ - name: 'agentTeams.tmuxCompat', - params: AgentTeamsTmuxCompat, - handler: async (params, { runtime }) => ({ - tmux: await runtime.handleAgentTeamsTmuxCompat(params) - }) - }), - defineMethod({ - name: 'agentTeams.prepareLaunch', - params: AgentTeamsPrepareLaunch, - handler: async (params, { runtime }) => ({ - launch: await runtime.prepareClaudeAgentTeamsLeader({ - paneKey: params.paneKey, - baseEnv: params.env - }) - }) - }), - defineMethod({ - name: 'terminal.setDisplayMode', - params: TerminalSetDisplayMode, - handler: async (params, { runtime }) => { - // Why: a stale handle must fail with terminal_handle_stale, not mutate the wrong PTY's display mode/viewport (#7718). - const leaf = runtime.resolveLiveLeafForHandle(params.terminal) - if (!leaf?.ptyId) { - throw new Error('no_connected_pty') - } - // Why: late-bind viewport for desktop-subscribed callers; otherwise an 'auto' toggle skips phone-fit and nothing resizes. - if (params.viewport && params.client?.id) { - runtime.updateMobileSubscriberViewport(leaf.ptyId, params.client.id, params.viewport) - } - if (params.client && params.client.type === 'mobile' && params.mode !== 'desktop') { - runtime.markMobileActor(leaf.ptyId, params.client.id) - } - runtime.setMobileDisplayMode(leaf.ptyId, params.mode) - await runtime.applyMobileDisplayMode(leaf.ptyId) - return { mode: params.mode, seq: runtime.getLayout(leaf.ptyId)?.seq } - } - }), - defineMethod({ - name: 'terminal.restoreFit', - params: TerminalHandle, - handler: async (params, { runtime }) => { - // Why: a stale handle must fail with terminal_handle_stale, not reclaim the wrong PTY to desktop dims (#7718). - const leaf = runtime.resolveLiveLeafForHandle(params.terminal) - if (!leaf?.ptyId) { - throw new Error('no_connected_pty') - } - return { restored: await runtime.reclaimTerminalForDesktop(leaf.ptyId) } - } - }), - defineMethod({ - name: 'terminal.getDisplayMode', - params: TerminalHandle, - handler: async (params, { runtime }) => { - const leaf = runtime.resolveLeafForHandle(params.terminal) - const mode = leaf?.ptyId ? runtime.getMobileDisplayMode(leaf.ptyId) : 'auto' - const isPhoneFitted = leaf?.ptyId ? runtime.isMobileSubscriberActive(leaf.ptyId) : false - return { mode, isPhoneFitted } - } - }), - defineMethod({ - name: 'terminal.updateViewport', - params: TerminalUpdateViewport, - handler: async (params, { runtime }) => { - // Why: a stale handle must fail with terminal_handle_stale, not write viewport state to the wrong PTY (#7718). - const leaf = runtime.resolveLiveLeafForHandle(params.terminal) - if (!leaf?.ptyId) { - throw new Error('no_connected_pty') - } - const viewportUpdate = await updateViewportForClient( - runtime, - leaf.ptyId, - `viewport:${params.client.id}`, - params.client, - params.viewport, - 'mobile', - // Why: one-shot RPC with no disconnect hook — refresh the existing stream-owned floor, never create a leak-prone one. - 'refresh', - params.claim === true - ) - return { ...viewportUpdate, seq: runtime.getLayout(leaf.ptyId)?.seq } - } - }), - // Why: one streaming RPC owns the binary socket and routes many panes by streamId; legacy subscribe stays as fallback. - defineStreamingMethod({ - name: 'terminal.multiplex', - params: TerminalMultiplex, - handler: async ( - _params, - { runtime, connectionId, sendBinary, registerBinaryStreamHandler, signal }, - emit - ) => { - if (!sendBinary || !registerBinaryStreamHandler || !connectionId) { - throw new Error('binary_terminal_stream_required') - } - - let closed = false - let cursor = 0 - const streams = new Map() - const sourceRangeRegistry = new TerminalSourceRangeRegistry() - const pendingPtyWaitControllers = new Map>() - let ackTotalInFlightBytes = 0 - let ackTotalWindowBytes = TERMINAL_MULTIPLEX_ACK_TOTAL_INITIAL_WINDOW_BYTES - let ackFlushCursorStreamId: number | null = null - let resolveMultiplex = (): void => {} - const multiplexClosed = new Promise((resolve) => { - resolveMultiplex = resolve - }) - const sendFrame = ( - streamId: number, - opcode: TerminalStreamOpcode, - payload: Uint8Array = new Uint8Array(), - seq?: number, - onRejected?: () => void - ): boolean => { - if (closed) { - onRejected?.() - return false - } - // Why: a seq-less Output chunk must carry sentinel 0, not the control-frame cursor, or it poisons the client's frame-drop tracker. - const resolvedSeq = - typeof seq === 'number' ? seq : opcode === TerminalStreamOpcode.Output ? 0 : cursor++ - let sent: boolean | void - try { - sent = sendBinary( - encodeTerminalStreamFrame({ opcode, streamId, seq: resolvedSeq, payload }) - ) - } catch { - onRejected?.() - closeMultiplex() - return false - } - if (sent === false) { - onRejected?.() - // Why: false means the transport discarded this frame; reconnect is the only available retry boundary with an authoritative snapshot. - closeMultiplex() - return false - } - return true - } - const sendStreamError = (streamId: number, message: string): void => { - sendFrame(streamId, TerminalStreamOpcode.Error, encodeTerminalStreamText(message)) - emit({ type: 'error', streamId, message }) - } - const notifyStreamWriteUnavailable = ( - stream: TerminalMultiplexStream, - outcome: TerminalStreamInputOutcome - ): void => { - if ( - closed || - streams.get(stream.streamId) !== stream || - outcome !== 'rejected' || - !stream.supportsWriteUnavailable - ) { - return - } - sendFrame(stream.streamId, TerminalStreamOpcode.WriteUnavailable) - } - const sendResizedFrame = ( - stream: TerminalMultiplexStream, - event: { cols: number; rows: number; displayMode: string; reason: string; seq?: number } - ): void => { - stream.lastResizeCols = event.cols - sendFrame( - stream.streamId, - TerminalStreamOpcode.Resized, - encodeTerminalStreamJson({ - cols: event.cols, - rows: event.rows, - displayMode: event.displayMode, - reason: event.reason, - seq: event.seq - }) - ) - } - const canSendAckGatedOutput = (stream: TerminalMultiplexStream, bytes: number): boolean => { - if (!stream.ackOutput) { - return true - } - return ( - stream.ackInFlightBytes + bytes <= stream.ackWindowBytes && - ackTotalInFlightBytes + bytes <= ackTotalWindowBytes && - (!stream.ackOutputSourceRanges || stream.sourceRangeLedger?.canAccept(bytes) === true) - ) - } - const sendAckGatedOutput = ( - stream: TerminalMultiplexStream, - chunk: TerminalOutputFrameChunk - ): boolean => { - const prepared = stream.ackOutputSourceRanges - ? stream.sourceRangeLedger?.prepareAccept( - chunk.bytes.byteLength, - chunk.displayLength, - chunk.sourceRanges ?? [], - chunk.seq - ) - : undefined - if (stream.ackOutputSourceRanges && prepared?.status !== 'ready') { - if (prepared?.status !== 'capacity') { - detachStream(stream.streamId, true) - } - return false - } - const admission = prepared?.status === 'ready' ? prepared.admission : undefined - const sent = sendFrame( - stream.streamId, - chunk.opcode ?? TerminalStreamOpcode.Output, - chunk.bytes, - chunk.seq, - admission?.rollback - ) - if (!sent) { - return false - } - if (admission && !admission.commit()) { - detachStream(stream.streamId, true) - return false - } - if (stream.ackOutput) { - stream.ackInFlightBytes += chunk.bytes.byteLength - ackTotalInFlightBytes += chunk.bytes.byteLength - } - return true - } - const queueOrSendOutput = ( - stream: TerminalMultiplexStream, - chunk: TerminalOutputFrameChunk - ): void => { - if (closed || streams.get(stream.streamId) !== stream || stream.outputPaused) { - return - } - if ( - stream.ackPendingOutputOverflowed || - stream.ackPendingOutput.length > 0 || - !canSendAckGatedOutput(stream, chunk.bytes.byteLength) - ) { - appendAckPendingOutput(stream, chunk) - return - } - sendAckGatedOutput(stream, chunk) - } - const sendAckRecoverySnapshot = async (stream: TerminalMultiplexStream): Promise => { - if ( - closed || - streams.get(stream.streamId) !== stream || - stream.outputPaused || - stream.ackRecoverySnapshotInFlight - ) { - return - } - stream.ackRecoverySnapshotInFlight = true - let replacement: RemoteTerminalSourceRangeReplacementReservation | null = null - try { - const serialized = await serializeBudgetedRequestedSnapshot(runtime, stream.ptyId, 0) - if (closed || streams.get(stream.streamId) !== stream || stream.outputPaused) { - return - } - if (!serialized) { - throw new Error('Remote terminal recovery snapshot unavailable.') - } - if ( - stream.ackOutputSourceRanges && - (serialized.source === undefined || typeof serialized.seq !== 'number') - ) { - throw new Error('Remote terminal recovery snapshot source identity unavailable.') - } - if ( - stream.ackOutputSourceRanges && - serialized.source !== undefined && - typeof serialized.seq === 'number' - ) { - replacement = runtime.reserveRemoteTerminalSourceRangeReplacement( - { - ptyId: stream.ptyId, - consumerId: stream.remoteDesktopSubscriptionKey, - streamGeneration: stream.streamGeneration - }, - serialized.seq, - 'ack-pending-overflow' - ) - stream.sourceRangeReplacement = replacement - } - const displayMode = runtime.getMobileDisplayMode(stream.ptyId) - const publication = sendSnapshotFrames( - (opcode, payload) => - !closed && - streams.get(stream.streamId) === stream && - sendFrame(stream.streamId, opcode, payload), - { - kind: 'scrollback', - cols: serialized.cols, - rows: serialized.rows, - displayMode, - reason: 'ack-pending-overflow', - seq: serialized.seq, - source: serialized.source, - kittyKeyboardFlags: serialized.kittyKeyboardFlags, - truncatedByByteBudget: serialized.truncatedByByteBudget, - data: serialized.data - } - ) - if (!publication.published) { - throw new Error('Remote terminal recovery snapshot was not published.') - } - if (closed || streams.get(stream.streamId) !== stream) { - throw new Error('Remote terminal recovery snapshot stream detached.') - } - const localReplacement = replacement - ? typeof serialized.seq === 'number' - ? stream.sourceRangeLedger?.planSourceRangeReplacement(serialized.seq) - : null - : null - if (replacement && !localReplacement) { - throw new Error('Remote terminal recovery source ledger replacement unavailable.') - } - if ( - replacement && - (!serialized.source || - typeof serialized.seq !== 'number' || - !runtime.commitRemoteTerminalSourceRangeReplacement(replacement, { - source: serialized.source, - seq: serialized.seq - })) - ) { - throw new Error('Remote terminal recovery snapshot replacement was not accepted.') - } - localReplacement?.commit() - stream.sourceRangeReplacement = null - replacement = null - if (typeof serialized.seq === 'number') { - const snapshotSeq = serialized.seq - const retained = stream.ackPendingOutput.filter( - (chunk) => !(typeof chunk.seq === 'number' && chunk.seq <= snapshotSeq) - ) - stream.ackPendingOutput = retained - stream.ackPendingOutputBytes = retained.reduce( - (total, chunk) => total + chunk.bytes.byteLength, - 0 - ) - } - stream.ackPendingOutputOverflowed = false - } catch (error) { - if (replacement) { - if (stream.sourceRangeReplacement === replacement) { - stream.sourceRangeReplacement = null - runtime.rollbackRemoteTerminalSourceRangeReplacement( - replacement, - 'ack-pending-overflow-unpublished' - ) - } - replacement = null - } - if (closed || streams.get(stream.streamId) !== stream) { - return - } - sendStreamError( - stream.streamId, - error instanceof Error ? error.message : 'Remote terminal recovery snapshot failed.' - ) - detachStream(stream.streamId, true) - } finally { - if (streams.get(stream.streamId) === stream) { - stream.ackRecoverySnapshotInFlight = false - flushAllAckPendingOutput() - } - } - } - const flushAckPendingOutput = ( - stream: TerminalMultiplexStream, - maxChunks = Number.POSITIVE_INFINITY - ): number => { - if (stream.outputPaused) { - return 0 - } - if (stream.ackPendingOutputOverflowed) { - void sendAckRecoverySnapshot(stream) - return 0 - } - let flushed = 0 - while ( - flushed < stream.ackPendingOutput.length && - flushed < maxChunks && - canSendAckGatedOutput(stream, stream.ackPendingOutput[flushed]!.bytes.byteLength) - ) { - if (!sendAckGatedOutput(stream, stream.ackPendingOutput[flushed]!)) { - return flushed - } - flushed += 1 - } - if (flushed > 0) { - stream.ackPendingOutput.splice(0, flushed) - stream.ackPendingOutputBytes = stream.ackPendingOutput.reduce( - (total, pending) => total + pending.bytes.byteLength, - 0 - ) - } - return flushed - } - const flushAllAckPendingOutput = (): void => { - const ordered = Array.from(streams.values()) - ackFlushCursorStreamId = drainTerminalMultiplexRoundRobin({ - streams: ordered, - cursorStreamId: ackFlushCursorStreamId, - canContinue: () => !closed, - drainOne: (stream) => { - if (streams.get(stream.streamId) !== stream) { - return false - } - if (flushAckPendingOutput(stream, 1) > 0) { - return true - } - return false - } - }) - } - const acknowledgeOutput = (stream: TerminalMultiplexStream, bytes: number): void => { - if (!stream.ackOutput || bytes <= 0) { - return - } - const acknowledged = Math.min(stream.ackInFlightBytes, bytes) - stream.ackWindowBytes = Math.min( - TERMINAL_MULTIPLEX_ACK_STREAM_MAX_WINDOW_BYTES, - stream.ackWindowBytes + acknowledged - ) - ackTotalWindowBytes = Math.min( - TERMINAL_MULTIPLEX_ACK_TOTAL_MAX_WINDOW_BYTES, - ackTotalWindowBytes + acknowledged - ) - stream.ackInFlightBytes -= acknowledged - ackTotalInFlightBytes = Math.max(0, ackTotalInFlightBytes - acknowledged) - flushAllAckPendingOutput() - } - const acknowledgeSourceRanges = ( - stream: TerminalMultiplexStream, - streamGeneration: string, - ackedEndByte: number - ): void => { - if (!stream.ackOutputSourceRanges) { - return - } - const result = stream.sourceRangeLedger?.acknowledge(streamGeneration, ackedEndByte) - if (!result) { - return - } - if (result.status !== 'accepted') { - return - } - if (result.settled.length > 0) { - runtime.settleRemoteTerminalSourceRanges( - { - ptyId: stream.ptyId, - consumerId: stream.remoteDesktopSubscriptionKey, - streamGeneration: stream.streamGeneration - }, - result.settled - ) - } - acknowledgeOutput(stream, result.acknowledgedBytes) - } - const detachSourceRangeConsumer = (stream: TerminalMultiplexStream, reason: string): void => { - if (!stream.sourceRangeConsumerAttached) { - return - } - stream.sourceRangeConsumerAttached = false - const ledger = stream.sourceRangeLedger - stream.sourceRangeLedger = null - if (!ledger) { - return - } - const identity = { - ptyId: stream.ptyId, - consumerId: stream.remoteDesktopSubscriptionKey, - streamGeneration: stream.streamGeneration - } - const transfer = ledger.beginTransfer() - const ranges = transfer.frames.flatMap((frame) => frame.sourceRanges) - try { - runtime.cancelRemoteTerminalSourceRanges(identity, ranges, reason) - } finally { - transfer.commit() - } - } - const detachStream = ( - streamId: number, - emitEnd: boolean, - releaseRemoteDesktopDriver = true - ): void => { - const stream = streams.get(streamId) - if (!stream) { - return - } - const replacement = stream.sourceRangeReplacement - stream.sourceRangeReplacement = null - if (replacement) { - runtime.rollbackRemoteTerminalSourceRangeReplacement( - replacement, - 'stream-detached-replacement-aborted' - ) - } - stream.outputBatcher.flush() - stream.outputBatcher.dispose() - detachSourceRangeConsumer(stream, 'stream-detached') - ackTotalInFlightBytes = Math.max(0, ackTotalInFlightBytes - stream.ackInFlightBytes) - stream.ackInFlightBytes = 0 - stream.ackPendingOutput = [] - stream.ackPendingOutputBytes = 0 - stream.ackPendingOutputOverflowed = false - stream.ackRecoverySnapshotInFlight = false - stream.unsubscribeData() - stream.unsubscribeResize() - stream.unsubscribeFit() - stream.unsubscribeDriver() - stream.unregisterBinaryHandler() - streams.delete(streamId) - flushAllAckPendingOutput() - // Why: release the runtime exit-waiter for this slot (see the field's note); delete before abort so its .catch no-ops instead of re-detaching. - stream.exitWaiterAbort.abort() - if (stream.isMobile && stream.client?.id) { - runtime.handleMobileUnsubscribe(stream.ptyId, stream.client.id) - } else if ( - releaseRemoteDesktopDriver && - stream.registeredRemoteDesktopDriver && - stream.client?.id - ) { - // Why: release the width floor only if THIS stream took it, so a passive stream can't release a peer's floor. - runtime.unregisterRemoteDesktopViewer(stream.ptyId, stream.remoteDesktopSubscriptionKey) - } - if (emitEnd) { - emit({ type: 'end', streamId }) - } - } - const cancelPendingPtyWaits = (streamId: number): void => { - const controllers = pendingPtyWaitControllers.get(streamId) - if (!controllers) { - return - } - pendingPtyWaitControllers.delete(streamId) - for (const controller of controllers) { - controller.abort() - } - } - const cancelAllPendingPtyWaits = (): void => { - for (const streamId of Array.from(pendingPtyWaitControllers.keys())) { - cancelPendingPtyWaits(streamId) - } - } - const closeMultiplex = (): void => { - if (closed) { - return - } - closed = true - signal?.removeEventListener('abort', cancelAllPendingPtyWaits) - cancelAllPendingPtyWaits() - const remoteDesktopKeysByPty = new Map() - for (const streamId of Array.from(streams.keys())) { - const stream = streams.get(streamId) - if (stream?.registeredRemoteDesktopDriver && !stream.isMobile && stream.client?.id) { - const keys = remoteDesktopKeysByPty.get(stream.ptyId) ?? [] - keys.push(stream.remoteDesktopSubscriptionKey) - remoteDesktopKeysByPty.set(stream.ptyId, keys) - } - detachStream(streamId, false, false) - } - // Why: one connection can own many panes on the same PTY; remove floors together so close scans each registry once. - for (const [ptyId, subscriptionKeys] of remoteDesktopKeysByPty) { - void runtime.unregisterRemoteDesktopViewers(ptyId, subscriptionKeys) - } - unregisterControlHandler() - resolveMultiplex() - } - const handleSlotFrame = ( - stream: TerminalMultiplexStream, - frame: TerminalStreamFrame - ): void => { - if (closed || streams.get(stream.streamId) !== stream) { - return - } - if (frame.opcode === TerminalStreamOpcode.Unsubscribe) { - cancelPendingPtyWaits(stream.streamId) - detachStream(stream.streamId, false) - return - } - if (frame.opcode === TerminalStreamOpcode.Ack) { - const payload = decodeTerminalStreamJson(frame.payload) ?? {} - if (stream.ackOutputSourceRanges) { - const parsed = TerminalMultiplexSourceRangeAckFrame.safeParse(payload) - if (parsed.success) { - acknowledgeSourceRanges( - stream, - parsed.data.streamGeneration, - parsed.data.ackedEndByte - ) - } - } else { - const parsed = TerminalMultiplexLegacyAckFrame.safeParse(payload) - if (parsed.success) { - acknowledgeOutput(stream, parsed.data.bytes) - } - } - return - } - if (frame.opcode === TerminalStreamOpcode.Input) { - const text = decodeTerminalStreamText(frame.payload) - if (!text) { - return - } - if (isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client)) { - return - } - // Mobile already has the higher-priority floor, so a rejected desktop claim must not suppress later phone input. - const inputClaimTail = stream.isMobile ? Promise.resolve(true) : stream.desktopClaimTail - void inputClaimTail.then(async (claimed) => { - if (!claimed || isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client)) { - return - } - const outcome = await sendTerminalStreamInput(runtime, { - terminal: stream.terminal, - text, - client: stream.client, - isMobile: stream.isMobile - }) - notifyStreamWriteUnavailable(stream, outcome) - }) - return - } - if (frame.opcode === TerminalStreamOpcode.SetOutputPaused && stream.supportsOutputPause) { - const payload = decodeTerminalStreamJson<{ paused?: unknown }>(frame.payload) - if (typeof payload?.paused !== 'boolean' || stream.outputPaused === payload.paused) { - return - } - stream.outputPaused = payload.paused - if (stream.outputPaused) { - stream.outputBatcher.flush() - stream.ackPendingOutput = [] - stream.ackPendingOutputBytes = 0 - stream.ackPendingOutputOverflowed = false - } - return - } - if (frame.opcode === TerminalStreamOpcode.Resize && stream.client) { - const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>( - frame.payload - ) - if (!viewport || typeof viewport.cols !== 'number' || typeof viewport.rows !== 'number') { - return - } - const cols = viewport.cols - const rows = viewport.rows - // Why: resize registers stream-scoped geometry so detach can release it; older clients lack explicit claims. - if (!stream.isMobile && stream.client?.id) { - stream.registeredRemoteDesktopDriver = true - if (stream.buffering) { - stream.pendingRemoteDesktopViewport = { cols: viewport.cols, rows: viewport.rows } - return - } - } - stream.desktopClaimTail = stream.desktopClaimTail - .then(async (priorClaimed) => { - const result = await updateViewportForClient( - runtime, - stream.ptyId, - stream.remoteDesktopSubscriptionKey, - stream.client!, - { cols, rows }, - stream.isMobile ? 'mobile' : 'desktop', - 'register', - !stream.supportsDesktopViewportClaims - ) - return stream.supportsDesktopViewportClaims - ? priorClaimed && result.applied - : result.applied - }) - .catch(() => false) - return - } - if ( - frame.opcode === TerminalStreamOpcode.ClaimViewport && - stream.client && - !stream.isMobile - ) { - const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>( - frame.payload - ) - if (!viewport || typeof viewport.cols !== 'number' || typeof viewport.rows !== 'number') { - return - } - const cols = viewport.cols - const rows = viewport.rows - stream.registeredRemoteDesktopDriver = true - stream.desktopClaimTail = stream.desktopClaimTail - .then( - () => - runtime.updateRemoteDesktopViewer( - stream.ptyId, - stream.remoteDesktopSubscriptionKey, - stream.client!.id, - cols, - rows, - true - ), - () => - runtime.updateRemoteDesktopViewer( - stream.ptyId, - stream.remoteDesktopSubscriptionKey, - stream.client!.id, - cols, - rows, - true - ) - ) - .catch(() => false) - return - } - if (frame.opcode === TerminalStreamOpcode.SnapshotRequest) { - const payload = TerminalMultiplexSnapshotRequestFrame.safeParse( - decodeTerminalStreamJson(frame.payload) ?? {} - ) - void sendRequestedSnapshot(stream, payload.success ? payload.data : {}) - } - } - const sendRequestedSnapshot = async ( - stream: TerminalMultiplexStream, - request: z.infer - ): Promise => { - if (closed || streams.get(stream.streamId) !== stream) { - return - } - stream.outputBatcher.flush() - stream.pendingOutputOverflowed = false - stream.buffering = true - const requestId = request.requestId - let sentSnapshotOutputSeq: number | undefined - try { - const scrollbackRows = normalizeMultiplexSnapshotScrollbackRows(request.scrollbackRows) - let serialized = await serializeBudgetedRequestedSnapshot( - runtime, - stream.ptyId, - scrollbackRows - ) - if (closed || streams.get(stream.streamId) !== stream) { - return - } - let size = runtime.getTerminalSize(stream.ptyId) - let displayMode = runtime.getMobileDisplayMode(stream.ptyId) - if (stream.pendingOutputOverflowed) { - // Why: the overflowed tail is newer than the first snapshot, so retry for a current image instead of null. - stream.pendingOutput.splice(0) - stream.pendingOutputBytes = 0 - stream.pendingOutputOverflowed = false - serialized = await serializeBudgetedRequestedSnapshot( - runtime, - stream.ptyId, - scrollbackRows - ) - if (closed || streams.get(stream.streamId) !== stream) { - return - } - size = runtime.getTerminalSize(stream.ptyId) - displayMode = runtime.getMobileDisplayMode(stream.ptyId) - if (stream.pendingOutputOverflowed) { - sendSnapshotFrames((opcode, payload) => sendFrame(stream.streamId, opcode, payload), { - kind: 'scrollback', - cols: size?.cols ?? 80, - rows: size?.rows ?? 24, - requestId, - displayMode, - truncated: true, - truncatedByByteBudget: false, - unavailable: 'pending-output-overflowed', - data: '' - }) - return - } - } - sentSnapshotOutputSeq = serialized?.seq - sendSnapshotFrames((opcode, payload) => sendFrame(stream.streamId, opcode, payload), { - kind: 'scrollback', - cols: serialized?.cols ?? size?.cols ?? 80, - rows: serialized?.rows ?? size?.rows ?? 24, - requestId, - displayMode, - seq: serialized?.seq, - cwd: serialized?.cwd, - source: serialized?.source, - kittyKeyboardFlags: serialized?.kittyKeyboardFlags, - oscLinks: serialized?.oscLinks, - pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi, - truncated: false, - truncatedByByteBudget: serialized?.truncatedByByteBudget, - // Why: no serializer answered, which is not proof the pane is empty — say so instead of passing off '' as the buffer. - unavailable: serialized ? undefined : 'no-serializable-buffer', - data: serialized?.data ?? '' - }) - } catch (error) { - sendStreamError( - stream.streamId, - error instanceof Error ? error.message : 'Remote terminal snapshot failed.' - ) - } finally { - if (streams.get(stream.streamId) === stream) { - const shouldFlushPendingOutput = !stream.pendingOutputOverflowed - stream.buffering = false - const pendingOutput = stream.pendingOutput.splice(0) - if (shouldFlushPendingOutput) { - for (const chunk of pendingOutput) { - // Why: an untagged reply resets the client to the snapshot's - // high-water, so covered bytes would render twice; tagged - // snapshots feed a side consumer and the live view still - // needs every buffered chunk. - const uncovered = - typeof requestId === 'number' - ? chunk - : getOutputAfterSnapshotSeq(chunk, sentSnapshotOutputSeq) - if (uncovered) { - stream.outputBatcher.push(uncovered.data, uncovered.meta) - } - } - } - stream.pendingOutputBytes = 0 - stream.pendingOutputOverflowed = false - stream.outputBatcher.flush() - // Why: a resize parked during snapshot buffering must be applied now, or it is dropped until the viewer's next resize. - if ( - !stream.isMobile && - stream.client?.id && - stream.registeredRemoteDesktopDriver && - stream.pendingRemoteDesktopViewport - ) { - const viewport = stream.pendingRemoteDesktopViewport - stream.pendingRemoteDesktopViewport = null - void updateViewportForClient( - runtime, - stream.ptyId, - stream.remoteDesktopSubscriptionKey, - stream.client, - viewport, - 'desktop', - 'register', - !stream.supportsDesktopViewportClaims - ).catch(() => {}) - } - } - } - } - const handleSubscribeFrame = async (payload: Uint8Array): Promise => { - const raw = decodeTerminalStreamJson(payload) - const parsed = TerminalMultiplexSubscribeFrame.safeParse(raw) - if (!parsed.success) { - return - } - const request = parsed.data - detachStream(request.streamId, false) - cancelPendingPtyWaits(request.streamId) - - const isMobile = request.client?.type === 'mobile' - let leaf: { ptyId: string | null } | null - try { - // Why: binding the stream to whatever PTY now occupies a stale handle's pane would mirror the wrong terminal (#7718). - leaf = runtime.resolveLiveLeafForHandle(request.terminal) - } catch { - sendStreamError(request.streamId, 'terminal_handle_stale') - emit({ type: 'end', streamId: request.streamId }) - return - } - if (!leaf?.ptyId && request.client) { - if ( - pendingPtyWaitControllers.size >= - TERMINAL_MULTIPLEX_MAX_PENDING_PTY_WAITS_PER_CONNECTION - ) { - sendStreamError(request.streamId, TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR) - emit({ type: 'end', streamId: request.streamId }) - return - } - // Why: a never-mounted tab has no graph leaf to await; mounting the exact tab attaches its PTY without activating the worktree. - runtime.requestRendererTerminalTabMount(request.terminal) - const waitController = new AbortController() - const pendingControllers = pendingPtyWaitControllers.get(request.streamId) ?? new Set() - pendingControllers.add(waitController) - pendingPtyWaitControllers.set(request.streamId, pendingControllers) - if (signal?.aborted) { - waitController.abort() - } - // Why: the live slot handler does not exist until the PTY attaches; retain cancellation ownership while the pane is still pending. - const unregisterPendingHandler = registerBinaryStreamHandler( - request.streamId, - (frame) => { - if (frame.opcode === TerminalStreamOpcode.Unsubscribe) { - cancelPendingPtyWaits(request.streamId) - detachStream(request.streamId, false) - } - } - ) - try { - const ptyId = await runtime.waitForLeafPtyId( - request.terminal, - 10_000, - waitController.signal - ) - leaf = { ptyId } - } catch { - if (closed || signal?.aborted || waitController.signal.aborted) { - return - } - // Fall through to the explicit no_connected_pty error below. - } finally { - const currentControllers = pendingPtyWaitControllers.get(request.streamId) - currentControllers?.delete(waitController) - if (currentControllers?.size === 0) { - pendingPtyWaitControllers.delete(request.streamId) - } - unregisterPendingHandler() - } - } - if (!leaf?.ptyId) { - sendStreamError(request.streamId, 'no_connected_pty') - emit({ type: 'end', streamId: request.streamId }) - return - } - if (closed) { - return - } - // Why: a competing subscribe may own this streamId after the PTY await; detach it so an orphaned view subscriber can't silence the model responder (terminal-query-authority.md). - detachStream(request.streamId, false) - if (streams.size >= TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION) { - sendStreamError(request.streamId, TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR) - emit({ type: 'end', streamId: request.streamId }) - return - } - - const ptyId = leaf.ptyId - const remoteDesktopSubscriptionKey = `multiplex:${connectionId}:${request.streamId}` - const streamGeneration = randomUUID() - const requestedSourceRangeConsumer = - request.capabilities?.ackOutput === 1 && request.capabilities?.ackOutputSourceRanges === 1 - const sourceRangeLedger = requestedSourceRangeConsumer - ? sourceRangeRegistry.open(streamGeneration) - : null - const sourceRangeConsumerAttached = - sourceRangeLedger !== null && - runtime.attachRemoteTerminalSourceRangeConsumer({ - ptyId, - consumerId: remoteDesktopSubscriptionKey, - streamGeneration - }) - if (!sourceRangeConsumerAttached) { - sourceRangeLedger?.close() - } - const stream: TerminalMultiplexStream = { - streamId: request.streamId, - terminal: request.terminal, - ptyId, - client: request.client, - isMobile, - ackOutput: request.capabilities?.ackOutput === 1, - ackOutputSourceRanges: sourceRangeConsumerAttached, - streamGeneration, - sourceRangeLedger: sourceRangeConsumerAttached ? sourceRangeLedger : null, - sourceRangeConsumerAttached, - sourceRangeReplacement: null, - ackInFlightBytes: 0, - ackWindowBytes: TERMINAL_MULTIPLEX_ACK_STREAM_INITIAL_WINDOW_BYTES, - supportsOutputPause: request.capabilities?.outputPause === 1, - supportsWriteUnavailable: request.capabilities?.writeUnavailable === 1, - outputPaused: false, - supportsDesktopViewportClaims: request.capabilities?.desktopViewportClaims === 1, - desktopClaimTail: Promise.resolve(true), - registeredRemoteDesktopDriver: false, - // Why: streamId is client-local, so key the width floor by connectionId or two connections sharing stream 1 for one PTY clobber each other's floor. - remoteDesktopSubscriptionKey, - pendingRemoteDesktopViewport: null, - buffering: true, - ackPendingOutput: [], - ackPendingOutputBytes: 0, - ackPendingOutputOverflowed: false, - ackRecoverySnapshotInFlight: false, - pendingOutput: [], - pendingOutputBytes: 0, - pendingOutputOverflowed: false, - lastResizeCols: undefined, - resizeGeneration: 0, - outputBatcher: createTerminalOutputBatcher((data, meta) => { - if (meta?.cwd !== undefined) { - sendFrame( - request.streamId, - TerminalStreamOpcode.Metadata, - encodeTerminalStreamJson({ cwd: meta.cwd }), - meta.seq - ) - } - for (const chunk of iterateTerminalOutputFrameChunks(data, meta)) { - queueOrSendOutput(stream, chunk) - } - }), - unsubscribeData: () => {}, - unsubscribeResize: () => {}, - unsubscribeFit: () => {}, - unsubscribeDriver: () => {}, - unregisterBinaryHandler: () => {}, - exitWaiterAbort: new AbortController() - } - streams.set(request.streamId, stream) - stream.unregisterBinaryHandler = registerBinaryStreamHandler(request.streamId, (frame) => - handleSlotFrame(stream, frame) - ) - - try { - const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { - if (closed || streams.get(request.streamId) !== stream) { - return - } - if (stream.outputPaused) { - return - } - if (stream.buffering) { - appendPendingMultiplexOutput(stream, data, meta) - return - } - stream.outputBatcher.push(data, meta) - }) - // Why: a multiplexed stream feeds a remote xterm view with query authority, so the main model responder yields while attached (terminal-query-authority.md). - const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) - stream.unsubscribeData = () => { - releaseViewSubscriber() - unsubscribeStreamData() - } - - if (isMobile && request.client?.id) { - await runtime.handleMobileSubscribe(ptyId, request.client.id, request.viewport) - } else if (request.client?.id && request.viewport) { - // Why: subscribe records this stream's geometry and cleanup key but doesn't claim ownership; activity frames claim later. - stream.registeredRemoteDesktopDriver = true - stream.pendingRemoteDesktopViewport = request.viewport - } - if ( - !isMobile && - request.client?.id && - stream.registeredRemoteDesktopDriver && - stream.pendingRemoteDesktopViewport - ) { - const viewport = stream.pendingRemoteDesktopViewport - stream.pendingRemoteDesktopViewport = null - await updateViewportForClient( - runtime, - ptyId, - stream.remoteDesktopSubscriptionKey, - request.client, - viewport, - 'desktop', - 'register', - !stream.supportsDesktopViewportClaims - ) - } - if (closed || streams.get(request.streamId) !== stream) { - return - } - - let read = await runtime.readTerminal(request.terminal) - let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) - if (closed || streams.get(request.streamId) !== stream) { - return - } - let initialOutputOverflowed = false - if (stream.pendingOutputOverflowed) { - stream.pendingOutput.splice(0) - stream.pendingOutputBytes = 0 - stream.pendingOutputOverflowed = false - read = await runtime.readTerminal(request.terminal) - serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) - if (closed || streams.get(request.streamId) !== stream) { - return - } - if (stream.pendingOutputOverflowed) { - initialOutputOverflowed = true - stream.pendingOutput.splice(0) - stream.pendingOutputBytes = 0 - stream.pendingOutputOverflowed = false - } - } - const size = runtime.getTerminalSize(ptyId) - const displayMode = runtime.getMobileDisplayMode(ptyId) - const layoutSeq = runtime.getLayout(ptyId)?.seq - // Why: layout versions and output offsets are different sequence domains. - const snapshotOutputSeq = serialized?.seq - emit({ - type: 'subscribed', - streamId: request.streamId, - terminal: request.terminal, - cols: serialized?.cols ?? size?.cols, - rows: serialized?.rows ?? size?.rows, - displayMode, - seq: layoutSeq, - ...((stream.ackOutputSourceRanges || stream.supportsOutputPause) && { - capabilities: { - ...(stream.ackOutputSourceRanges ? { ackOutputSourceRanges: 1 as const } : {}), - ...(stream.supportsOutputPause ? { outputPause: 1 as const } : {}) - } - }), - ...(stream.ackOutputSourceRanges ? { streamGeneration: stream.streamGeneration } : {}), - // Why: retained-tail truncation loses history, not the authoritative latest-screen fallback. - truncated: initialOutputOverflowed - }) - stream.sourceRangeReplacement = - stream.ackOutputSourceRanges && - serialized?.source !== undefined && - typeof serialized.seq === 'number' - ? runtime.reserveRemoteTerminalSourceRangeReplacement( - { - ptyId, - consumerId: stream.remoteDesktopSubscriptionKey, - streamGeneration: stream.streamGeneration - }, - serialized.seq, - 'initial-snapshot' - ) - : null - const snapshotPublication = sendSnapshotFrames( - (opcode, payload) => sendFrame(request.streamId, opcode, payload), - { - kind: 'scrollback', - cols: serialized?.cols ?? size?.cols ?? 80, - rows: serialized?.rows ?? size?.rows ?? 24, - displayMode, - seq: snapshotOutputSeq, - cwd: serialized?.cwd, - truncated: initialOutputOverflowed, - truncatedByByteBudget: serialized?.truncatedByByteBudget, - source: serialized?.source, - kittyKeyboardFlags: serialized?.kittyKeyboardFlags, - oscLinks: serialized?.oscLinks, - pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi, - data: - serialized?.data ?? (read.tail.length > 0 ? `${read.tail.join('\r\n')}\r\n` : '') - } - ) - const replacement = stream.sourceRangeReplacement - stream.sourceRangeReplacement = null - if (replacement) { - const committed = - snapshotPublication.published && - serialized?.source !== undefined && - typeof serialized.seq === 'number' && - runtime.commitRemoteTerminalSourceRangeReplacement(replacement, { - source: serialized.source, - seq: serialized.seq - }) - if (!committed) { - runtime.rollbackRemoteTerminalSourceRangeReplacement( - replacement, - 'initial-snapshot-unpublished' - ) - } - } - // Why: baseline for resize re-stream gating; the client already rewrapped to these cols via the initial snapshot replay. - stream.lastResizeCols = serialized?.cols ?? size?.cols - stream.buffering = false - const pendingOutput = stream.pendingOutput.splice(0) - if (!initialOutputOverflowed) { - for (const chunk of pendingOutput) { - const uncovered = getOutputAfterSnapshotSeq(chunk, snapshotOutputSeq) - if (uncovered) { - stream.outputBatcher.push(uncovered.data, uncovered.meta) - } - } - } - stream.pendingOutputBytes = 0 - stream.pendingOutputOverflowed = false - stream.outputBatcher.flush() - if (!isMobile) { - stream.unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => { - const mode = - event.mode === 'mobile-fit' - ? event.mode - : (runtime.getRemoteDesktopFitHold?.(ptyId, stream.remoteDesktopSubscriptionKey) - .mode ?? 'desktop-fit') - emit({ - type: 'fit-override-changed', - streamId: request.streamId, - mode, - cols: event.cols, - rows: event.rows - }) - }) - stream.unsubscribeDriver = runtime.subscribeToDriverChanges(ptyId, (driver) => { - emit({ - type: 'driver-changed', - streamId: request.streamId, - driver - }) - }) - const fitOverride = runtime.getTerminalFitOverride(ptyId) - const desktopHold = runtime.getRemoteDesktopFitHold?.( - ptyId, - stream.remoteDesktopSubscriptionKey - ) ?? { mode: 'desktop-fit' as const, cols: size?.cols ?? 0, rows: size?.rows ?? 0 } - emit({ - type: 'fit-override-changed', - streamId: request.streamId, - mode: fitOverride?.mode ?? desktopHold.mode, - cols: fitOverride?.cols ?? desktopHold.cols, - rows: fitOverride?.rows ?? desktopHold.rows - }) - emit({ - type: 'driver-changed', - streamId: request.streamId, - driver: runtime.getDriver(ptyId) - }) - } - stream.unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, (event) => { - stream.outputBatcher.flush() - const resizeGeneration = stream.resizeGeneration + 1 - stream.resizeGeneration = resizeGeneration - const widthChanged = stream.isMobile && event.cols !== stream.lastResizeCols - if (widthChanged) { - stream.lastResizeCols = event.cols - // Why: re-serialize+replay the full scrollback at the new cols so restored hard-wrapped lines rewrap; live output resumes after the snapshot lands. - void sendMobileResizeRestream( - runtime, - ptyId, - (opcode, payload) => sendFrame(request.streamId, opcode, payload), - event, - () => - !closed && - streams.get(request.streamId) === stream && - stream.resizeGeneration === resizeGeneration - ) - .then((restreamed) => { - if ( - closed || - streams.get(request.streamId) !== stream || - stream.resizeGeneration !== resizeGeneration - ) { - return - } - if (!restreamed) { - sendResizedFrame(stream, event) - } - }) - // Why: on re-stream failure, still emit the geometry-only Resized frame so the client never misses the resize. - .catch(() => { - if ( - closed || - streams.get(request.streamId) !== stream || - stream.resizeGeneration !== resizeGeneration - ) { - return - } - sendResizedFrame(stream, event) - }) - return - } - sendResizedFrame(stream, event) - }) - // Install the resize listener before draining the parked viewport, since applyLayout emits synchronously. - if ( - !stream.isMobile && - stream.client?.id && - stream.registeredRemoteDesktopDriver && - stream.pendingRemoteDesktopViewport - ) { - const viewport = stream.pendingRemoteDesktopViewport - stream.pendingRemoteDesktopViewport = null - void updateViewportForClient( - runtime, - ptyId, - stream.remoteDesktopSubscriptionKey, - stream.client, - viewport, - 'desktop', - 'register', - !stream.supportsDesktopViewportClaims - ).catch(() => {}) - } - void runtime - .waitForTerminal(request.terminal, { - condition: 'exit', - signal: stream.exitWaiterAbort.signal - }) - .then(() => { - if (streams.get(request.streamId) === stream) { - detachStream(request.streamId, true) - } - }) - .catch(() => { - if (streams.get(request.streamId) === stream) { - detachStream(request.streamId, true) - } - }) - } catch (error) { - // Why the ownership check: a newer subscribe may already own this streamId; tearing down the slot here would kill the successor's live registrations. - if (streams.get(request.streamId) !== stream) { - return - } - detachStream(request.streamId, false) - sendStreamError(request.streamId, error instanceof Error ? error.message : String(error)) - emit({ type: 'end', streamId: request.streamId }) - } - } - const unregisterControlHandler = registerBinaryStreamHandler(0, (frame) => { - if (frame.opcode === TerminalStreamOpcode.Subscribe) { - void handleSubscribeFrame(frame.payload) - } - }) - - signal?.addEventListener('abort', cancelAllPendingPtyWaits, { once: true }) - - runtime.registerSubscriptionCleanup( - `terminal-multiplex:${connectionId}`, - closeMultiplex, - connectionId - ) - emit({ type: 'ready' }) - await multiplexClosed - } - }), - // terminal.subscribe: streams live terminal output over WebSocket; mobile clients pass client+viewport for server-side auto-fit. - defineStreamingMethod({ - name: 'terminal.subscribe', - params: TerminalSubscribe, - handler: async ( - params, - { runtime, connectionId, sendBinary, registerBinaryStreamHandler, signal }, - emit - ) => { - let leaf = runtime.resolveLeafForHandle(params.terminal) - const isMobile = params.client?.type === 'mobile' - const serializerGenerationBeforeAnyMount = isMobile - ? (runtime.getRendererTerminalSerializerGenerationForHandle?.(params.terminal) ?? 0) - : 0 - let rendererMountRequestedBeforePty = false - const useBinaryStream = params.capabilities?.terminalBinaryStream === 1 && Boolean(sendBinary) - // Why: a closed stream must not allocate listeners, mobile-fit state, or a hidden renderer surface no client will consume. - if (signal?.aborted) { - return - } - - // Why: the PTY spawns asynchronously after tab creation; wait for it so an early subscribe gets a live stream instead of a bare scrollback+end. - if (!leaf?.ptyId && params.client) { - // Why: a never-mounted tab has no graph leaf to await; mounting the exact tab attaches its PTY without activating the worktree. - rendererMountRequestedBeforePty = runtime.requestRendererTerminalTabMount(params.terminal) - try { - const ptyId = await runtime.waitForLeafPtyId(params.terminal, 10_000, signal) - leaf = { ptyId } - } catch { - if (signal?.aborted) { - return - } - // PTY wait timed out — fall through to scrollback-only path below - } - } - - if (!leaf?.ptyId) { - const read = await runtime.readTerminal(params.terminal) - emit({ - type: 'subscribed', - streamId: null, - lines: read.tail, - truncated: isTerminalReadPayloadIncomplete(read) - }) - emit({ type: 'end' }) - return - } - - if (isMobile && (!useBinaryStream || !sendBinary)) { - throw new Error('binary_terminal_stream_required') - } - - const ptyId = leaf.ptyId - const clientId = params.client?.id - const mobileInputLeaseOnly = - isMobile && params.capabilities?.mobileInputLeaseOnly === 1 && Boolean(clientId) - // Why: mount/PTY wait and phone-fit can each emit a redraw creating suffix-only state, so capture the pre-mount absence signal first. - const missingHeadlessStateBeforeMobileFit = - isMobile && - (rendererMountRequestedBeforePty || runtime.hasHeadlessTerminalState?.(ptyId) === false) - const serializerGenerationBeforeMobileFit = missingHeadlessStateBeforeMobileFit - ? rendererMountRequestedBeforePty - ? serializerGenerationBeforeAnyMount - : runtime.getRendererTerminalSerializerGeneration(ptyId) - : 0 - const supportsDesktopViewportClaims = params.capabilities?.desktopViewportClaims === 1 - const supportsWriteUnavailable = params.capabilities?.writeUnavailable === 1 - if (mobileInputLeaseOnly && clientId) { - let closed = false - let stopWatchingLifetime = (): void => {} - let resolveStream = (): void => {} - const streamClosed = new Promise((resolve) => { - resolveStream = resolve - }) - const subscriptionId = `${params.terminal}:${clientId}` - // Why: chat needs the input-floor ack without registering a view subscriber or transporting duplicate PTY output. - const registration = runtime.registerOwnedSubscriptionCleanup( - subscriptionId, - () => { - stopWatchingLifetime() - closed = true - runtime.handleMobileUnsubscribe(ptyId, clientId) - emit({ type: 'end' }) - resolveStream() - }, - connectionId - ) - stopWatchingLifetime = watchSubscriptionLifetime(runtime, ptyId, signal, registration) - if (closed) { - // Why: an already-exited pty releases synchronously, so cleanup ran before this setup registers anything. - return - } - try { - // Why: a lease-only subscriber has no terminal view, so its cached viewport must never phone-fit the PTY. - await runtime.handleMobileSubscribe(ptyId, clientId, undefined) - if (closed || signal?.aborted) { - // Why: a disconnect can win the awaited subscribe and resurrect mobile presence after cleanup already released it. - // Unguarded on purpose: this must still fire when our own cleanup already ran. - // Safe only because lease-only passes no viewport and both !viewport paths in - // handleMobileSubscribeInternal return with no await, so no rebind can land - // first. Adding an await there — or passing a viewport here — makes a - // superseded handler delete the replacement's (ptyId, clientId) presence. - runtime.handleMobileUnsubscribe(ptyId, clientId) - if (!closed) { - registration.releaseIfCurrent() - } - return - } - emit({ type: 'subscribed', streamId: null, lines: [], truncated: false }) - await streamClosed - } catch (error) { - registration.releaseIfCurrent() - throw error - } - return - } - // Why: only unregister the width floor this subscription took (see the multiplex stream's registeredRemoteDesktopDriver note). - let registeredRemoteDesktopDriver = false - if (!useBinaryStream) { - // Why: a hidden watcher and a visible pane can subscribe to one terminal, so key by client so neither stream evicts the other. - const subscriptionId = clientId ? `${params.terminal}:${clientId}` : params.terminal - const remoteDesktopSubscriptionKey = `json:${nextTerminalStreamId++}` - let closed = false - let outputBatcher: ReturnType | null = null - let unsubscribeData = (): void => {} - let unsubscribeFit = (): void => {} - let stopWatchingLifetime = (): void => {} - let resolveStream = (): void => {} - const streamClosed = new Promise((resolve) => { - resolveStream = resolve - }) - // Why: register before viewport/snapshot awaits so a socket close can't orphan the stream listeners or its remote-desktop width floor. - const registration = runtime.registerOwnedSubscriptionCleanup( - subscriptionId, - () => { - stopWatchingLifetime() - closed = true - outputBatcher?.flush() - outputBatcher?.dispose() - unsubscribeData() - unsubscribeFit() - if (registeredRemoteDesktopDriver && clientId) { - runtime.unregisterRemoteDesktopViewer(ptyId, remoteDesktopSubscriptionKey) - } - emit({ type: 'end' }) - resolveStream() - }, - connectionId - ) - stopWatchingLifetime = watchSubscriptionLifetime(runtime, ptyId, signal, registration) - if (closed) { - // Why: an already-exited pty releases synchronously, so cleanup ran before this setup registers anything. - return - } - try { - if (clientId && params.client && params.viewport) { - registeredRemoteDesktopDriver = true - await updateViewportForClient( - runtime, - ptyId, - remoteDesktopSubscriptionKey, - params.client, - params.viewport, - 'desktop', - 'register', - !supportsDesktopViewportClaims - ) - } - if (closed || signal?.aborted) { - registration.releaseIfCurrent() - return - } - const read = await runtime.readTerminal(params.terminal) - const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, false) - if (closed || signal?.aborted) { - registration.releaseIfCurrent() - return - } - const size = runtime.getTerminalSize(ptyId) - const displayMode = runtime.getMobileDisplayMode(ptyId) - const seq = runtime.getLayout(ptyId)?.seq - emit({ - type: 'scrollback', - lines: read.tail, - truncated: isTerminalReadPayloadIncomplete(read), - serialized: serialized?.data, - oscLinks: serialized?.oscLinks, - cwd: serialized?.cwd, - // Why: an empty snapshot with no PTY size must still report the dims the fit - // will produce — dimless frames re-armed the mobile fit loop (STA-3337). - cols: serialized?.cols ?? size?.cols ?? params.viewport?.cols, - rows: serialized?.rows ?? size?.rows ?? params.viewport?.rows, - displayMode, - seq - }) - outputBatcher = createTerminalOutputBatcher((chunk) => { - emit({ type: 'data', chunk }) - }) - const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data) => { - outputBatcher?.push(data) - }) - // Why: the legacy JSON stream can feed a live xterm view, so register as a view subscriber; worst case is a withheld model reply, safer than a double reply. - const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) - unsubscribeData = () => { - releaseViewSubscriber() - unsubscribeStreamData() - } - unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => { - outputBatcher?.flush() - const mode = - event.mode === 'mobile-fit' - ? event.mode - : (runtime.getRemoteDesktopFitHold?.(ptyId, remoteDesktopSubscriptionKey).mode ?? - 'desktop-fit') - emit({ - type: 'fit-override-changed', - mode, - cols: event.cols, - rows: event.rows - }) - }) - await streamClosed - } catch (error) { - registration.releaseIfCurrent() - throw error - } - return - } - - const streamId = nextTerminalStreamId++ - const remoteDesktopSubscriptionKey = `stream:${streamId}` - let cursor = 0 - let closed = false - let buffering = true - let pendingRemoteDesktopViewport: { cols: number; rows: number } | null = null - // Why: cols the mobile client last rewrapped to; gates the resize re-stream to fire only on an actual width change. - let lastResizeCols: number | undefined - let resizeGeneration = 0 - let pendingOutput: TerminalOutputChunk[] = [] - let desktopClaimTail: Promise = Promise.resolve(true) - let pendingOutputBytes = 0 - let pendingOutputOverflowed = false - let pendingQueryScanState: TerminalReplyQueryScanState = EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE - const pendingQuerySequences: TerminalReplyQuerySequence[] = [] - let pendingQueryChars = 0 - let pendingQueryOverflowed = false - let unsubscribeData = (): void => {} - let unsubscribeResize = (): void => {} - let unsubscribeFit = (): void => {} - let unregisterBinaryHandler = (): void => {} - let abortRendererMountWait = (): void => {} - let stopWatchingLifetime = (): void => {} - let lateRendererReadyPromise: Promise | null = null - let outputBatcher: ReturnType | null = null - let resolveStream = (): void => {} - const streamClosed = new Promise((resolve) => { - resolveStream = resolve - }) - // Why: register cleanup before any await so a mid-subscribe disconnect still removes mobile presence; client-scoped ids also allow parallel desktop subscribers. - const subscriptionId = clientId ? `${params.terminal}:${clientId}` : params.terminal - const registration = runtime.registerOwnedSubscriptionCleanup( - subscriptionId, - () => { - stopWatchingLifetime() - outputBatcher?.flush() - outputBatcher?.dispose() - closed = true - unsubscribeData() - unsubscribeResize() - unsubscribeFit() - unregisterBinaryHandler() - abortRendererMountWait() - if (isMobile && clientId) { - runtime.handleMobileUnsubscribe(ptyId, clientId) - } else if (registeredRemoteDesktopDriver && clientId) { - runtime.unregisterRemoteDesktopViewer(ptyId, remoteDesktopSubscriptionKey) - } - emit({ type: 'end' }) - resolveStream() - }, - connectionId - ) - stopWatchingLifetime = watchSubscriptionLifetime(runtime, ptyId, signal, registration) - if (closed) { - // Why: an already-exited pty releases synchronously, so cleanup ran before this setup registers anything. - return - } - const sendFrame = ( - opcode: TerminalStreamOpcode, - payload: Uint8Array = new Uint8Array(), - frameSeq = cursor++ - ): void => { - if (closed || !sendBinary) { - return - } - sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: frameSeq, payload })) - } - outputBatcher = createTerminalOutputBatcher((data, meta) => { - if (meta?.cwd !== undefined) { - sendFrame( - TerminalStreamOpcode.Metadata, - encodeTerminalStreamJson({ cwd: meta.cwd }), - meta.seq - ) - } - for (const chunk of iterateTerminalOutputFrameChunks(data, meta)) { - sendFrame(chunk.opcode ?? TerminalStreamOpcode.Output, chunk.bytes, chunk.seq) - } - }) - unregisterBinaryHandler = - registerBinaryStreamHandler?.(streamId, (frame) => { - if (closed) { - return - } - if (frame.opcode === TerminalStreamOpcode.Input) { - const text = decodeTerminalStreamText(frame.payload) - if (!text) { - return - } - if (isTerminalInputLockedForClient(runtime, ptyId, params.client)) { - return - } - void desktopClaimTail.then(async (claimed) => { - if (!claimed || isTerminalInputLockedForClient(runtime, ptyId, params.client)) { - return - } - const outcome = await sendTerminalStreamInput(runtime, { - terminal: params.terminal, - text, - client: params.client, - isMobile - }) - if (!closed && outcome === 'rejected' && supportsWriteUnavailable) { - sendFrame(TerminalStreamOpcode.WriteUnavailable) - } - }) - return - } - if (frame.opcode === TerminalStreamOpcode.Resize && params.client) { - const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>( - frame.payload - ) - if ( - !viewport || - typeof viewport.cols !== 'number' || - typeof viewport.rows !== 'number' - ) { - return - } - const cols = viewport.cols - const rows = viewport.rows - if (clientId) { - registeredRemoteDesktopDriver = true - if (buffering) { - pendingRemoteDesktopViewport = { cols: viewport.cols, rows: viewport.rows } - return - } - } - desktopClaimTail = desktopClaimTail - .then(async (priorClaimed) => { - const result = await updateViewportForClient( - runtime, - ptyId, - remoteDesktopSubscriptionKey, - params.client!, - { cols, rows }, - 'desktop', - 'register', - !supportsDesktopViewportClaims - ) - return supportsDesktopViewportClaims - ? priorClaimed && result.applied - : result.applied - }) - .catch(() => false) - return - } - if ( - frame.opcode === TerminalStreamOpcode.ClaimViewport && - params.client && - clientId && - !isMobile - ) { - const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>( - frame.payload - ) - if ( - !viewport || - typeof viewport.cols !== 'number' || - typeof viewport.rows !== 'number' - ) { - return - } - const cols = viewport.cols - const rows = viewport.rows - registeredRemoteDesktopDriver = true - desktopClaimTail = desktopClaimTail - .then( - () => - runtime.updateRemoteDesktopViewer( - ptyId, - remoteDesktopSubscriptionKey, - clientId, - cols, - rows, - true - ), - () => - runtime.updateRemoteDesktopViewer( - ptyId, - remoteDesktopSubscriptionKey, - clientId, - cols, - rows, - true - ) - ) - .catch(() => false) - } - }) ?? (() => {}) - const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { - if (closed) { - return - } - if (buffering) { - const rawLength = meta?.rawLength - if ( - typeof meta?.seq === 'number' && - typeof rawLength === 'number' && - rawLength === data.length - ) { - const scan = scanTerminalReplyQuerySequences( - data, - meta.seq - rawLength, - pendingQueryScanState - ) - pendingQueryScanState = scan.state - for (const query of scan.queries) { - if (pendingQueryChars + query.data.length > TERMINAL_QUERY_REPLAY_MAX_CHARS) { - pendingQueryOverflowed = true - break - } - pendingQuerySequences.push(query) - pendingQueryChars += query.data.length - } - } else { - pendingQueryScanState = EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE - } - const remainingBudget = Math.max( - 1, - TERMINAL_MULTIPLEX_PENDING_MAX_BYTES - pendingOutputBytes - ) - const measurement = measureTerminalStreamByteLength(data, { - stopAfterBytes: remainingBudget - }) - pendingOutput.push({ data, bytes: measurement.byteLength, meta }) - pendingOutputBytes += measurement.byteLength - const trimmed = trimPendingOutputToBudget(pendingOutput, pendingOutputBytes) - pendingOutputBytes = trimmed.bytes - pendingOutputOverflowed ||= trimmed.overflowed - return - } - outputBatcher?.push(data, meta) - }) - // Why: capture live bytes before mobile-fit awaits; registering presence first would suppress main while no view held the query. - const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) - unsubscribeData = () => { - releaseViewSubscriber() - unsubscribeStreamData() - } - // Server-side auto-fit: resize PTY to phone dims before serializing scrollback - try { - if (isMobile && clientId) { - await runtime.handleMobileSubscribe(ptyId, clientId, params.viewport) - } else if (clientId && params.viewport) { - // Why: legacy subscribe records geometry without taking ownership; only an explicit activity/claim frame may suppress the host. - registeredRemoteDesktopDriver = true - pendingRemoteDesktopViewport = params.viewport - } - if (closed) { - return - } - - let read = await runtime.readTerminal(params.terminal) - let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) - if (closed) { - return - } - // Why: missing model state (not blank snapshot text) signals a never-attached PTY; a renderer-sourced snapshot already proves attachment, so skip the remount. - const mountRequested = - missingHeadlessStateBeforeMobileFit && - serialized?.source !== 'renderer' && - (rendererMountRequestedBeforePty || - runtime.requestRendererTerminalTabMount(params.terminal)) - if (missingHeadlessStateBeforeMobileFit && mountRequested) { - // Why: an idle legacy PTY emits no later byte, so wait for a settle proving this remount completed before replaying its screen. - const mountWaitController = new AbortController() - const abortMountWait = (): void => mountWaitController.abort() - abortRendererMountWait = abortMountWait - if (signal?.aborted) { - abortMountWait() - } else { - signal?.addEventListener('abort', abortMountWait, { once: true }) - } - const rendererReadyPromise = runtime - .waitForRendererTerminalSerializer( - ptyId, - serializerGenerationBeforeMobileFit, - undefined, - mountWaitController.signal - ) - .catch(() => false) - const finishMountWait = (): void => { - signal?.removeEventListener('abort', abortMountWait) - if (abortRendererMountWait === abortMountWait) { - abortRendererMountWait = () => {} - } - } - void rendererReadyPromise.then(finishMountWait, finishMountWait) - let deadlineTimer: ReturnType | null = null - const initialDeadline = new Promise((resolve) => { - deadlineTimer = setTimeout(() => resolve(false), MOBILE_RENDERER_MOUNT_READY_TIMEOUT_MS) - if (typeof deadlineTimer.unref === 'function') { - deadlineTimer.unref() - } - }) - const rendererReady = await Promise.race([rendererReadyPromise, initialDeadline]) - if (deadlineTimer) { - clearTimeout(deadlineTimer) - } - if (closed || signal?.aborted) { - return - } - if (rendererReady) { - read = await runtime.readTerminal(params.terminal) - const stableRendererSnapshot = await serializeStableMobileRendererSnapshot( - runtime, - ptyId - ) - if (closed) { - return - } - if (stableRendererSnapshot?.data.length) { - serialized = stableRendererSnapshot - const trailingOutput = pendingOutput.flatMap((item) => { - const output = getOutputAfterSnapshotSeq(item, stableRendererSnapshot.seq) - const seq = item.meta?.seq - return output && typeof seq === 'number' ? [{ data: output.data, seq }] : [] - }) - runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery( - ptyId, - stableRendererSnapshot, - trailingOutput - ) - } - } else { - // Why: a renderer can settle after the bounded initial response; keep observing so an idle PTY self-heals without bytes. - lateRendererReadyPromise = rendererReadyPromise - } - } - let initialOutputOverflowed = false - if (pendingOutputOverflowed) { - pendingOutput.splice(0) - pendingOutputBytes = 0 - pendingOutputOverflowed = false - read = await runtime.readTerminal(params.terminal) - serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) - if (closed) { - return - } - if (pendingOutputOverflowed) { - initialOutputOverflowed = true - pendingOutput.splice(0) - pendingOutputBytes = 0 - pendingOutputOverflowed = false - } - } - const size = runtime.getTerminalSize(ptyId) - const displayMode = runtime.getMobileDisplayMode(ptyId) - // Why: layout seq is the mobile stale-event filter's high-water mark (undefined pre-transition is fail-open). See docs/mobile-terminal-layout-state-machine.md. - const layoutSeq = runtime.getLayout(ptyId)?.seq - // Why: only an output offset can cover buffered chunks; layout versions are a separate sequence domain. - let snapshotOutputSeq = serialized?.seq - emit({ - type: 'subscribed', - streamId, - lines: read.tail, - truncated: - initialOutputOverflowed || (!sendBinary && isTerminalReadPayloadIncomplete(read)), - cols: serialized?.cols ?? size?.cols, - rows: serialized?.rows ?? size?.rows, - displayMode, - seq: layoutSeq - }) - const snapshotStats = sendSnapshotFrames(sendFrame, { - kind: 'scrollback', - // Why: prefer the subscriber's viewport over the 80x24 stopgap when the PTY has - // no size yet — the mismatch made mobile burn its resubscribe budget (STA-3337). - cols: serialized?.cols ?? size?.cols ?? params.viewport?.cols ?? 80, - rows: serialized?.rows ?? size?.rows ?? params.viewport?.rows ?? 24, - displayMode, - seq: snapshotOutputSeq, - cwd: serialized?.cwd, - truncated: initialOutputOverflowed, - truncatedByByteBudget: serialized?.truncatedByByteBudget, - oscLinks: serialized?.oscLinks, - data: serialized?.data ?? '' - }) - console.log('[mobile-terminal-stream] snapshot', { - terminal: params.terminal, - streamId, - kind: 'scrollback', - bytes: snapshotStats.bytes, - chunks: snapshotStats.chunks, - scrollbackRows: serialized?.scrollbackRows, - truncatedByByteBudget: serialized?.truncatedByByteBudget === true - }) - // Why: baseline for resize re-stream gating; the client already rewrapped to these cols via the initial snapshot replay. - lastResizeCols = serialized?.cols ?? size?.cols - let recoveryAttempts = 0 - // Why: if the bounded pre-subscribe tail overflowed, only a fresh model snapshot covers the dropped middle without replay gaps. - while (pendingOutputOverflowed && recoveryAttempts < 2) { - pendingOutputOverflowed = false - recoveryAttempts += 1 - const recovery = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) - if (closed) { - return - } - if (!recovery) { - break - } - // Why: without an output seq (renderer fallback) covered chunks can't be trimmed exactly, so keep the bounded replay over an unverifiable snapshot. - if (typeof recovery.seq !== 'number') { - break - } - // Why: clients drop a repeat scrollback snapshot but apply 'resized' inline; omit seq so output-byte seqs don't pollute the layout-seq filter. - const recoveryStats = sendSnapshotFrames(sendFrame, { - kind: 'resized', - cols: recovery.cols, - rows: recovery.rows, - displayMode, - reason: 'pending-output-overflow', - source: recovery.source, - truncated: false, - truncatedByByteBudget: recovery.truncatedByByteBudget, - data: recovery.data - }) - console.log('[mobile-terminal-stream] recovery snapshot', { - terminal: params.terminal, - streamId, - reason: 'pending-output-overflow', - bytes: recoveryStats.bytes, - chunks: recoveryStats.chunks, - scrollbackRows: recovery.scrollbackRows, - truncatedByByteBudget: recovery.truncatedByByteBudget === true - }) - const trimmed = trimPendingOutputCoveredBySnapshot(pendingOutput, recovery.seq) - pendingOutput = trimmed.chunks - pendingOutputBytes = trimmed.bytes - snapshotOutputSeq = recovery.seq - } - buffering = false - const bufferedOutput = pendingOutput.splice(0) - const queryReplayData = pendingQueryOverflowed - ? '' - : pendingQuerySequences - .filter( - (query) => - initialOutputOverflowed || - (typeof snapshotOutputSeq === 'number' && query.startSeq < snapshotOutputSeq) - ) - .map((query) => query.data) - .join('') - if (queryReplayData) { - // Why: snapshots omit control queries but their seq trims the live chunk; replay the post-snapshot query so the mobile xterm answers once. - outputBatcher.push(queryReplayData) - } - if (!initialOutputOverflowed) { - for (const item of bufferedOutput) { - const uncovered = getOutputAfterSnapshotSeq(item, snapshotOutputSeq) - let uncoveredData = uncovered?.data ?? null - let uncoveredMeta = uncovered?.meta - if ( - uncoveredData && - uncoveredData !== item.data && - typeof snapshotOutputSeq === 'number' && - typeof item.meta?.seq === 'number' && - typeof item.meta.rawLength === 'number' - ) { - if (item.meta.rawLength === item.data.length) { - uncoveredMeta = { ...item.meta, rawLength: uncoveredData.length } - } - uncoveredData = stripSnapshotBoundaryQuerySuffixes( - uncoveredData, - snapshotOutputSeq, - snapshotOutputSeq, - pendingQuerySequences - ) - } - if (uncoveredData) { - outputBatcher.push(uncoveredData, uncoveredMeta) - } - } - } - pendingOutputBytes = 0 - outputBatcher.flush() - const lateRendererReady = lateRendererReadyPromise - lateRendererReadyPromise = null - if (lateRendererReady) { - void lateRendererReady - .then(async (rendererReady) => { - if (!rendererReady || closed) { - return - } - outputBatcher?.flush() - const recovery = await serializeStableMobileRendererSnapshot(runtime, ptyId) - if (closed) { - return - } - if (!recovery?.data.length) { - return - } - // Why: late recovery has no buffered-output gate, so only an exact renderer high-water may reset mobile without erasing live bytes. - if (recovery.seq !== runtime.getPtyOutputSequence(ptyId)) { - return - } - runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery(ptyId, recovery) - // Why: shipped mobile clients apply resized snapshots in place, so a blank xterm recovers without resubscribe. - const recoveryStats = sendSnapshotFrames(sendFrame, { - kind: 'resized', - cols: recovery.cols, - rows: recovery.rows, - displayMode, - reason: 'renderer-mount-ready', - source: recovery.source, - truncated: false, - truncatedByByteBudget: recovery.truncatedByByteBudget, - data: recovery.data - }) - lastResizeCols = recovery.cols - console.log('[mobile-terminal-stream] recovery snapshot', { - terminal: params.terminal, - streamId, - reason: 'renderer-mount-ready', - bytes: recoveryStats.bytes, - chunks: recoveryStats.chunks, - scrollbackRows: recovery.scrollbackRows, - truncatedByByteBudget: recovery.truncatedByByteBudget === true - }) - }) - .catch(() => {}) - } - const sendResizedFrame = (event: { - cols: number - rows: number - displayMode: string - reason: string - seq?: number - }): void => { - lastResizeCols = event.cols - sendFrame( - TerminalStreamOpcode.Resized, - encodeTerminalStreamJson({ - cols: event.cols, - rows: event.rows, - displayMode: event.displayMode, - reason: event.reason, - seq: event.seq - }) - ) - } - unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, (event) => { - outputBatcher?.flush() - const eventGeneration = resizeGeneration + 1 - resizeGeneration = eventGeneration - // Why: xterm only re-wraps soft-wrapped lines, so a width change needs a full re-serialize+replay to rewrap restored hard-wrapped scrollback. - const widthChanged = isMobile && event.cols !== lastResizeCols - if (widthChanged) { - lastResizeCols = event.cols - void sendMobileResizeRestream( - runtime, - ptyId, - sendFrame, - event, - () => !closed && resizeGeneration === eventGeneration - ) - .then((restreamed) => { - if (closed || resizeGeneration !== eventGeneration) { - return - } - if (!restreamed) { - sendResizedFrame(event) - } - }) - // Why: on re-stream failure, still emit the geometry-only Resized frame so the client never misses the resize. - .catch(() => { - if (closed || resizeGeneration !== eventGeneration) { - return - } - sendResizedFrame(event) - }) - return - } - sendResizedFrame(event) - }) - - // Install the resize listener before draining the parked viewport, since applyLayout emits synchronously. - if ( - clientId && - params.client && - registeredRemoteDesktopDriver && - pendingRemoteDesktopViewport - ) { - const viewport = pendingRemoteDesktopViewport - pendingRemoteDesktopViewport = null - void updateViewportForClient( - runtime, - ptyId, - remoteDesktopSubscriptionKey, - params.client, - viewport, - 'desktop', - 'register', - !supportsDesktopViewportClaims - ).catch(() => {}) - } - - // Legacy fit-override-changed for non-mobile (desktop) subscribers - unsubscribeFit = !isMobile - ? runtime.subscribeToFitOverrideChanges(ptyId, (event) => { - const mode = - event.mode === 'mobile-fit' - ? event.mode - : (runtime.getRemoteDesktopFitHold?.(ptyId, remoteDesktopSubscriptionKey).mode ?? - 'desktop-fit') - emit({ - type: 'fit-override-changed', - mode, - cols: event.cols, - rows: event.rows - }) - }) - : () => {} - } catch (error) { - registration.releaseIfCurrent() - throw error - } - - await streamClosed - } - }), - defineMethod({ - name: 'terminal.unsubscribe', - params: TerminalUnsubscribe, - handler: async (params, { runtime, connectionId }) => { - // Why: only the connection that owns the subscription may retire it — a stale - // unsubscribe from the pre-reconnect socket names an id the replacement now owns. - let unsubscribed = runtime.cleanupSubscriptionIfOwnedByConnection( - params.subscriptionId, - connectionId - ) - // Why: older builds send a bare-handle subscriptionId, so also try the reconstructed `${terminal}:${clientId}` composite key. - // Why AND over the calls that ran: a clientless stream registers under the bare id - // and a client-scoped one under the composite, so either call can be the real - // teardown. Reporting false needs one genuine refusal, not merely a missing id. - if (params.client && !params.subscriptionId.includes(':')) { - unsubscribed = - runtime.cleanupSubscriptionIfOwnedByConnection( - `${params.subscriptionId}:${params.client.id}`, - connectionId - ) && unsubscribed - } - return { unsubscribed } - } - }), - defineMethod({ - name: 'terminal.getAutoRestoreFit', - params: z.object({}), - handler: async (_params, { runtime }) => ({ - ms: runtime.getMobileAutoRestoreFitMs() - }) - }), - defineMethod({ - name: 'terminal.setAutoRestoreFit', - params: TerminalSetAutoRestoreFit, - handler: async (params, { runtime }) => ({ - ms: runtime.setMobileAutoRestoreFitMs(params.ms) - }) - }) + ...TERMINAL_QUERY_METHODS, + ...TERMINAL_SEND_METHODS, + ...TERMINAL_LIFECYCLE_METHODS, + ...TERMINAL_VIEWPORT_METHODS_BEFORE_STREAMS, + ...TERMINAL_MULTIPLEX_METHODS, + ...TERMINAL_SUBSCRIBE_METHODS, + ...TERMINAL_VIEWPORT_METHODS_AFTER_STREAMS ] diff --git a/src/main/runtime/rpc/methods/terminal/stream-schemas.ts b/src/main/runtime/rpc/methods/terminal/stream-schemas.ts new file mode 100644 index 00000000000..04a6990fe51 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/stream-schemas.ts @@ -0,0 +1,78 @@ +import { z } from 'zod' +import { requiredString } from '../../schemas' +import { TerminalViewport } from './unary-schemas' + +const TerminalHandle = z.object({ terminal: requiredString('Missing terminal handle') }) + +export const TerminalResizeForClient = z.discriminatedUnion('mode', [ + z.object({ + terminal: requiredString('Missing terminal handle'), + mode: z.literal('mobile-fit'), + cols: z.number().finite().positive(), + rows: z.number().finite().positive(), + clientId: requiredString('Missing client ID') + }), + z.object({ + terminal: requiredString('Missing terminal handle'), + mode: z.literal('restore'), + clientId: requiredString('Missing client ID') + }) +]) + +export const TerminalSubscribe = TerminalHandle.extend({ + client: z + .object({ + id: requiredString('Missing client ID'), + type: z.enum(['mobile', 'desktop']).default('desktop') + }) + .optional(), + viewport: TerminalViewport.optional(), + capabilities: z + .object({ + terminalBinaryStream: z.literal(1).optional(), + desktopViewportClaims: z.literal(1).optional(), + mobileInputLeaseOnly: z.literal(1).optional(), + writeUnavailable: z.literal(1).optional() + }) + .optional() +}) + +export const TerminalMultiplex = z.object({}) + +export const TerminalMultiplexSubscribeFrame = TerminalHandle.extend({ + streamId: z.number().int().min(1), + client: z + .object({ + id: requiredString('Missing client ID'), + type: z.enum(['mobile', 'desktop']).default('desktop') + }) + .optional(), + viewport: TerminalViewport.optional(), + capabilities: z + .object({ + ackOutput: z.literal(1).optional(), + ackOutputSourceRanges: z.literal(1).optional(), + desktopViewportClaims: z.literal(1).optional(), + outputPause: z.literal(1).optional(), + writeUnavailable: z.literal(1).optional() + }) + .optional() +}) + +export const TerminalMultiplexLegacyAckFrame = z + .object({ + bytes: z.number().int().nonnegative() + }) + .strict() + +export const TerminalMultiplexSourceRangeAckFrame = z + .object({ + streamGeneration: z.string().min(1), + ackedEndByte: z.number().int().nonnegative() + }) + .strict() + +export const TerminalMultiplexSnapshotRequestFrame = z.object({ + requestId: z.number().int().positive().optional(), + scrollbackRows: z.number().finite().optional() +}) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts b/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts new file mode 100644 index 00000000000..7313eb82b94 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts @@ -0,0 +1,188 @@ +import { InvalidArgumentError } from '../../core' +import type { + DriverState, + OrcaRuntimeService, + SubscriptionRegistration +} from '../../../orca-runtime' +import { + TERMINAL_INPUT_MAX_BYTES, + TERMINAL_INPUT_TOO_LARGE_ERROR, + isTerminalInputTooLargeWithYield +} from '../../../../../shared/terminal-input' +import type { TerminalViewportClient } from './terminal-stream-types' + +export function isTerminalInputLockedForClient( + runtime: OrcaRuntimeService, + ptyId: string, + client: TerminalViewportClient | undefined +): boolean { + if (client?.type === 'mobile') { + return false + } + // Why: pre-refactor mobile builds sent no client metadata, so treat a missing client as legacy mobile (unlocked). + if (!client) { + return false + } + return runtime.getDriver(ptyId).kind === 'mobile' +} + +export async function assertTerminalSendTextWithinLimit(text: string | undefined): Promise { + if (!text) { + return + } + // Why: sends can be paste-sized; validate outside Zod so large input yields before runtime dispatch. + if (await isTerminalInputTooLargeWithYield(text, TERMINAL_INPUT_MAX_BYTES)) { + throw new InvalidArgumentError(TERMINAL_INPUT_TOO_LARGE_ERROR) + } +} + +export function resolveMobileFloorClientId( + driver: DriverState | null, + client: TerminalViewportClient | undefined +): string | null { + if (client?.type === 'mobile') { + return client.id + } + if (!client && driver?.kind === 'mobile') { + return driver.clientId + } + return null +} + +export type TerminalStreamInputOutcome = 'delivered' | 'rejected' | 'failed' + +export function watchSubscriptionLifetime( + runtime: OrcaRuntimeService, + ptyId: string, + signal: AbortSignal | undefined, + registration: SubscriptionRegistration +): () => void { + let unsubscribeExit: (() => void) | null = null + let removeAbort: (() => void) | null = null + let stopped = false + const stop = (): void => { + stopped = true + unsubscribeExit?.() + removeAbort?.() + } + const release = (): void => { + registration.releaseIfCurrent() + stop() + } + unsubscribeExit = runtime.subscribeToPtyExit(ptyId, release) + if (stopped) { + unsubscribeExit() + return stop + } + if (!signal) { + return stop + } + if (signal.aborted) { + release() + return stop + } + const onAbort = (): void => release() + removeAbort = () => signal.removeEventListener('abort', onAbort) + signal.addEventListener('abort', onAbort, { once: true }) + if (stopped) { + removeAbort() + } + return stop +} + +export function isTerminalStreamInputRejection(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + return message.includes('terminal_not_writable') || message.includes('terminal_handle_stale') +} + +export async function sendTerminalStreamInput( + runtime: OrcaRuntimeService, + args: { + terminal: string + text: string + client: TerminalViewportClient | undefined + isMobile: boolean + } +): Promise { + const action = { text: args.text, enter: false, interrupt: false } + const clientId = args.isMobile ? args.client?.id : undefined + const floorClaim: MobileInputFloorClaimHolder = { current: null } + try { + if (!clientId) { + const result = await runtime.sendTerminal(args.terminal, action) + return result.accepted ? 'delivered' : 'rejected' + } + const result = await runtime.sendTerminal(args.terminal, action, { + reserveWrite: (writePtyId) => { + const claim = runtime.beginMobileInputFloor(writePtyId, clientId) + if (!claim) { + throw new Error('mobile_input_floor_unavailable') + } + floorClaim.current = claim + }, + afterWrite: () => commitMobileInputFloorClaim(floorClaim) + }) + if (!result.accepted) { + floorClaim.current?.rollback() + return 'rejected' + } + return 'delivered' + } catch (error) { + floorClaim.current?.rollback() + return isTerminalStreamInputRejection(error) ? 'rejected' : 'failed' + } +} + +export type MobileInputFloorClaimHolder = { + current: ReturnType +} + +export async function commitMobileInputFloorClaim( + claim: MobileInputFloorClaimHolder +): Promise { + const current = claim.current + if (!current) { + return + } + try { + await current.commit() + } finally { + // Why: the runtime may yield before the next write, which then needs a fresh reservation if desktop reclaimed the floor. + if (claim.current === current) { + claim.current = null + } + } +} + +export function getTerminalSendGuardRefusedReason( + error: unknown +): 'no-agent' | 'permission' | undefined { + const message = error instanceof Error ? error.message : String(error) + if (message.includes('terminal_guard_permission')) { + return 'permission' + } + if (message.includes('terminal_guard_no_agent')) { + return 'no-agent' + } + return undefined +} + +export function isTerminalSendGuardNotWritable(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + return message.includes('terminal_guard_not_writable') +} + +export function assertTerminalSendExactPtyBinding( + runtime: OrcaRuntimeService, + handle: string, + expectedPtyId: string | undefined +): void { + try { + if (expectedPtyId && runtime.resolveLiveLeafForHandle(handle)?.ptyId === expectedPtyId) { + return + } + } catch { + // Fall through to the stable guarded-send result below. + } + throw new Error('terminal_guard_not_writable') +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-binary-control-frames.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-binary-control-frames.ts new file mode 100644 index 00000000000..1033464071c --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-binary-control-frames.ts @@ -0,0 +1,141 @@ +import { + TerminalStreamOpcode, + decodeTerminalStreamJson, + decodeTerminalStreamText +} from '../../../../../shared/terminal-stream-protocol' +import { isTerminalInputLockedForClient, sendTerminalStreamInput } from './terminal-input-delivery' +import type { TerminalSubscriptionArgs } from './terminal-legacy-subscription-types' +import { updateViewportForClient } from './terminal-viewport-update' + +type LegacyBinaryControlState = { + isClosed: () => boolean + isBuffering: () => boolean + setRegisteredRemoteDesktopDriver: () => void + setPendingRemoteDesktopViewport: (viewport: { cols: number; rows: number }) => void + getDesktopClaimTail: () => Promise + setDesktopClaimTail: (tail: Promise) => void + sendFrame: (opcode: TerminalStreamOpcode, payload?: Uint8Array) => void +} + +export function registerLegacyBinaryControlFrames( + args: TerminalSubscriptionArgs, + streamId: number, + remoteDesktopSubscriptionKey: string, + controls: LegacyBinaryControlState +): () => void { + const { + params, + runtime, + registerBinaryStreamHandler, + ptyId, + clientId, + isMobile, + supportsDesktopViewportClaims, + supportsWriteUnavailable + } = args + if (!registerBinaryStreamHandler) { + return () => {} + } + return registerBinaryStreamHandler(streamId, (frame) => { + if (controls.isClosed()) { + return + } + if (frame.opcode === TerminalStreamOpcode.Input) { + const text = decodeTerminalStreamText(frame.payload) + if (!text) { + return + } + if (isTerminalInputLockedForClient(runtime, ptyId, params.client)) { + return + } + void controls.getDesktopClaimTail().then(async (claimed) => { + if (!claimed || isTerminalInputLockedForClient(runtime, ptyId, params.client)) { + return + } + const outcome = await sendTerminalStreamInput(runtime, { + terminal: params.terminal, + text, + client: params.client, + isMobile + }) + if (!controls.isClosed() && outcome === 'rejected' && supportsWriteUnavailable) { + controls.sendFrame(TerminalStreamOpcode.WriteUnavailable) + } + }) + return + } + if (frame.opcode === TerminalStreamOpcode.Resize && params.client) { + const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>(frame.payload) + if (!viewport || typeof viewport.cols !== 'number' || typeof viewport.rows !== 'number') { + return + } + const cols = viewport.cols + const rows = viewport.rows + if (clientId) { + controls.setRegisteredRemoteDesktopDriver() + if (controls.isBuffering()) { + controls.setPendingRemoteDesktopViewport({ cols: viewport.cols, rows: viewport.rows }) + return + } + } + controls.setDesktopClaimTail( + controls + .getDesktopClaimTail() + .then(async (priorClaimed) => { + const result = await updateViewportForClient( + runtime, + ptyId, + remoteDesktopSubscriptionKey, + params.client!, + { cols, rows }, + 'desktop', + 'register', + !supportsDesktopViewportClaims + ) + return supportsDesktopViewportClaims ? priorClaimed && result.applied : result.applied + }) + .catch(() => false) + ) + return + } + if ( + frame.opcode === TerminalStreamOpcode.ClaimViewport && + params.client && + clientId && + !isMobile + ) { + const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>(frame.payload) + if (!viewport || typeof viewport.cols !== 'number' || typeof viewport.rows !== 'number') { + return + } + const cols = viewport.cols + const rows = viewport.rows + controls.setRegisteredRemoteDesktopDriver() + controls.setDesktopClaimTail( + controls + .getDesktopClaimTail() + .then( + () => + runtime.updateRemoteDesktopViewer( + ptyId, + remoteDesktopSubscriptionKey, + clientId, + cols, + rows, + true + ), + () => + runtime.updateRemoteDesktopViewer( + ptyId, + remoteDesktopSubscriptionKey, + clientId, + cols, + rows, + true + ) + ) + .catch(() => false) + ) + } + }) +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-simple-subscriptions.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-simple-subscriptions.ts new file mode 100644 index 00000000000..ad4c7dd05da --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-simple-subscriptions.ts @@ -0,0 +1,184 @@ +import { createTerminalOutputBatcher } from './terminal-output-batcher' +import type { TerminalOutputBatcher } from './terminal-output-batcher' +import { isTerminalReadPayloadIncomplete } from './terminal-stream-replay' +import { serializeBudgetedMobileSnapshot } from './terminal-snapshot-publication' +import { updateViewportForClient } from './terminal-viewport-update' +import { watchSubscriptionLifetime } from './terminal-input-delivery' +import type { TerminalSubscriptionArgs } from './terminal-legacy-subscription-types' +import { allocateTerminalSubscriptionStreamId } from './terminal-subscription-stream-id' + +export async function runTerminalLeaseSubscription(args: TerminalSubscriptionArgs): Promise { + const { params, runtime, connectionId, signal, emit, ptyId, clientId } = args + if (!clientId) { + return + } + let closed = false + let stopWatchingLifetime = (): void => {} + let resolveStream = (): void => {} + const streamClosed = new Promise((resolve) => { + resolveStream = resolve + }) + const subscriptionId = `${params.terminal}:${clientId}` + // Why: chat needs the input-floor ack without registering a view subscriber or transporting duplicate PTY output. + const registration = runtime.registerOwnedSubscriptionCleanup( + subscriptionId, + () => { + stopWatchingLifetime() + closed = true + runtime.handleMobileUnsubscribe(ptyId, clientId) + emit({ type: 'end' }) + resolveStream() + }, + connectionId + ) + stopWatchingLifetime = watchSubscriptionLifetime(runtime, ptyId, signal, registration) + if (closed) { + // Why: an already-exited pty releases synchronously, so cleanup ran before this setup registers anything. + return + } + try { + // Why: a lease-only subscriber has no terminal view, so its cached viewport must never phone-fit the PTY. + await runtime.handleMobileSubscribe(ptyId, clientId, undefined) + if (closed || signal?.aborted) { + // Why: a disconnect can win the awaited subscribe and resurrect mobile presence after cleanup already released it. + // Unguarded on purpose: this must still fire when our own cleanup already ran. + // Safe only because lease-only passes no viewport and both !viewport paths in + // handleMobileSubscribeInternal return with no await, so no rebind can land + // first. Adding an await there — or passing a viewport here — makes a + // superseded handler delete the replacement's (ptyId, clientId) presence. + runtime.handleMobileUnsubscribe(ptyId, clientId) + if (!closed) { + registration.releaseIfCurrent() + } + return + } + emit({ type: 'subscribed', streamId: null, lines: [], truncated: false }) + await streamClosed + } catch (error) { + registration.releaseIfCurrent() + throw error + } +} + +export async function runTerminalJsonSubscription(args: TerminalSubscriptionArgs): Promise { + const { + params, + runtime, + connectionId, + signal, + emit, + ptyId, + clientId, + supportsDesktopViewportClaims + } = args + // Why: only unregister the width floor this subscription took (see the multiplex stream's registeredRemoteDesktopDriver note). + let registeredRemoteDesktopDriver = false + + // Why: a hidden watcher and a visible pane can subscribe to one terminal, so key by client so neither stream evicts the other. + const subscriptionId = clientId ? `${params.terminal}:${clientId}` : params.terminal + const remoteDesktopSubscriptionKey = `json:${allocateTerminalSubscriptionStreamId()}` + let closed = false + let outputBatcher: TerminalOutputBatcher | null = null + let unsubscribeData = (): void => {} + let unsubscribeFit = (): void => {} + let stopWatchingLifetime = (): void => {} + let resolveStream = (): void => {} + const streamClosed = new Promise((resolve) => { + resolveStream = resolve + }) + // Why: register before viewport/snapshot awaits so a socket close can't orphan the stream listeners or its remote-desktop width floor. + const registration = runtime.registerOwnedSubscriptionCleanup( + subscriptionId, + () => { + stopWatchingLifetime() + closed = true + outputBatcher?.flush() + outputBatcher?.dispose() + unsubscribeData() + unsubscribeFit() + if (registeredRemoteDesktopDriver && clientId) { + runtime.unregisterRemoteDesktopViewer(ptyId, remoteDesktopSubscriptionKey) + } + emit({ type: 'end' }) + resolveStream() + }, + connectionId + ) + stopWatchingLifetime = watchSubscriptionLifetime(runtime, ptyId, signal, registration) + if (closed) { + // Why: an already-exited pty releases synchronously, so cleanup ran before this setup registers anything. + return + } + try { + if (clientId && params.client && params.viewport) { + registeredRemoteDesktopDriver = true + await updateViewportForClient( + runtime, + ptyId, + remoteDesktopSubscriptionKey, + params.client, + params.viewport, + 'desktop', + 'register', + !supportsDesktopViewportClaims + ) + } + if (closed || signal?.aborted) { + registration.releaseIfCurrent() + return + } + const read = await runtime.readTerminal(params.terminal) + const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, false) + if (closed || signal?.aborted) { + registration.releaseIfCurrent() + return + } + const size = runtime.getTerminalSize(ptyId) + const displayMode = runtime.getMobileDisplayMode(ptyId) + const seq = runtime.getLayout(ptyId)?.seq + emit({ + type: 'scrollback', + lines: read.tail, + truncated: isTerminalReadPayloadIncomplete(read), + serialized: serialized?.data, + oscLinks: serialized?.oscLinks, + cwd: serialized?.cwd, + // Why: an empty snapshot with no PTY size must still report the dims the fit + // will produce — dimless frames re-armed the mobile fit loop (STA-3337). + cols: serialized?.cols ?? size?.cols ?? params.viewport?.cols, + rows: serialized?.rows ?? size?.rows ?? params.viewport?.rows, + displayMode, + seq + }) + outputBatcher = createTerminalOutputBatcher((chunk) => { + emit({ type: 'data', chunk }) + }) + const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data) => { + outputBatcher?.push(data) + }) + // Why: the legacy JSON stream can feed a live xterm view, so register as a view subscriber; worst case is a withheld model reply, safer than a double reply. + const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) + unsubscribeData = () => { + releaseViewSubscriber() + unsubscribeStreamData() + } + unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => { + outputBatcher?.flush() + const mode = + event.mode === 'mobile-fit' + ? event.mode + : (runtime.getRemoteDesktopFitHold?.(ptyId, remoteDesktopSubscriptionKey).mode ?? + 'desktop-fit') + emit({ + type: 'fit-override-changed', + mode, + cols: event.cols, + rows: event.rows + }) + }) + await streamClosed + } catch (error) { + registration.releaseIfCurrent() + throw error + } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-binary.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-binary.ts new file mode 100644 index 00000000000..08639f16f48 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-binary.ts @@ -0,0 +1,286 @@ +import { + TerminalStreamOpcode, + encodeTerminalStreamFrame, + encodeTerminalStreamJson +} from '../../../../../shared/terminal-stream-protocol' +import { iterateTerminalOutputFrameChunks } from '../../terminal-output-frame-chunks' +import { + EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE, + scanTerminalReplyQuerySequences, + type TerminalReplyQueryScanState, + type TerminalReplyQuerySequence +} from '../../../../../shared/terminal-reply-query-scan' +import { TERMINAL_MULTIPLEX_PENDING_MAX_BYTES } from '../../../../../shared/terminal-multiplex-flow-control' +import { measureTerminalStreamByteLength } from '../../terminal-stream-byte-length' +import { createTerminalOutputBatcher, type TerminalOutputBatcher } from './terminal-output-batcher' +import { watchSubscriptionLifetime } from './terminal-input-delivery' +import { trimPendingOutputToBudget } from './terminal-stream-replay' +import { allocateTerminalSubscriptionStreamId } from './terminal-subscription-stream-id' +import type { + LegacyBinarySubscriptionState, + TerminalSubscriptionArgs +} from './terminal-legacy-subscription-types' +import type { TerminalOutputChunk } from './terminal-stream-types' +import { publishLegacyBinaryInitialSnapshot } from './terminal-legacy-subscribe-snapshot' +import { activateLegacyBinarySubscription } from './terminal-legacy-subscribe-live' +import { registerLegacyBinaryControlFrames } from './terminal-legacy-binary-control-frames' +const TERMINAL_QUERY_REPLAY_MAX_CHARS = 16 * 1024 +export async function runTerminalBinarySubscription(args: TerminalSubscriptionArgs): Promise { + const { params, runtime, connectionId, sendBinary, signal, emit, ptyId, clientId, isMobile } = + args + if (!sendBinary) { + throw new Error('binary_terminal_stream_required') + } + let registeredRemoteDesktopDriver = false + const streamId = allocateTerminalSubscriptionStreamId() + const remoteDesktopSubscriptionKey = `stream:${streamId}` + let cursor = 0 + let closed = false + let buffering = true + let pendingRemoteDesktopViewport: { cols: number; rows: number } | null = null + let lastResizeCols: number | undefined + let resizeGeneration = 0 + let pendingOutput: TerminalOutputChunk[] = [] + let desktopClaimTail: Promise = Promise.resolve(true) + let pendingOutputBytes = 0 + let pendingOutputOverflowed = false + let pendingQueryScanState: TerminalReplyQueryScanState = EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE + const pendingQuerySequences: TerminalReplyQuerySequence[] = [] + let pendingQueryChars = 0 + let pendingQueryOverflowed = false + let unsubscribeData = (): void => {} + let unsubscribeResize = (): void => {} + let unsubscribeFit = (): void => {} + let unregisterBinaryHandler = (): void => {} + let abortRendererMountWait = (): void => {} + let stopWatchingLifetime = (): void => {} + let lateRendererReadyPromise: Promise | null = null + let outputBatcher: TerminalOutputBatcher | null = null + let resolveStream = (): void => {} + const streamClosed = new Promise((resolve) => { + resolveStream = resolve + }) + // Why: register cleanup before any await so a mid-subscribe disconnect still removes mobile presence; client-scoped ids also allow parallel desktop subscribers. + const subscriptionId = clientId ? `${params.terminal}:${clientId}` : params.terminal + const registration = runtime.registerOwnedSubscriptionCleanup( + subscriptionId, + () => { + stopWatchingLifetime() + outputBatcher?.flush() + outputBatcher?.dispose() + closed = true + unsubscribeData() + unsubscribeResize() + unsubscribeFit() + unregisterBinaryHandler() + abortRendererMountWait() + if (isMobile && clientId) { + runtime.handleMobileUnsubscribe(ptyId, clientId) + } else if (registeredRemoteDesktopDriver && clientId) { + runtime.unregisterRemoteDesktopViewer(ptyId, remoteDesktopSubscriptionKey) + } + emit({ type: 'end' }) + resolveStream() + }, + connectionId + ) + stopWatchingLifetime = watchSubscriptionLifetime(runtime, ptyId, signal, registration) + if (closed) { + // Why: an already-exited pty releases synchronously, so cleanup ran before this setup registers anything. + return + } + const sendFrame = ( + opcode: TerminalStreamOpcode, + payload: Uint8Array = new Uint8Array(), + frameSeq = cursor++ + ): void => { + if (closed || !sendBinary) { + return + } + sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: frameSeq, payload })) + } + outputBatcher = createTerminalOutputBatcher((data, meta) => { + if (meta?.cwd !== undefined) { + sendFrame( + TerminalStreamOpcode.Metadata, + encodeTerminalStreamJson({ cwd: meta.cwd }), + meta.seq + ) + } + for (const chunk of iterateTerminalOutputFrameChunks(data, meta)) { + sendFrame(chunk.opcode ?? TerminalStreamOpcode.Output, chunk.bytes, chunk.seq) + } + }) + unregisterBinaryHandler = registerLegacyBinaryControlFrames( + args, + streamId, + remoteDesktopSubscriptionKey, + { + isClosed: () => closed, + isBuffering: () => buffering, + setRegisteredRemoteDesktopDriver: () => { + registeredRemoteDesktopDriver = true + }, + setPendingRemoteDesktopViewport: (viewport) => { + pendingRemoteDesktopViewport = viewport + }, + getDesktopClaimTail: () => desktopClaimTail, + setDesktopClaimTail: (tail) => { + desktopClaimTail = tail + }, + sendFrame + } + ) + const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { + if (closed) { + return + } + if (buffering) { + const rawLength = meta?.rawLength + if ( + typeof meta?.seq === 'number' && + typeof rawLength === 'number' && + rawLength === data.length + ) { + const scan = scanTerminalReplyQuerySequences( + data, + meta.seq - rawLength, + pendingQueryScanState + ) + pendingQueryScanState = scan.state + for (const query of scan.queries) { + if (pendingQueryChars + query.data.length > TERMINAL_QUERY_REPLAY_MAX_CHARS) { + pendingQueryOverflowed = true + break + } + pendingQuerySequences.push(query) + pendingQueryChars += query.data.length + } + } else { + pendingQueryScanState = EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE + } + const remainingBudget = Math.max(1, TERMINAL_MULTIPLEX_PENDING_MAX_BYTES - pendingOutputBytes) + const measurement = measureTerminalStreamByteLength(data, { + stopAfterBytes: remainingBudget + }) + pendingOutput.push({ data, bytes: measurement.byteLength, meta }) + pendingOutputBytes += measurement.byteLength + const trimmed = trimPendingOutputToBudget(pendingOutput, pendingOutputBytes) + pendingOutputBytes = trimmed.bytes + pendingOutputOverflowed ||= trimmed.overflowed + return + } + outputBatcher?.push(data, meta) + }) + // Why: capture live bytes before mobile-fit awaits; registering presence first would suppress main while no view held the query. + const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) + unsubscribeData = () => { + releaseViewSubscriber() + unsubscribeStreamData() + } + const state: LegacyBinarySubscriptionState = { + streamId, + remoteDesktopSubscriptionKey, + get closed() { + return closed + }, + set closed(value) { + closed = value + }, + get buffering() { + return buffering + }, + set buffering(value) { + buffering = value + }, + get pendingRemoteDesktopViewport() { + return pendingRemoteDesktopViewport + }, + set pendingRemoteDesktopViewport(value) { + pendingRemoteDesktopViewport = value + }, + get lastResizeCols() { + return lastResizeCols + }, + set lastResizeCols(value) { + lastResizeCols = value + }, + get resizeGeneration() { + return resizeGeneration + }, + set resizeGeneration(value) { + resizeGeneration = value + }, + get pendingOutput() { + return pendingOutput + }, + set pendingOutput(value) { + pendingOutput = value + }, + get pendingOutputBytes() { + return pendingOutputBytes + }, + set pendingOutputBytes(value) { + pendingOutputBytes = value + }, + get pendingOutputOverflowed() { + return pendingOutputOverflowed + }, + set pendingOutputOverflowed(value) { + pendingOutputOverflowed = value + }, + pendingQuerySequences, + get pendingQueryOverflowed() { + return pendingQueryOverflowed + }, + set pendingQueryOverflowed(value) { + pendingQueryOverflowed = value + }, + get lateRendererReadyPromise() { + return lateRendererReadyPromise + }, + set lateRendererReadyPromise(value) { + lateRendererReadyPromise = value + }, + get abortRendererMountWait() { + return abortRendererMountWait + }, + set abortRendererMountWait(value) { + abortRendererMountWait = value + }, + outputBatcher: outputBatcher!, + get unsubscribeResize() { + return unsubscribeResize + }, + set unsubscribeResize(value) { + unsubscribeResize = value + }, + get unsubscribeFit() { + return unsubscribeFit + }, + set unsubscribeFit(value) { + unsubscribeFit = value + }, + get registeredRemoteDesktopDriver() { + return registeredRemoteDesktopDriver + }, + set registeredRemoteDesktopDriver(value) { + registeredRemoteDesktopDriver = value + }, + displayMode: 'auto', + registration, + streamClosed, + sendFrame + } + try { + await publishLegacyBinaryInitialSnapshot(args, state) + if (state.closed || signal?.aborted) { + return + } + activateLegacyBinarySubscription(args, state) + } catch (error) { + registration.releaseIfCurrent() + throw error + } + await streamClosed +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-live.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-live.ts new file mode 100644 index 00000000000..73a359d73af --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-live.ts @@ -0,0 +1,158 @@ +import { + TerminalStreamOpcode, + encodeTerminalStreamJson +} from '../../../../../shared/terminal-stream-protocol' +import { + sendMobileResizeRestream, + sendSnapshotFrames, + serializeStableMobileRendererSnapshot +} from './terminal-snapshot-publication' +import { updateViewportForClient } from './terminal-viewport-update' +import type { + LegacyBinarySubscriptionState, + TerminalSubscriptionArgs +} from './terminal-legacy-subscription-types' + +export function activateLegacyBinarySubscription( + args: TerminalSubscriptionArgs, + state: LegacyBinarySubscriptionState +): void { + const { params, runtime, emit, ptyId, clientId, isMobile, supportsDesktopViewportClaims } = args + const lateRendererReady = state.lateRendererReadyPromise + state.lateRendererReadyPromise = null + if (lateRendererReady) { + void lateRendererReady + .then(async (rendererReady) => { + if (!rendererReady || state.closed) { + return + } + state.outputBatcher?.flush() + const recovery = await serializeStableMobileRendererSnapshot(runtime, ptyId) + if (state.closed) { + return + } + if (!recovery?.data.length) { + return + } + // Why: late recovery has no buffered-output gate, so only an exact renderer high-water may reset mobile without erasing live bytes. + if (recovery.seq !== runtime.getPtyOutputSequence(ptyId)) { + return + } + runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery(ptyId, recovery) + // Why: shipped mobile clients apply resized snapshots in place, so a blank xterm recovers without resubscribe. + const recoveryStats = sendSnapshotFrames(state.sendFrame, { + kind: 'resized', + cols: recovery.cols, + rows: recovery.rows, + displayMode: state.displayMode, + reason: 'renderer-mount-ready', + source: recovery.source, + truncated: false, + truncatedByByteBudget: recovery.truncatedByByteBudget, + data: recovery.data + }) + state.lastResizeCols = recovery.cols + console.log('[mobile-terminal-stream] recovery snapshot', { + terminal: params.terminal, + streamId: state.streamId, + reason: 'renderer-mount-ready', + bytes: recoveryStats.bytes, + chunks: recoveryStats.chunks, + scrollbackRows: recovery.scrollbackRows, + truncatedByByteBudget: recovery.truncatedByByteBudget === true + }) + }) + .catch(() => {}) + } + const sendResizedFrame = (event: { + cols: number + rows: number + displayMode: string + reason: string + seq?: number + }): void => { + state.lastResizeCols = event.cols + state.sendFrame( + TerminalStreamOpcode.Resized, + encodeTerminalStreamJson({ + cols: event.cols, + rows: event.rows, + displayMode: event.displayMode, + reason: event.reason, + seq: event.seq + }) + ) + } + state.unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, (event) => { + state.outputBatcher?.flush() + const eventGeneration = state.resizeGeneration + 1 + state.resizeGeneration = eventGeneration + // Why: xterm only re-wraps soft-wrapped lines, so a width change needs a full re-serialize+replay to rewrap restored hard-wrapped scrollback. + const widthChanged = isMobile && event.cols !== state.lastResizeCols + if (widthChanged) { + state.lastResizeCols = event.cols + void sendMobileResizeRestream( + runtime, + ptyId, + state.sendFrame, + event, + () => !state.closed && state.resizeGeneration === eventGeneration + ) + .then((restreamed) => { + if (state.closed || state.resizeGeneration !== eventGeneration) { + return + } + if (!restreamed) { + sendResizedFrame(event) + } + }) + // Why: on re-stream failure, still emit the geometry-only Resized frame so the client never misses the resize. + .catch(() => { + if (state.closed || state.resizeGeneration !== eventGeneration) { + return + } + sendResizedFrame(event) + }) + return + } + sendResizedFrame(event) + }) + + // Install the resize listener before draining the parked viewport, since applyLayout emits synchronously. + if ( + clientId && + params.client && + state.registeredRemoteDesktopDriver && + state.pendingRemoteDesktopViewport + ) { + const viewport = state.pendingRemoteDesktopViewport + state.pendingRemoteDesktopViewport = null + void updateViewportForClient( + runtime, + ptyId, + state.remoteDesktopSubscriptionKey, + params.client, + viewport, + 'desktop', + 'register', + !supportsDesktopViewportClaims + ).catch(() => {}) + } + + // Legacy fit-override-changed for non-mobile (desktop) subscribers + state.unsubscribeFit = !isMobile + ? runtime.subscribeToFitOverrideChanges(ptyId, (event) => { + const mode = + event.mode === 'mobile-fit' + ? event.mode + : (runtime.getRemoteDesktopFitHold?.(ptyId, state.remoteDesktopSubscriptionKey).mode ?? + 'desktop-fit') + emit({ + type: 'fit-override-changed', + mode, + cols: event.cols, + rows: event.rows + }) + }) + : () => {} +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts new file mode 100644 index 00000000000..fcae3d02f6f --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts @@ -0,0 +1,265 @@ +import { + sendSnapshotFrames, + serializeBudgetedMobileSnapshot, + serializeStableMobileRendererSnapshot +} from './terminal-snapshot-publication' +import { + getOutputAfterSnapshotSeq, + isTerminalReadPayloadIncomplete, + stripSnapshotBoundaryQuerySuffixes, + trimPendingOutputCoveredBySnapshot +} from './terminal-stream-replay' +import type { + LegacyBinarySubscriptionState, + TerminalSubscriptionArgs +} from './terminal-legacy-subscription-types' + +const MOBILE_RENDERER_MOUNT_READY_TIMEOUT_MS = 3_000 + +export async function publishLegacyBinaryInitialSnapshot( + args: TerminalSubscriptionArgs, + state: LegacyBinarySubscriptionState +): Promise { + const { + params, + runtime, + signal, + sendBinary, + emit, + ptyId, + clientId, + isMobile, + missingHeadlessStateBeforeMobileFit, + rendererMountRequestedBeforePty, + serializerGenerationBeforeMobileFit + } = args + if (isMobile && clientId) { + await runtime.handleMobileSubscribe(ptyId, clientId, params.viewport) + } else if (clientId && params.viewport) { + // Why: legacy subscribe records geometry without taking ownership; only an explicit activity/claim frame may suppress the host. + state.registeredRemoteDesktopDriver = true + state.pendingRemoteDesktopViewport = params.viewport + } + if (state.closed) { + return + } + + let read = await runtime.readTerminal(params.terminal) + let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) + if (state.closed) { + return + } + // Why: missing model state (not blank snapshot text) signals a never-attached PTY; a renderer-sourced snapshot already proves attachment, so skip the remount. + const mountRequested = + missingHeadlessStateBeforeMobileFit && + serialized?.source !== 'renderer' && + (rendererMountRequestedBeforePty || runtime.requestRendererTerminalTabMount(params.terminal)) + if (missingHeadlessStateBeforeMobileFit && mountRequested) { + // Why: an idle legacy PTY emits no later byte, so wait for a settle proving this remount completed before replaying its screen. + const mountWaitController = new AbortController() + const abortMountWait = (): void => mountWaitController.abort() + state.abortRendererMountWait = abortMountWait + if (signal?.aborted) { + abortMountWait() + } else { + signal?.addEventListener('abort', abortMountWait, { once: true }) + } + const rendererReadyPromise = runtime + .waitForRendererTerminalSerializer( + ptyId, + serializerGenerationBeforeMobileFit, + undefined, + mountWaitController.signal + ) + .catch(() => false) + const finishMountWait = (): void => { + signal?.removeEventListener('abort', abortMountWait) + if (state.abortRendererMountWait === abortMountWait) { + state.abortRendererMountWait = () => {} + } + } + void rendererReadyPromise.then(finishMountWait, finishMountWait) + let deadlineTimer: ReturnType | null = null + const initialDeadline = new Promise((resolve) => { + deadlineTimer = setTimeout(() => resolve(false), MOBILE_RENDERER_MOUNT_READY_TIMEOUT_MS) + if (typeof deadlineTimer.unref === 'function') { + deadlineTimer.unref() + } + }) + const rendererReady = await Promise.race([rendererReadyPromise, initialDeadline]) + if (deadlineTimer) { + clearTimeout(deadlineTimer) + } + if (state.closed || signal?.aborted) { + return + } + if (rendererReady) { + read = await runtime.readTerminal(params.terminal) + const stableRendererSnapshot = await serializeStableMobileRendererSnapshot(runtime, ptyId) + if (state.closed) { + return + } + if (stableRendererSnapshot?.data.length) { + serialized = stableRendererSnapshot + const trailingOutput = state.pendingOutput.flatMap((item) => { + const output = getOutputAfterSnapshotSeq(item, stableRendererSnapshot.seq) + const seq = item.meta?.seq + return output && typeof seq === 'number' ? [{ data: output.data, seq }] : [] + }) + runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery( + ptyId, + stableRendererSnapshot, + trailingOutput + ) + } + } else { + // Why: a renderer can settle after the bounded initial response; keep observing so an idle PTY self-heals without bytes. + state.lateRendererReadyPromise = rendererReadyPromise + } + } + let initialOutputOverflowed = false + if (state.pendingOutputOverflowed) { + state.pendingOutput.splice(0) + state.pendingOutputBytes = 0 + state.pendingOutputOverflowed = false + read = await runtime.readTerminal(params.terminal) + serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) + if (state.closed) { + return + } + if (state.pendingOutputOverflowed) { + initialOutputOverflowed = true + state.pendingOutput.splice(0) + state.pendingOutputBytes = 0 + state.pendingOutputOverflowed = false + } + } + const size = runtime.getTerminalSize(ptyId) + state.displayMode = runtime.getMobileDisplayMode(ptyId) + // Why: layout seq is the mobile stale-event filter's high-water mark (undefined pre-transition is fail-open). See docs/mobile-terminal-layout-state-machine.md. + const layoutSeq = runtime.getLayout(ptyId)?.seq + // Why: only an output offset can cover buffered chunks; layout versions are a separate sequence domain. + let snapshotOutputSeq = serialized?.seq + emit({ + type: 'subscribed', + streamId: state.streamId, + lines: read.tail, + truncated: initialOutputOverflowed || (!sendBinary && isTerminalReadPayloadIncomplete(read)), + cols: serialized?.cols ?? size?.cols, + rows: serialized?.rows ?? size?.rows, + displayMode: state.displayMode, + seq: layoutSeq + }) + const snapshotStats = sendSnapshotFrames(state.sendFrame, { + kind: 'scrollback', + // Why: prefer the subscriber's viewport over the 80x24 stopgap when the PTY has + // no size yet — the mismatch made mobile burn its resubscribe budget (STA-3337). + cols: serialized?.cols ?? size?.cols ?? params.viewport?.cols ?? 80, + rows: serialized?.rows ?? size?.rows ?? params.viewport?.rows ?? 24, + displayMode: state.displayMode, + seq: snapshotOutputSeq, + cwd: serialized?.cwd, + truncated: initialOutputOverflowed, + truncatedByByteBudget: serialized?.truncatedByByteBudget, + oscLinks: serialized?.oscLinks, + data: serialized?.data ?? '' + }) + console.log('[mobile-terminal-stream] snapshot', { + terminal: params.terminal, + streamId: state.streamId, + kind: 'scrollback', + bytes: snapshotStats.bytes, + chunks: snapshotStats.chunks, + scrollbackRows: serialized?.scrollbackRows, + truncatedByByteBudget: serialized?.truncatedByByteBudget === true + }) + // Why: baseline for resize re-stream gating; the client already rewrapped to these cols via the initial snapshot replay. + state.lastResizeCols = serialized?.cols ?? size?.cols + let recoveryAttempts = 0 + // Why: if the bounded pre-subscribe tail overflowed, only a fresh model snapshot covers the dropped middle without replay gaps. + while (state.pendingOutputOverflowed && recoveryAttempts < 2) { + state.pendingOutputOverflowed = false + recoveryAttempts += 1 + const recovery = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) + if (state.closed) { + return + } + if (!recovery) { + break + } + // Why: without an output seq (renderer fallback) covered chunks can't be trimmed exactly, so keep the bounded replay over an unverifiable snapshot. + if (typeof recovery.seq !== 'number') { + break + } + // Why: clients drop a repeat scrollback snapshot but apply 'resized' inline; omit seq so output-byte seqs don't pollute the layout-seq filter. + const recoveryStats = sendSnapshotFrames(state.sendFrame, { + kind: 'resized', + cols: recovery.cols, + rows: recovery.rows, + displayMode: state.displayMode, + reason: 'pending-output-overflow', + source: recovery.source, + truncated: false, + truncatedByByteBudget: recovery.truncatedByByteBudget, + data: recovery.data + }) + console.log('[mobile-terminal-stream] recovery snapshot', { + terminal: params.terminal, + streamId: state.streamId, + reason: 'pending-output-overflow', + bytes: recoveryStats.bytes, + chunks: recoveryStats.chunks, + scrollbackRows: recovery.scrollbackRows, + truncatedByByteBudget: recovery.truncatedByByteBudget === true + }) + const trimmed = trimPendingOutputCoveredBySnapshot(state.pendingOutput, recovery.seq) + state.pendingOutput = trimmed.chunks + state.pendingOutputBytes = trimmed.bytes + snapshotOutputSeq = recovery.seq + } + state.buffering = false + const bufferedOutput = state.pendingOutput.splice(0) + const queryReplayData = state.pendingQueryOverflowed + ? '' + : state.pendingQuerySequences + .filter( + (query) => + initialOutputOverflowed || + (typeof snapshotOutputSeq === 'number' && query.startSeq < snapshotOutputSeq) + ) + .map((query) => query.data) + .join('') + if (queryReplayData) { + // Why: snapshots omit control queries but their seq trims the live chunk; replay the post-snapshot query so the mobile xterm answers once. + state.outputBatcher.push(queryReplayData) + } + if (!initialOutputOverflowed) { + for (const item of bufferedOutput) { + const uncovered = getOutputAfterSnapshotSeq(item, snapshotOutputSeq) + let uncoveredData = uncovered?.data ?? null + let uncoveredMeta = uncovered?.meta + if ( + uncoveredData && + uncoveredData !== item.data && + typeof snapshotOutputSeq === 'number' && + typeof item.meta?.seq === 'number' && + typeof item.meta.rawLength === 'number' + ) { + if (item.meta.rawLength === item.data.length) { + uncoveredMeta = { ...item.meta, rawLength: uncoveredData.length } + } + uncoveredData = stripSnapshotBoundaryQuerySuffixes( + uncoveredData, + snapshotOutputSeq, + snapshotOutputSeq, + state.pendingQuerySequences + ) + } + if (uncoveredData) { + state.outputBatcher.push(uncoveredData, uncoveredMeta) + } + } + } + state.pendingOutputBytes = 0 + state.outputBatcher.flush() +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscription-types.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscription-types.ts new file mode 100644 index 00000000000..d9eb624f0e1 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscription-types.ts @@ -0,0 +1,58 @@ +import type { z } from 'zod' +import type { RpcContext } from '../../core' +import type { SubscriptionRegistration } from '../../../orca-runtime' +import type { TerminalReplyQuerySequence } from '../../../../../shared/terminal-reply-query-scan' +import type { TerminalOutputChunk } from './terminal-stream-types' +import type { TerminalSubscribe } from './stream-schemas' +import type { TerminalOutputBatcher } from './terminal-output-batcher' +import type { TerminalStreamOpcode } from '../../../../../shared/terminal-stream-protocol' + +export type TerminalSubscribeParams = z.infer +export type TerminalSubscriptionEmit = (result: unknown) => void + +export type TerminalSubscriptionArgs = { + params: TerminalSubscribeParams + runtime: RpcContext['runtime'] + connectionId: RpcContext['connectionId'] + sendBinary: RpcContext['sendBinary'] + registerBinaryStreamHandler: RpcContext['registerBinaryStreamHandler'] + signal: RpcContext['signal'] + emit: TerminalSubscriptionEmit + ptyId: string + clientId: string | undefined + isMobile: boolean + supportsDesktopViewportClaims: boolean + supportsWriteUnavailable: boolean + missingHeadlessStateBeforeMobileFit: boolean + rendererMountRequestedBeforePty: boolean + serializerGenerationBeforeMobileFit: number +} + +export type LegacyBinarySubscriptionState = { + readonly streamId: number + readonly remoteDesktopSubscriptionKey: string + closed: boolean + buffering: boolean + pendingRemoteDesktopViewport: { cols: number; rows: number } | null + lastResizeCols: number | undefined + resizeGeneration: number + pendingOutput: TerminalOutputChunk[] + pendingOutputBytes: number + pendingOutputOverflowed: boolean + readonly pendingQuerySequences: TerminalReplyQuerySequence[] + pendingQueryOverflowed: boolean + lateRendererReadyPromise: Promise | null + abortRendererMountWait: () => void + outputBatcher: TerminalOutputBatcher + unsubscribeResize: () => void + unsubscribeFit: () => void + registeredRemoteDesktopDriver: boolean + displayMode: string + readonly registration: SubscriptionRegistration + readonly streamClosed: Promise + readonly sendFrame: ( + opcode: TerminalStreamOpcode, + payload?: Uint8Array, + frameSeq?: number + ) => void +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts new file mode 100644 index 00000000000..37c4cda51b4 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts @@ -0,0 +1,178 @@ +import { defineMethod, type RpcAnyMethod } from '../../core' +import { + navigationTargetsHost, + resolveRuntimeNavigationTarget +} from '../../../../../shared/runtime-navigation' +import { withTerminalCloseAttribution } from '../../terminal-close-attribution' +import { + AgentTeamsPrepareLaunch, + AgentTeamsTmuxCompat, + TerminalCreateParams, + TerminalFocus, + TerminalHandle, + TerminalSleep, + TerminalSplit, + TerminalStop, + TerminalStopExact, + TerminalWait +} from './unary-schemas' +import { TerminalResizeForClient } from './stream-schemas' + +export const TERMINAL_LIFECYCLE_METHODS: RpcAnyMethod[] = [ + defineMethod({ + name: 'terminal.wait', + params: TerminalWait, + handler: async (params, { runtime, signal }) => ({ + wait: await runtime.waitForTerminal(params.terminal, { + condition: params.for, + timeoutMs: params.timeoutMs, + signal + }) + }) + }), + defineMethod({ + name: 'terminal.create', + params: TerminalCreateParams, + handler: async (params, { runtime, pairedDeviceId, clientId }) => ({ + terminal: await runtime.dedupeTerminalCreate( + pairedDeviceId ?? clientId ?? 'local', + params.worktree, + params.clientMutationId, + params.reconcileExisting === true, + (canonicalWorktreeSelector, preAllocatedHandle) => + runtime.createTerminal(canonicalWorktreeSelector, { + command: params.command, + startupCommandDelivery: params.startupCommandDelivery, + env: params.env, + envToDelete: params.envToDelete, + ...(params.launchConfig ? { launchConfig: params.launchConfig } : {}), + ...(params.resumeProviderSession + ? { resumeProviderSession: params.resumeProviderSession } + : {}), + ...(params.launchToken ? { launchToken: params.launchToken } : {}), + ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), + ...(params.terminalColorQueryReplies + ? { terminalColorQueryReplies: params.terminalColorQueryReplies } + : {}), + title: params.title, + focus: params.focus === true, + rendererBacked: params.rendererBacked === true, + activate: params.activate === true, + presentation: params.presentation, + tabId: params.tabId, + leafId: params.leafId, + ...(preAllocatedHandle ? { preAllocatedHandle } : {}) + }) + ) + }) + }), + defineMethod({ + name: 'terminal.split', + params: TerminalSplit, + handler: async (params, { runtime }) => ({ + split: await runtime.splitTerminal(params.terminal, { + direction: params.direction, + command: params.command, + env: params.env, + telemetrySource: params.telemetrySource + }) + }) + }), + defineMethod({ + name: 'terminal.stop', + params: TerminalStop, + handler: async (params, { runtime }) => runtime.stopTerminalsForWorktree(params.worktree) + }), + defineMethod({ + name: 'terminal.sleep', + params: TerminalSleep, + handler: async (params, { runtime }) => runtime.sleepTerminalsForWorktree(params.worktree) + }), + defineMethod({ + name: 'terminal.stopExact', + params: TerminalStopExact, + handler: async (params, { runtime }) => + runtime.stopExactTerminalsForWorktree(params.worktree, params.expectedPtyIds, { + keepHistory: params.keepHistory, + targetOnly: params.targetOnly + }) + }), + defineMethod({ + name: 'terminal.resizeForClient', + params: TerminalResizeForClient, + handler: async (params, { runtime }) => { + // Why: a stale handle must fail with terminal_handle_stale, not resize the wrong PTY (#7718). + const leaf = runtime.resolveLiveLeafForHandle(params.terminal) + if (!leaf?.ptyId) { + throw new Error('no_connected_pty') + } + const result = await runtime.resizeForClient( + leaf.ptyId, + params.mode, + params.clientId, + params.mode === 'mobile-fit' ? params.cols : undefined, + params.mode === 'mobile-fit' ? params.rows : undefined + ) + return { + terminal: { + handle: params.terminal, + ...result + } + } + } + }), + defineMethod({ + name: 'terminal.focus', + params: TerminalFocus, + handler: async (params, { runtime, clientKind }) => ({ + focus: await runtime.focusTerminal(params.terminal, { + navigateHost: navigationTargetsHost( + resolveRuntimeNavigationTarget({ navigation: params.navigation, clientKind }) + ) + }) + }) + }), + defineMethod({ + name: 'terminal.close', + params: TerminalHandle, + handler: async (params, context) => ({ + close: await withTerminalCloseAttribution( + 'terminal.close', + context, + 'terminal', + params.terminal, + () => context.runtime.closeTerminal(params.terminal) + ) + }) + }), + defineMethod({ + name: 'terminal.closeTab', + params: TerminalHandle, + handler: async (params, context) => ({ + close: await withTerminalCloseAttribution( + 'terminal.closeTab', + context, + 'terminal-tab', + params.terminal, + () => context.runtime.closeTerminalTab(params.terminal) + ) + }) + }), + defineMethod({ + name: 'agentTeams.tmuxCompat', + params: AgentTeamsTmuxCompat, + handler: async (params, { runtime }) => ({ + tmux: await runtime.handleAgentTeamsTmuxCompat(params) + }) + }), + defineMethod({ + name: 'agentTeams.prepareLaunch', + params: AgentTeamsPrepareLaunch, + handler: async (params, { runtime }) => ({ + launch: await runtime.prepareClaudeAgentTeamsLeader({ + paneKey: params.paneKey, + baseEnv: params.env + }) + }) + }) +] diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-cleanup.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-cleanup.ts new file mode 100644 index 00000000000..924975d34b6 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-cleanup.ts @@ -0,0 +1,100 @@ +import type { + TerminalMultiplexCleanupStage, + TerminalMultiplexConnection, + TerminalMultiplexFlowControlStage +} from './terminal-multiplex-connection' + +export function installMultiplexCleanup( + build: TerminalMultiplexFlowControlStage +): asserts build is TerminalMultiplexCleanupStage { + const state = build as TerminalMultiplexConnection + const { runtime, streams, pendingPtyWaitControllers, emit, signal } = state + state.detachStream = ( + streamId: number, + emitEnd: boolean, + releaseRemoteDesktopDriver = true + ): void => { + const stream = streams.get(streamId) + if (!stream) { + return + } + const replacement = stream.sourceRangeReplacement + stream.sourceRangeReplacement = null + if (replacement) { + runtime.rollbackRemoteTerminalSourceRangeReplacement( + replacement, + 'stream-detached-replacement-aborted' + ) + } + stream.outputBatcher.flush() + stream.outputBatcher.dispose() + state.detachSourceRangeConsumer(stream, 'stream-detached') + state.ackTotalInFlightBytes = Math.max(0, state.ackTotalInFlightBytes - stream.ackInFlightBytes) + stream.ackInFlightBytes = 0 + stream.ackPendingOutput = [] + stream.ackPendingOutputBytes = 0 + stream.ackPendingOutputOverflowed = false + stream.ackRecoverySnapshotInFlight = false + stream.unsubscribeData() + stream.unsubscribeResize() + stream.unsubscribeFit() + stream.unsubscribeDriver() + stream.unregisterBinaryHandler() + streams.delete(streamId) + state.flushAllAckPendingOutput() + // Why: release the runtime exit-waiter for this slot (see the field's note); delete before abort so its .catch no-ops instead of re-detaching. + stream.exitWaiterAbort.abort() + if (stream.isMobile && stream.client?.id) { + runtime.handleMobileUnsubscribe(stream.ptyId, stream.client.id) + } else if ( + releaseRemoteDesktopDriver && + stream.registeredRemoteDesktopDriver && + stream.client?.id + ) { + // Why: release the width floor only if THIS stream took it, so a passive stream can't release a peer's floor. + runtime.unregisterRemoteDesktopViewer(stream.ptyId, stream.remoteDesktopSubscriptionKey) + } + if (emitEnd) { + emit({ type: 'end', streamId }) + } + } + state.cancelPendingPtyWaits = (streamId: number): void => { + const controllers = pendingPtyWaitControllers.get(streamId) + if (!controllers) { + return + } + pendingPtyWaitControllers.delete(streamId) + for (const controller of controllers) { + controller.abort() + } + } + state.cancelAllPendingPtyWaits = (): void => { + for (const streamId of Array.from(pendingPtyWaitControllers.keys())) { + state.cancelPendingPtyWaits(streamId) + } + } + state.closeMultiplex = (): void => { + if (state.closed) { + return + } + state.closed = true + signal?.removeEventListener('abort', state.cancelAllPendingPtyWaits) + state.cancelAllPendingPtyWaits() + const remoteDesktopKeysByPty = new Map() + for (const streamId of Array.from(streams.keys())) { + const stream = streams.get(streamId) + if (stream?.registeredRemoteDesktopDriver && !stream.isMobile && stream.client?.id) { + const keys = remoteDesktopKeysByPty.get(stream.ptyId) ?? [] + keys.push(stream.remoteDesktopSubscriptionKey) + remoteDesktopKeysByPty.set(stream.ptyId, keys) + } + state.detachStream(streamId, false, false) + } + // Why: one connection can own many panes on the same PTY; remove floors together so close scans each registry once. + for (const [ptyId, subscriptionKeys] of remoteDesktopKeysByPty) { + void runtime.unregisterRemoteDesktopViewers(ptyId, subscriptionKeys) + } + state.unregisterControlHandler() + state.resolveMultiplex() + } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-connection.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-connection.ts new file mode 100644 index 00000000000..67d68602b62 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-connection.ts @@ -0,0 +1,106 @@ +import type { z } from 'zod' +import type { RpcContext } from '../../core' +import type { + TerminalStreamFrame, + TerminalStreamOpcode +} from '../../../../../shared/terminal-stream-protocol' +import type { TerminalOutputFrameChunk } from '../../terminal-output-frame-chunks' +import type { + TerminalMultiplexSnapshotRequestFrame, + TerminalMultiplexSubscribeFrame +} from './stream-schemas' +import type { TerminalMultiplexStream } from './terminal-stream-types' +import type { TerminalSourceRangeRegistry } from '../../terminal-source-range-registry' + +export type MultiplexSubscribeRequest = z.infer +export type MultiplexSnapshotRequest = z.infer +export type MultiplexEmit = (result: unknown) => void + +export type TerminalMultiplexConnectionBase = { + runtime: RpcContext['runtime'] + connectionId: string + sendBinary: NonNullable + registerBinaryStreamHandler: NonNullable + signal: RpcContext['signal'] + emit: MultiplexEmit + closed: boolean + cursor: number + streams: Map + sourceRangeRegistry: TerminalSourceRangeRegistry + pendingPtyWaitControllers: Map> + ackTotalInFlightBytes: number + ackTotalWindowBytes: number + ackFlushCursorStreamId: number | null + resolveMultiplex: () => void + multiplexClosed: Promise + unregisterControlHandler: () => void +} + +export type TerminalMultiplexFrameDelivery = { + sendFrame: ( + streamId: number, + opcode: TerminalStreamOpcode, + payload?: Uint8Array, + frameSeq?: number, + onRejected?: () => void + ) => boolean + sendStreamError: (streamId: number, message: string) => void + notifyStreamWriteUnavailable: ( + stream: TerminalMultiplexStream, + outcome: 'delivered' | 'rejected' | 'failed' + ) => void + sendResizedFrame: ( + stream: TerminalMultiplexStream, + event: { cols: number; rows: number; displayMode: string; reason: string; seq?: number } + ) => void + canSendAckGatedOutput: (stream: TerminalMultiplexStream, bytes: number) => boolean + sendAckGatedOutput: (stream: TerminalMultiplexStream, chunk: TerminalOutputFrameChunk) => boolean + queueOrSendOutput: (stream: TerminalMultiplexStream, chunk: TerminalOutputFrameChunk) => void +} + +export type TerminalMultiplexFlowControl = { + sendAckRecoverySnapshot: (stream: TerminalMultiplexStream) => Promise + flushAckPendingOutput: (stream: TerminalMultiplexStream, maxChunks?: number) => number + flushAllAckPendingOutput: () => void + acknowledgeOutput: (stream: TerminalMultiplexStream, bytes: number) => void + acknowledgeSourceRanges: ( + stream: TerminalMultiplexStream, + streamGeneration: string, + ackedEndByte: number + ) => void + detachSourceRangeConsumer: (stream: TerminalMultiplexStream, reason: string) => void +} + +export type TerminalMultiplexCleanup = { + detachStream: (streamId: number, emitEnd: boolean, releaseRemoteDesktopDriver?: boolean) => void + cancelPendingPtyWaits: (streamId: number) => void + cancelAllPendingPtyWaits: () => void + closeMultiplex: () => void +} + +export type TerminalMultiplexSlotFrames = { + handleSlotFrame: (stream: TerminalMultiplexStream, frame: TerminalStreamFrame) => void + sendRequestedSnapshot: ( + stream: TerminalMultiplexStream, + request: MultiplexSnapshotRequest + ) => Promise +} + +export type TerminalMultiplexSubscribeFrame = { + handleSubscribeFrame: (payload: Uint8Array) => Promise +} + +export type TerminalMultiplexFrameDeliveryStage = TerminalMultiplexConnectionBase & + TerminalMultiplexFrameDelivery + +export type TerminalMultiplexFlowControlStage = TerminalMultiplexFrameDeliveryStage & + TerminalMultiplexFlowControl + +export type TerminalMultiplexCleanupStage = TerminalMultiplexFlowControlStage & + TerminalMultiplexCleanup + +export type TerminalMultiplexSlotFramesStage = TerminalMultiplexCleanupStage & + TerminalMultiplexSlotFrames + +export type TerminalMultiplexConnection = TerminalMultiplexSlotFramesStage & + TerminalMultiplexSubscribeFrame diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts new file mode 100644 index 00000000000..a469b2d7a00 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts @@ -0,0 +1,264 @@ +import { + TERMINAL_MULTIPLEX_ACK_STREAM_MAX_WINDOW_BYTES, + TERMINAL_MULTIPLEX_ACK_TOTAL_MAX_WINDOW_BYTES +} from '../../../../../shared/terminal-multiplex-flow-control' +import { drainTerminalMultiplexRoundRobin } from '../../terminal-multiplex-round-robin' +import { + sendSnapshotFrames, + serializeBudgetedRequestedSnapshot +} from './terminal-snapshot-publication' +import type { + TerminalMultiplexConnection, + TerminalMultiplexFlowControlStage, + TerminalMultiplexFrameDeliveryStage +} from './terminal-multiplex-connection' +import type { TerminalMultiplexStream } from './terminal-stream-types' +import type { RemoteTerminalSourceRangeReplacementReservation } from '../../../remote-terminal-source-range-consumer' + +export function installMultiplexFlowControl( + build: TerminalMultiplexFrameDeliveryStage +): asserts build is TerminalMultiplexFlowControlStage { + const state = build as TerminalMultiplexConnection + const { runtime, streams } = state + state.sendAckRecoverySnapshot = async (stream: TerminalMultiplexStream): Promise => { + if ( + state.closed || + streams.get(stream.streamId) !== stream || + stream.outputPaused || + stream.ackRecoverySnapshotInFlight + ) { + return + } + stream.ackRecoverySnapshotInFlight = true + let replacement: RemoteTerminalSourceRangeReplacementReservation | null = null + try { + const serialized = await serializeBudgetedRequestedSnapshot(runtime, stream.ptyId, 0) + if (state.closed || streams.get(stream.streamId) !== stream || stream.outputPaused) { + return + } + if (!serialized) { + throw new Error('Remote terminal recovery snapshot unavailable.') + } + if ( + stream.ackOutputSourceRanges && + (serialized.source === undefined || typeof serialized.seq !== 'number') + ) { + throw new Error('Remote terminal recovery snapshot source identity unavailable.') + } + if ( + stream.ackOutputSourceRanges && + serialized.source !== undefined && + typeof serialized.seq === 'number' + ) { + replacement = runtime.reserveRemoteTerminalSourceRangeReplacement( + { + ptyId: stream.ptyId, + consumerId: stream.remoteDesktopSubscriptionKey, + streamGeneration: stream.streamGeneration + }, + serialized.seq, + 'ack-pending-overflow' + ) + stream.sourceRangeReplacement = replacement + } + const displayMode = runtime.getMobileDisplayMode(stream.ptyId) + const publication = sendSnapshotFrames( + (opcode, payload) => + !state.closed && + streams.get(stream.streamId) === stream && + state.sendFrame(stream.streamId, opcode, payload), + { + kind: 'scrollback', + cols: serialized.cols, + rows: serialized.rows, + displayMode, + reason: 'ack-pending-overflow', + seq: serialized.seq, + source: serialized.source, + kittyKeyboardFlags: serialized.kittyKeyboardFlags, + truncatedByByteBudget: serialized.truncatedByByteBudget, + data: serialized.data + } + ) + if (!publication.published) { + throw new Error('Remote terminal recovery snapshot was not published.') + } + if (state.closed || streams.get(stream.streamId) !== stream) { + throw new Error('Remote terminal recovery snapshot stream detached.') + } + const localReplacement = replacement + ? typeof serialized.seq === 'number' + ? stream.sourceRangeLedger?.planSourceRangeReplacement(serialized.seq) + : null + : null + if (replacement && !localReplacement) { + throw new Error('Remote terminal recovery source ledger replacement unavailable.') + } + if ( + replacement && + (!serialized.source || + typeof serialized.seq !== 'number' || + !runtime.commitRemoteTerminalSourceRangeReplacement(replacement, { + source: serialized.source, + seq: serialized.seq + })) + ) { + throw new Error('Remote terminal recovery snapshot replacement was not accepted.') + } + localReplacement?.commit() + stream.sourceRangeReplacement = null + replacement = null + if (typeof serialized.seq === 'number') { + const snapshotSeq = serialized.seq + const retained = stream.ackPendingOutput.filter( + (chunk) => !(typeof chunk.seq === 'number' && chunk.seq <= snapshotSeq) + ) + stream.ackPendingOutput = retained + stream.ackPendingOutputBytes = retained.reduce( + (total, chunk) => total + chunk.bytes.byteLength, + 0 + ) + } + stream.ackPendingOutputOverflowed = false + } catch (error) { + if (replacement) { + if (stream.sourceRangeReplacement === replacement) { + stream.sourceRangeReplacement = null + runtime.rollbackRemoteTerminalSourceRangeReplacement( + replacement, + 'ack-pending-overflow-unpublished' + ) + } + replacement = null + } + if (state.closed || streams.get(stream.streamId) !== stream) { + return + } + state.sendStreamError( + stream.streamId, + error instanceof Error ? error.message : 'Remote terminal recovery snapshot failed.' + ) + state.detachStream(stream.streamId, true) + } finally { + if (streams.get(stream.streamId) === stream) { + stream.ackRecoverySnapshotInFlight = false + state.flushAllAckPendingOutput() + } + } + } + state.flushAckPendingOutput = ( + stream: TerminalMultiplexStream, + maxChunks = Number.POSITIVE_INFINITY + ): number => { + if (stream.outputPaused) { + return 0 + } + if (stream.ackPendingOutputOverflowed) { + void state.sendAckRecoverySnapshot(stream) + return 0 + } + let flushed = 0 + while ( + flushed < stream.ackPendingOutput.length && + flushed < maxChunks && + state.canSendAckGatedOutput(stream, stream.ackPendingOutput[flushed]!.bytes.byteLength) + ) { + if (!state.sendAckGatedOutput(stream, stream.ackPendingOutput[flushed]!)) { + return flushed + } + flushed += 1 + } + if (flushed > 0) { + stream.ackPendingOutput.splice(0, flushed) + stream.ackPendingOutputBytes = stream.ackPendingOutput.reduce( + (total, pending) => total + pending.bytes.byteLength, + 0 + ) + } + return flushed + } + state.flushAllAckPendingOutput = (): void => { + const ordered = Array.from(streams.values()) + state.ackFlushCursorStreamId = drainTerminalMultiplexRoundRobin({ + streams: ordered, + cursorStreamId: state.ackFlushCursorStreamId, + canContinue: () => !state.closed, + drainOne: (stream) => { + if (streams.get(stream.streamId) !== stream) { + return false + } + if (state.flushAckPendingOutput(stream, 1) > 0) { + return true + } + return false + } + }) + } + state.acknowledgeOutput = (stream: TerminalMultiplexStream, bytes: number): void => { + if (!stream.ackOutput || bytes <= 0) { + return + } + const acknowledged = Math.min(stream.ackInFlightBytes, bytes) + stream.ackWindowBytes = Math.min( + TERMINAL_MULTIPLEX_ACK_STREAM_MAX_WINDOW_BYTES, + stream.ackWindowBytes + acknowledged + ) + state.ackTotalWindowBytes = Math.min( + TERMINAL_MULTIPLEX_ACK_TOTAL_MAX_WINDOW_BYTES, + state.ackTotalWindowBytes + acknowledged + ) + stream.ackInFlightBytes -= acknowledged + state.ackTotalInFlightBytes = Math.max(0, state.ackTotalInFlightBytes - acknowledged) + state.flushAllAckPendingOutput() + } + state.acknowledgeSourceRanges = ( + stream: TerminalMultiplexStream, + streamGeneration: string, + ackedEndByte: number + ): void => { + if (!stream.ackOutputSourceRanges) { + return + } + const result = stream.sourceRangeLedger?.acknowledge(streamGeneration, ackedEndByte) + if (!result) { + return + } + if (result.status !== 'accepted') { + return + } + if (result.settled.length > 0) { + runtime.settleRemoteTerminalSourceRanges( + { + ptyId: stream.ptyId, + consumerId: stream.remoteDesktopSubscriptionKey, + streamGeneration: stream.streamGeneration + }, + result.settled + ) + } + state.acknowledgeOutput(stream, result.acknowledgedBytes) + } + state.detachSourceRangeConsumer = (stream: TerminalMultiplexStream, reason: string): void => { + if (!stream.sourceRangeConsumerAttached) { + return + } + stream.sourceRangeConsumerAttached = false + const ledger = stream.sourceRangeLedger + stream.sourceRangeLedger = null + if (!ledger) { + return + } + const identity = { + ptyId: stream.ptyId, + consumerId: stream.remoteDesktopSubscriptionKey, + streamGeneration: stream.streamGeneration + } + const transfer = ledger.beginTransfer() + const ranges = transfer.frames.flatMap((frame) => frame.sourceRanges) + try { + runtime.cancelRemoteTerminalSourceRanges(identity, ranges, reason) + } finally { + transfer.commit() + } + } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-frame-delivery.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-frame-delivery.ts new file mode 100644 index 00000000000..6e9c0e38231 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-frame-delivery.ts @@ -0,0 +1,153 @@ +import { + TerminalStreamOpcode, + encodeTerminalStreamFrame, + encodeTerminalStreamJson, + encodeTerminalStreamText +} from '../../../../../shared/terminal-stream-protocol' +import { appendAckPendingOutput } from './terminal-stream-replay' +import type { + TerminalMultiplexConnection, + TerminalMultiplexConnectionBase, + TerminalMultiplexFrameDeliveryStage +} from './terminal-multiplex-connection' +import type { TerminalOutputFrameChunk } from '../../terminal-output-frame-chunks' +import type { TerminalStreamInputOutcome } from './terminal-input-delivery' +import type { TerminalMultiplexStream } from './terminal-stream-types' + +export function installMultiplexFrameDelivery( + build: TerminalMultiplexConnectionBase +): asserts build is TerminalMultiplexFrameDeliveryStage { + const state = build as TerminalMultiplexConnection + const { sendBinary, emit, streams } = state + state.sendFrame = ( + streamId: number, + opcode: TerminalStreamOpcode, + payload: Uint8Array = new Uint8Array(), + seq?: number, + onRejected?: () => void + ): boolean => { + if (state.closed) { + onRejected?.() + return false + } + // Why: a seq-less Output chunk must carry sentinel 0, not the control-frame state.cursor, or it poisons the client's frame-drop tracker. + const resolvedSeq = + typeof seq === 'number' ? seq : opcode === TerminalStreamOpcode.Output ? 0 : state.cursor++ + let sent: boolean | void + try { + sent = sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: resolvedSeq, payload })) + } catch { + onRejected?.() + state.closeMultiplex() + return false + } + if (sent === false) { + onRejected?.() + // Why: false means the transport discarded this frame; reconnect is the only available retry boundary with an authoritative snapshot. + state.closeMultiplex() + return false + } + return true + } + state.sendStreamError = (streamId: number, message: string): void => { + state.sendFrame(streamId, TerminalStreamOpcode.Error, encodeTerminalStreamText(message)) + emit({ type: 'error', streamId, message }) + } + state.notifyStreamWriteUnavailable = ( + stream: TerminalMultiplexStream, + outcome: TerminalStreamInputOutcome + ): void => { + if ( + state.closed || + streams.get(stream.streamId) !== stream || + outcome !== 'rejected' || + !stream.supportsWriteUnavailable + ) { + return + } + state.sendFrame(stream.streamId, TerminalStreamOpcode.WriteUnavailable) + } + state.sendResizedFrame = ( + stream: TerminalMultiplexStream, + event: { cols: number; rows: number; displayMode: string; reason: string; seq?: number } + ): void => { + stream.lastResizeCols = event.cols + state.sendFrame( + stream.streamId, + TerminalStreamOpcode.Resized, + encodeTerminalStreamJson({ + cols: event.cols, + rows: event.rows, + displayMode: event.displayMode, + reason: event.reason, + seq: event.seq + }) + ) + } + state.canSendAckGatedOutput = (stream: TerminalMultiplexStream, bytes: number): boolean => { + if (!stream.ackOutput) { + return true + } + return ( + stream.ackInFlightBytes + bytes <= stream.ackWindowBytes && + state.ackTotalInFlightBytes + bytes <= state.ackTotalWindowBytes && + (!stream.ackOutputSourceRanges || stream.sourceRangeLedger?.canAccept(bytes) === true) + ) + } + state.sendAckGatedOutput = ( + stream: TerminalMultiplexStream, + chunk: TerminalOutputFrameChunk + ): boolean => { + const prepared = stream.ackOutputSourceRanges + ? stream.sourceRangeLedger?.prepareAccept( + chunk.bytes.byteLength, + chunk.displayLength, + chunk.sourceRanges ?? [], + chunk.seq + ) + : undefined + if (stream.ackOutputSourceRanges && prepared?.status !== 'ready') { + if (prepared?.status !== 'capacity') { + state.detachStream(stream.streamId, true) + } + return false + } + const admission = prepared?.status === 'ready' ? prepared.admission : undefined + const sent = state.sendFrame( + stream.streamId, + chunk.opcode ?? TerminalStreamOpcode.Output, + chunk.bytes, + chunk.seq, + admission?.rollback + ) + if (!sent) { + return false + } + if (admission && !admission.commit()) { + state.detachStream(stream.streamId, true) + return false + } + if (stream.ackOutput) { + stream.ackInFlightBytes += chunk.bytes.byteLength + state.ackTotalInFlightBytes += chunk.bytes.byteLength + } + return true + } + state.queueOrSendOutput = ( + stream: TerminalMultiplexStream, + chunk: TerminalOutputFrameChunk + ): void => { + if (state.closed || streams.get(stream.streamId) !== stream || stream.outputPaused) { + return + } + if ( + stream.ackPendingOutputOverflowed || + stream.ackPendingOutput.length > 0 || + !state.canSendAckGatedOutput(stream, chunk.bytes.byteLength) + ) { + appendAckPendingOutput(stream, chunk) + return + } + state.sendAckGatedOutput(stream, chunk) + } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts new file mode 100644 index 00000000000..724e14f2cc5 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts @@ -0,0 +1,137 @@ +import { + sendSnapshotFrames, + serializeBudgetedMobileSnapshot +} from './terminal-snapshot-publication' +import { getOutputAfterSnapshotSeq } from './terminal-stream-replay' +import type { + MultiplexSubscribeRequest, + TerminalMultiplexConnection +} from './terminal-multiplex-connection' +import type { TerminalMultiplexStream } from './terminal-stream-types' + +export type MultiplexPublishedInitialState = { + isMobile: boolean + size: { cols: number; rows: number } | null + displayMode: string +} + +export async function publishMultiplexInitialSnapshot( + state: TerminalMultiplexConnection, + request: MultiplexSubscribeRequest, + stream: TerminalMultiplexStream +): Promise { + const { runtime, streams, emit } = state + const { ptyId } = stream + const isMobile = stream.isMobile + let read = await runtime.readTerminal(request.terminal) + let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) + if (state.closed || streams.get(request.streamId) !== stream) { + return null + } + let initialOutputOverflowed = false + if (stream.pendingOutputOverflowed) { + stream.pendingOutput.splice(0) + stream.pendingOutputBytes = 0 + stream.pendingOutputOverflowed = false + read = await runtime.readTerminal(request.terminal) + serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) + if (state.closed || streams.get(request.streamId) !== stream) { + return null + } + if (stream.pendingOutputOverflowed) { + initialOutputOverflowed = true + stream.pendingOutput.splice(0) + stream.pendingOutputBytes = 0 + stream.pendingOutputOverflowed = false + } + } + const size = runtime.getTerminalSize(ptyId) + const displayMode = runtime.getMobileDisplayMode(ptyId) + const layoutSeq = runtime.getLayout(ptyId)?.seq + // Why: layout versions and output offsets are different sequence domains. + const snapshotOutputSeq = serialized?.seq + emit({ + type: 'subscribed', + streamId: request.streamId, + terminal: request.terminal, + cols: serialized?.cols ?? size?.cols, + rows: serialized?.rows ?? size?.rows, + displayMode, + seq: layoutSeq, + ...((stream.ackOutputSourceRanges || stream.supportsOutputPause) && { + capabilities: { + ...(stream.ackOutputSourceRanges ? { ackOutputSourceRanges: 1 as const } : {}), + ...(stream.supportsOutputPause ? { outputPause: 1 as const } : {}) + } + }), + ...(stream.ackOutputSourceRanges ? { streamGeneration: stream.streamGeneration } : {}), + // Why: retained-tail truncation loses history, not the authoritative latest-screen fallback. + truncated: initialOutputOverflowed + }) + stream.sourceRangeReplacement = + stream.ackOutputSourceRanges && + serialized?.source !== undefined && + typeof serialized.seq === 'number' + ? runtime.reserveRemoteTerminalSourceRangeReplacement( + { + ptyId, + consumerId: stream.remoteDesktopSubscriptionKey, + streamGeneration: stream.streamGeneration + }, + serialized.seq, + 'initial-snapshot' + ) + : null + const snapshotPublication = sendSnapshotFrames( + (opcode, payload) => state.sendFrame(request.streamId, opcode, payload), + { + kind: 'scrollback', + cols: serialized?.cols ?? size?.cols ?? 80, + rows: serialized?.rows ?? size?.rows ?? 24, + displayMode, + seq: snapshotOutputSeq, + cwd: serialized?.cwd, + truncated: initialOutputOverflowed, + truncatedByByteBudget: serialized?.truncatedByByteBudget, + source: serialized?.source, + kittyKeyboardFlags: serialized?.kittyKeyboardFlags, + oscLinks: serialized?.oscLinks, + pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi, + data: serialized?.data ?? (read.tail.length > 0 ? `${read.tail.join('\r\n')}\r\n` : '') + } + ) + const replacement = stream.sourceRangeReplacement + stream.sourceRangeReplacement = null + if (replacement) { + const committed = + snapshotPublication.published && + serialized?.source !== undefined && + typeof serialized.seq === 'number' && + runtime.commitRemoteTerminalSourceRangeReplacement(replacement, { + source: serialized.source, + seq: serialized.seq + }) + if (!committed) { + runtime.rollbackRemoteTerminalSourceRangeReplacement( + replacement, + 'initial-snapshot-unpublished' + ) + } + } + // Why: baseline for resize re-stream gating; the client already rewrapped to these cols via the initial snapshot replay. + stream.lastResizeCols = serialized?.cols ?? size?.cols + stream.buffering = false + const pendingOutput = stream.pendingOutput.splice(0) + if (!initialOutputOverflowed) { + for (const chunk of pendingOutput) { + const uncovered = getOutputAfterSnapshotSeq(chunk, snapshotOutputSeq) + if (uncovered) { + stream.outputBatcher.push(uncovered.data, uncovered.meta) + } + } + } + stream.pendingOutputBytes = 0 + stream.pendingOutputOverflowed = false + stream.outputBatcher.flush() + return { isMobile, size, displayMode } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-live-stream.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-live-stream.ts new file mode 100644 index 00000000000..45604dc4a01 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-live-stream.ts @@ -0,0 +1,139 @@ +import { sendMobileResizeRestream } from './terminal-snapshot-publication' +import { updateViewportForClient } from './terminal-viewport-update' +import type { + MultiplexSubscribeRequest, + TerminalMultiplexConnection +} from './terminal-multiplex-connection' +import type { MultiplexPublishedInitialState } from './terminal-multiplex-initial-snapshot' +import type { TerminalMultiplexStream } from './terminal-stream-types' + +export function activateMultiplexStream( + state: TerminalMultiplexConnection, + request: MultiplexSubscribeRequest, + stream: TerminalMultiplexStream, + published: MultiplexPublishedInitialState +): void { + const { runtime, streams, emit } = state + const { ptyId } = stream + const { isMobile, size } = published + if (!isMobile) { + stream.unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => { + const mode = + event.mode === 'mobile-fit' + ? event.mode + : (runtime.getRemoteDesktopFitHold?.(ptyId, stream.remoteDesktopSubscriptionKey).mode ?? + 'desktop-fit') + emit({ + type: 'fit-override-changed', + streamId: request.streamId, + mode, + cols: event.cols, + rows: event.rows + }) + }) + stream.unsubscribeDriver = runtime.subscribeToDriverChanges(ptyId, (driver) => { + emit({ + type: 'driver-changed', + streamId: request.streamId, + driver + }) + }) + const fitOverride = runtime.getTerminalFitOverride(ptyId) + const desktopHold = runtime.getRemoteDesktopFitHold?.( + ptyId, + stream.remoteDesktopSubscriptionKey + ) ?? { mode: 'desktop-fit' as const, cols: size?.cols ?? 0, rows: size?.rows ?? 0 } + emit({ + type: 'fit-override-changed', + streamId: request.streamId, + mode: fitOverride?.mode ?? desktopHold.mode, + cols: fitOverride?.cols ?? desktopHold.cols, + rows: fitOverride?.rows ?? desktopHold.rows + }) + emit({ + type: 'driver-changed', + streamId: request.streamId, + driver: runtime.getDriver(ptyId) + }) + } + stream.unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, (event) => { + stream.outputBatcher.flush() + const resizeGeneration = stream.resizeGeneration + 1 + stream.resizeGeneration = resizeGeneration + const widthChanged = stream.isMobile && event.cols !== stream.lastResizeCols + if (widthChanged) { + stream.lastResizeCols = event.cols + // Why: re-serialize+replay the full scrollback at the new cols so restored hard-wrapped lines rewrap; live output resumes after the snapshot lands. + void sendMobileResizeRestream( + runtime, + ptyId, + (opcode, payload) => state.sendFrame(request.streamId, opcode, payload), + event, + () => + !state.closed && + streams.get(request.streamId) === stream && + stream.resizeGeneration === resizeGeneration + ) + .then((restreamed) => { + if ( + state.closed || + streams.get(request.streamId) !== stream || + stream.resizeGeneration !== resizeGeneration + ) { + return + } + if (!restreamed) { + state.sendResizedFrame(stream, event) + } + }) + // Why: on re-stream failure, still emit the geometry-only Resized frame so the client never misses the resize. + .catch(() => { + if ( + state.closed || + streams.get(request.streamId) !== stream || + stream.resizeGeneration !== resizeGeneration + ) { + return + } + state.sendResizedFrame(stream, event) + }) + return + } + state.sendResizedFrame(stream, event) + }) + // Install the resize listener before draining the parked viewport, since applyLayout emits synchronously. + if ( + !stream.isMobile && + stream.client?.id && + stream.registeredRemoteDesktopDriver && + stream.pendingRemoteDesktopViewport + ) { + const viewport = stream.pendingRemoteDesktopViewport + stream.pendingRemoteDesktopViewport = null + void updateViewportForClient( + runtime, + ptyId, + stream.remoteDesktopSubscriptionKey, + stream.client, + viewport, + 'desktop', + 'register', + !stream.supportsDesktopViewportClaims + ).catch(() => {}) + } + void runtime + .waitForTerminal(request.terminal, { + condition: 'exit', + signal: stream.exitWaiterAbort.signal + }) + .then(() => { + if (streams.get(request.streamId) === stream) { + state.detachStream(request.streamId, true) + } + }) + .catch(() => { + if (streams.get(request.streamId) === stream) { + state.detachStream(request.streamId, true) + } + }) +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-method.ts new file mode 100644 index 00000000000..11fd0c4d039 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-method.ts @@ -0,0 +1,72 @@ +import { defineStreamingMethod, type RpcAnyMethod } from '../../core' +import { TerminalStreamOpcode } from '../../../../../shared/terminal-stream-protocol' +import { TERMINAL_MULTIPLEX_ACK_TOTAL_INITIAL_WINDOW_BYTES } from '../../../../../shared/terminal-multiplex-flow-control' +import { TerminalSourceRangeRegistry } from '../../terminal-source-range-registry' +import { TerminalMultiplex } from './stream-schemas' +import type { TerminalMultiplexConnectionBase } from './terminal-multiplex-connection' +import type { TerminalMultiplexStream } from './terminal-stream-types' +import { installMultiplexFrameDelivery } from './terminal-multiplex-frame-delivery' +import { installMultiplexFlowControl } from './terminal-multiplex-flow-control' +import { installMultiplexCleanup } from './terminal-multiplex-cleanup' +import { installMultiplexSlotFrames } from './terminal-multiplex-slot-frames' +import { installMultiplexSubscribeFrame } from './terminal-multiplex-subscribe-frame' + +export const TERMINAL_MULTIPLEX_METHODS: RpcAnyMethod[] = [ + defineStreamingMethod({ + name: 'terminal.multiplex', + params: TerminalMultiplex, + handler: async ( + _params, + { runtime, connectionId, sendBinary, registerBinaryStreamHandler, signal }, + emit + ) => { + if (!sendBinary || !registerBinaryStreamHandler || !connectionId) { + throw new Error('binary_terminal_stream_required') + } + let resolveMultiplex = (): void => {} + const multiplexClosed = new Promise((resolve) => { + resolveMultiplex = resolve + }) + // Installers only close over this per-connection state; none of it is module-global. + const state: TerminalMultiplexConnectionBase = { + runtime, + connectionId, + sendBinary, + registerBinaryStreamHandler, + signal, + emit, + closed: false, + cursor: 0, + streams: new Map(), + sourceRangeRegistry: new TerminalSourceRangeRegistry(), + pendingPtyWaitControllers: new Map>(), + ackTotalInFlightBytes: 0, + ackTotalWindowBytes: TERMINAL_MULTIPLEX_ACK_TOTAL_INITIAL_WINDOW_BYTES, + ackFlushCursorStreamId: null, + resolveMultiplex, + multiplexClosed, + unregisterControlHandler: () => {} + } + + installMultiplexFrameDelivery(state) + installMultiplexFlowControl(state) + installMultiplexCleanup(state) + installMultiplexSlotFrames(state) + installMultiplexSubscribeFrame(state) + + state.unregisterControlHandler = registerBinaryStreamHandler(0, (frame) => { + if (frame.opcode === TerminalStreamOpcode.Subscribe) { + void state.handleSubscribeFrame(frame.payload) + } + }) + signal?.addEventListener('abort', state.cancelAllPendingPtyWaits, { once: true }) + runtime.registerSubscriptionCleanup( + `terminal-multiplex:${connectionId}`, + state.closeMultiplex, + connectionId + ) + emit({ type: 'ready' }) + await multiplexClosed + } + }) +] diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts new file mode 100644 index 00000000000..7a4ae50e701 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts @@ -0,0 +1,297 @@ +import { + TerminalStreamOpcode, + decodeTerminalStreamJson, + decodeTerminalStreamText, + type TerminalStreamFrame +} from '../../../../../shared/terminal-stream-protocol' +import { + TerminalMultiplexLegacyAckFrame, + TerminalMultiplexSnapshotRequestFrame, + TerminalMultiplexSourceRangeAckFrame +} from './stream-schemas' +import { isTerminalInputLockedForClient, sendTerminalStreamInput } from './terminal-input-delivery' +import { + getOutputAfterSnapshotSeq, + normalizeMultiplexSnapshotScrollbackRows +} from './terminal-stream-replay' +import { + sendSnapshotFrames, + serializeBudgetedRequestedSnapshot +} from './terminal-snapshot-publication' +import { updateViewportForClient } from './terminal-viewport-update' +import type { + MultiplexSnapshotRequest, + TerminalMultiplexCleanupStage, + TerminalMultiplexConnection, + TerminalMultiplexSlotFramesStage +} from './terminal-multiplex-connection' +import type { TerminalMultiplexStream } from './terminal-stream-types' + +export function installMultiplexSlotFrames( + build: TerminalMultiplexCleanupStage +): asserts build is TerminalMultiplexSlotFramesStage { + const state = build as TerminalMultiplexConnection + const { runtime, streams } = state + state.handleSlotFrame = (stream: TerminalMultiplexStream, frame: TerminalStreamFrame): void => { + if (state.closed || streams.get(stream.streamId) !== stream) { + return + } + if (frame.opcode === TerminalStreamOpcode.Unsubscribe) { + state.cancelPendingPtyWaits(stream.streamId) + state.detachStream(stream.streamId, false) + return + } + if (frame.opcode === TerminalStreamOpcode.Ack) { + const payload = decodeTerminalStreamJson(frame.payload) ?? {} + if (stream.ackOutputSourceRanges) { + const parsed = TerminalMultiplexSourceRangeAckFrame.safeParse(payload) + if (parsed.success) { + state.acknowledgeSourceRanges( + stream, + parsed.data.streamGeneration, + parsed.data.ackedEndByte + ) + } + } else { + const parsed = TerminalMultiplexLegacyAckFrame.safeParse(payload) + if (parsed.success) { + state.acknowledgeOutput(stream, parsed.data.bytes) + } + } + return + } + if (frame.opcode === TerminalStreamOpcode.Input) { + const text = decodeTerminalStreamText(frame.payload) + if (!text) { + return + } + if (isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client)) { + return + } + // Mobile already has the higher-priority floor, so a rejected desktop claim must not suppress later phone input. + const inputClaimTail = stream.isMobile ? Promise.resolve(true) : stream.desktopClaimTail + void inputClaimTail.then(async (claimed) => { + if (!claimed || isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client)) { + return + } + const outcome = await sendTerminalStreamInput(runtime, { + terminal: stream.terminal, + text, + client: stream.client, + isMobile: stream.isMobile + }) + state.notifyStreamWriteUnavailable(stream, outcome) + }) + return + } + if (frame.opcode === TerminalStreamOpcode.SetOutputPaused && stream.supportsOutputPause) { + const payload = decodeTerminalStreamJson<{ paused?: unknown }>(frame.payload) + if (typeof payload?.paused !== 'boolean' || stream.outputPaused === payload.paused) { + return + } + stream.outputPaused = payload.paused + if (stream.outputPaused) { + stream.outputBatcher.flush() + stream.ackPendingOutput = [] + stream.ackPendingOutputBytes = 0 + stream.ackPendingOutputOverflowed = false + } + return + } + if (frame.opcode === TerminalStreamOpcode.Resize && stream.client) { + const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>(frame.payload) + if (!viewport || typeof viewport.cols !== 'number' || typeof viewport.rows !== 'number') { + return + } + const cols = viewport.cols + const rows = viewport.rows + // Why: resize registers stream-scoped geometry so detach can release it; older clients lack explicit claims. + if (!stream.isMobile && stream.client?.id) { + stream.registeredRemoteDesktopDriver = true + if (stream.buffering) { + stream.pendingRemoteDesktopViewport = { cols: viewport.cols, rows: viewport.rows } + return + } + } + stream.desktopClaimTail = stream.desktopClaimTail + .then(async (priorClaimed) => { + const result = await updateViewportForClient( + runtime, + stream.ptyId, + stream.remoteDesktopSubscriptionKey, + stream.client!, + { cols, rows }, + stream.isMobile ? 'mobile' : 'desktop', + 'register', + !stream.supportsDesktopViewportClaims + ) + return stream.supportsDesktopViewportClaims + ? priorClaimed && result.applied + : result.applied + }) + .catch(() => false) + return + } + if (frame.opcode === TerminalStreamOpcode.ClaimViewport && stream.client && !stream.isMobile) { + const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>(frame.payload) + if (!viewport || typeof viewport.cols !== 'number' || typeof viewport.rows !== 'number') { + return + } + const cols = viewport.cols + const rows = viewport.rows + stream.registeredRemoteDesktopDriver = true + stream.desktopClaimTail = stream.desktopClaimTail + .then( + () => + runtime.updateRemoteDesktopViewer( + stream.ptyId, + stream.remoteDesktopSubscriptionKey, + stream.client!.id, + cols, + rows, + true + ), + () => + runtime.updateRemoteDesktopViewer( + stream.ptyId, + stream.remoteDesktopSubscriptionKey, + stream.client!.id, + cols, + rows, + true + ) + ) + .catch(() => false) + return + } + if (frame.opcode === TerminalStreamOpcode.SnapshotRequest) { + const payload = TerminalMultiplexSnapshotRequestFrame.safeParse( + decodeTerminalStreamJson(frame.payload) ?? {} + ) + void state.sendRequestedSnapshot(stream, payload.success ? payload.data : {}) + } + } + state.sendRequestedSnapshot = async ( + stream: TerminalMultiplexStream, + request: MultiplexSnapshotRequest + ): Promise => { + if (state.closed || streams.get(stream.streamId) !== stream) { + return + } + stream.outputBatcher.flush() + stream.pendingOutputOverflowed = false + stream.buffering = true + const requestId = request.requestId + let sentSnapshotOutputSeq: number | undefined + try { + const scrollbackRows = normalizeMultiplexSnapshotScrollbackRows(request.scrollbackRows) + let serialized = await serializeBudgetedRequestedSnapshot( + runtime, + stream.ptyId, + scrollbackRows + ) + if (state.closed || streams.get(stream.streamId) !== stream) { + return + } + let size = runtime.getTerminalSize(stream.ptyId) + let displayMode = runtime.getMobileDisplayMode(stream.ptyId) + if (stream.pendingOutputOverflowed) { + // Why: the overflowed tail is newer than the first snapshot, so retry for a current image instead of null. + stream.pendingOutput.splice(0) + stream.pendingOutputBytes = 0 + stream.pendingOutputOverflowed = false + serialized = await serializeBudgetedRequestedSnapshot(runtime, stream.ptyId, scrollbackRows) + if (state.closed || streams.get(stream.streamId) !== stream) { + return + } + size = runtime.getTerminalSize(stream.ptyId) + displayMode = runtime.getMobileDisplayMode(stream.ptyId) + if (stream.pendingOutputOverflowed) { + sendSnapshotFrames( + (opcode, payload) => state.sendFrame(stream.streamId, opcode, payload), + { + kind: 'scrollback', + cols: size?.cols ?? 80, + rows: size?.rows ?? 24, + requestId, + displayMode, + truncated: true, + truncatedByByteBudget: false, + unavailable: 'pending-output-overflowed', + data: '' + } + ) + return + } + } + sentSnapshotOutputSeq = serialized?.seq + sendSnapshotFrames((opcode, payload) => state.sendFrame(stream.streamId, opcode, payload), { + kind: 'scrollback', + cols: serialized?.cols ?? size?.cols ?? 80, + rows: serialized?.rows ?? size?.rows ?? 24, + requestId, + displayMode, + seq: serialized?.seq, + cwd: serialized?.cwd, + source: serialized?.source, + kittyKeyboardFlags: serialized?.kittyKeyboardFlags, + oscLinks: serialized?.oscLinks, + pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi, + truncated: false, + truncatedByByteBudget: serialized?.truncatedByByteBudget, + // Why: no serializer answered, which is not proof the pane is empty — say so instead of passing off '' as the buffer. + unavailable: serialized ? undefined : 'no-serializable-buffer', + data: serialized?.data ?? '' + }) + } catch (error) { + state.sendStreamError( + stream.streamId, + error instanceof Error ? error.message : 'Remote terminal snapshot failed.' + ) + } finally { + if (streams.get(stream.streamId) === stream) { + const shouldFlushPendingOutput = !stream.pendingOutputOverflowed + stream.buffering = false + const pendingOutput = stream.pendingOutput.splice(0) + if (shouldFlushPendingOutput) { + for (const chunk of pendingOutput) { + // Why: an untagged reply resets the client to the snapshot's + // high-water, so covered bytes would render twice; tagged + // snapshots feed a side consumer and the live view still + // needs every buffered chunk. + const uncovered = + typeof requestId === 'number' + ? chunk + : getOutputAfterSnapshotSeq(chunk, sentSnapshotOutputSeq) + if (uncovered) { + stream.outputBatcher.push(uncovered.data, uncovered.meta) + } + } + } + stream.pendingOutputBytes = 0 + stream.pendingOutputOverflowed = false + stream.outputBatcher.flush() + // Why: a resize parked during snapshot buffering must be applied now, or it is dropped until the viewer's next resize. + if ( + !stream.isMobile && + stream.client?.id && + stream.registeredRemoteDesktopDriver && + stream.pendingRemoteDesktopViewport + ) { + const viewport = stream.pendingRemoteDesktopViewport + stream.pendingRemoteDesktopViewport = null + void updateViewportForClient( + runtime, + stream.ptyId, + stream.remoteDesktopSubscriptionKey, + stream.client, + viewport, + 'desktop', + 'register', + !stream.supportsDesktopViewportClaims + ).catch(() => {}) + } + } + } + } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-stream-initialization.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-stream-initialization.ts new file mode 100644 index 00000000000..cc3ff90b2d4 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-stream-initialization.ts @@ -0,0 +1,151 @@ +import { randomUUID } from 'node:crypto' +import { + TerminalStreamOpcode, + encodeTerminalStreamJson +} from '../../../../../shared/terminal-stream-protocol' +import { iterateTerminalOutputFrameChunks } from '../../terminal-output-frame-chunks' +import { TERMINAL_MULTIPLEX_ACK_STREAM_INITIAL_WINDOW_BYTES } from '../../../../../shared/terminal-multiplex-flow-control' +import { createTerminalOutputBatcher } from './terminal-output-batcher' +import { appendPendingMultiplexOutput } from './terminal-stream-replay' +import { updateViewportForClient } from './terminal-viewport-update' +import type { + MultiplexSubscribeRequest, + TerminalMultiplexConnection +} from './terminal-multiplex-connection' +import type { TerminalMultiplexStream } from './terminal-stream-types' + +export async function initializeMultiplexStream( + state: TerminalMultiplexConnection, + request: MultiplexSubscribeRequest, + ptyId: string, + onInstalled: (stream: TerminalMultiplexStream) => void +): Promise { + const { runtime, connectionId, streams, sourceRangeRegistry, registerBinaryStreamHandler } = state + const isMobile = request.client?.type === 'mobile' + const remoteDesktopSubscriptionKey = `multiplex:${connectionId}:${request.streamId}` + const streamGeneration = randomUUID() + const requestedSourceRangeConsumer = + request.capabilities?.ackOutput === 1 && request.capabilities?.ackOutputSourceRanges === 1 + const sourceRangeLedger = requestedSourceRangeConsumer + ? sourceRangeRegistry.open(streamGeneration) + : null + const sourceRangeConsumerAttached = + sourceRangeLedger !== null && + runtime.attachRemoteTerminalSourceRangeConsumer({ + ptyId, + consumerId: remoteDesktopSubscriptionKey, + streamGeneration + }) + if (!sourceRangeConsumerAttached) { + sourceRangeLedger?.close() + } + const stream: TerminalMultiplexStream = { + streamId: request.streamId, + terminal: request.terminal, + ptyId, + client: request.client, + isMobile, + ackOutput: request.capabilities?.ackOutput === 1, + ackOutputSourceRanges: sourceRangeConsumerAttached, + streamGeneration, + sourceRangeLedger: sourceRangeConsumerAttached ? sourceRangeLedger : null, + sourceRangeConsumerAttached, + sourceRangeReplacement: null, + ackInFlightBytes: 0, + ackWindowBytes: TERMINAL_MULTIPLEX_ACK_STREAM_INITIAL_WINDOW_BYTES, + supportsOutputPause: request.capabilities?.outputPause === 1, + supportsWriteUnavailable: request.capabilities?.writeUnavailable === 1, + outputPaused: false, + supportsDesktopViewportClaims: request.capabilities?.desktopViewportClaims === 1, + desktopClaimTail: Promise.resolve(true), + registeredRemoteDesktopDriver: false, + // Why: streamId is client-local, so key the width floor by connectionId or two connections sharing stream 1 for one PTY clobber each other's floor. + remoteDesktopSubscriptionKey, + pendingRemoteDesktopViewport: null, + buffering: true, + ackPendingOutput: [], + ackPendingOutputBytes: 0, + ackPendingOutputOverflowed: false, + ackRecoverySnapshotInFlight: false, + pendingOutput: [], + pendingOutputBytes: 0, + pendingOutputOverflowed: false, + lastResizeCols: undefined, + resizeGeneration: 0, + outputBatcher: createTerminalOutputBatcher((data, meta) => { + if (meta?.cwd !== undefined) { + state.sendFrame( + request.streamId, + TerminalStreamOpcode.Metadata, + encodeTerminalStreamJson({ cwd: meta.cwd }), + meta.seq + ) + } + for (const chunk of iterateTerminalOutputFrameChunks(data, meta)) { + state.queueOrSendOutput(stream, chunk) + } + }), + unsubscribeData: () => {}, + unsubscribeResize: () => {}, + unsubscribeFit: () => {}, + unsubscribeDriver: () => {}, + unregisterBinaryHandler: () => {}, + exitWaiterAbort: new AbortController() + } + streams.set(request.streamId, stream) + stream.unregisterBinaryHandler = registerBinaryStreamHandler(request.streamId, (frame) => + state.handleSlotFrame(stream, frame) + ) + onInstalled(stream) + + const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { + if (state.closed || streams.get(request.streamId) !== stream) { + return + } + if (stream.outputPaused) { + return + } + if (stream.buffering) { + appendPendingMultiplexOutput(stream, data, meta) + return + } + stream.outputBatcher.push(data, meta) + }) + // Why: a multiplexed stream feeds a remote xterm view with query authority, so the main model responder yields while attached (terminal-query-authority.md). + const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) + stream.unsubscribeData = () => { + releaseViewSubscriber() + unsubscribeStreamData() + } + + if (isMobile && request.client?.id) { + await runtime.handleMobileSubscribe(ptyId, request.client.id, request.viewport) + } else if (request.client?.id && request.viewport) { + // Why: subscribe records this stream's geometry and cleanup key but doesn't claim ownership; activity frames claim later. + stream.registeredRemoteDesktopDriver = true + stream.pendingRemoteDesktopViewport = request.viewport + } + if ( + !isMobile && + request.client?.id && + stream.registeredRemoteDesktopDriver && + stream.pendingRemoteDesktopViewport + ) { + const viewport = stream.pendingRemoteDesktopViewport + stream.pendingRemoteDesktopViewport = null + await updateViewportForClient( + runtime, + ptyId, + stream.remoteDesktopSubscriptionKey, + request.client, + viewport, + 'desktop', + 'register', + !stream.supportsDesktopViewportClaims + ) + } + if (state.closed || streams.get(request.streamId) !== stream) { + return null + } + return stream +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-frame.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-frame.ts new file mode 100644 index 00000000000..dcb7b64ece4 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-frame.ts @@ -0,0 +1,73 @@ +import { decodeTerminalStreamJson } from '../../../../../shared/terminal-stream-protocol' +import { TerminalMultiplexSubscribeFrame } from './stream-schemas' +import type { + TerminalMultiplexConnection, + TerminalMultiplexSlotFramesStage +} from './terminal-multiplex-connection' +import { resolveMultiplexSubscribePty } from './terminal-multiplex-subscribe-resolution' +import { initializeMultiplexStream } from './terminal-multiplex-stream-initialization' +import { publishMultiplexInitialSnapshot } from './terminal-multiplex-initial-snapshot' +import { activateMultiplexStream } from './terminal-multiplex-live-stream' +import type { TerminalMultiplexStream } from './terminal-stream-types' + +export function installMultiplexSubscribeFrame( + build: TerminalMultiplexSlotFramesStage +): asserts build is TerminalMultiplexConnection { + const state = build as TerminalMultiplexConnection + state.handleSubscribeFrame = async (payload) => { + const raw = decodeTerminalStreamJson(payload) + const parsed = TerminalMultiplexSubscribeFrame.safeParse(raw) + if (!parsed.success) { + return + } + const request = parsed.data + const resolution = resolveMultiplexSubscribePty(state, request) + const ptyId = + typeof resolution === 'string' || resolution === null ? resolution : await resolution + if (!ptyId) { + return + } + let stream: TerminalMultiplexStream | null = null + let installedStream: TerminalMultiplexStream | null = null + try { + stream = await initializeMultiplexStream(state, request, ptyId, (installed) => { + installedStream = installed + }) + } catch (error) { + if (!installedStream) { + throw error + } + if (state.streams.get(request.streamId) !== installedStream) { + return + } + state.detachStream(request.streamId, false) + state.sendStreamError( + request.streamId, + error instanceof Error ? error.message : String(error) + ) + state.emit({ type: 'end', streamId: request.streamId }) + return + } + if (!stream) { + return + } + try { + const published = await publishMultiplexInitialSnapshot(state, request, stream) + if (!published) { + return + } + activateMultiplexStream(state, request, stream, published) + } catch (error) { + // A successor may already own the reused stream ID; never tear it down here. + if (state.streams.get(request.streamId) !== stream) { + return + } + state.detachStream(request.streamId, false) + state.sendStreamError( + request.streamId, + error instanceof Error ? error.message : String(error) + ) + state.emit({ type: 'end', streamId: request.streamId }) + } + } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-resolution.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-resolution.ts new file mode 100644 index 00000000000..6197850a47a --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-resolution.ts @@ -0,0 +1,96 @@ +import { TerminalStreamOpcode } from '../../../../../shared/terminal-stream-protocol' +import { + TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION, + TERMINAL_MULTIPLEX_MAX_PENDING_PTY_WAITS_PER_CONNECTION, + TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR +} from '../../../../../shared/terminal-multiplex-flow-control' +import type { + MultiplexSubscribeRequest, + TerminalMultiplexConnection +} from './terminal-multiplex-connection' + +function finalizeResolvedMultiplexPty( + state: TerminalMultiplexConnection, + request: MultiplexSubscribeRequest, + leaf: { ptyId: string | null } | null +): string | null { + if (!leaf?.ptyId) { + state.sendStreamError(request.streamId, 'no_connected_pty') + state.emit({ type: 'end', streamId: request.streamId }) + return null + } + if (state.closed) { + return null + } + // Why: a competing subscribe may own this streamId after the PTY await; detach it so an orphaned view subscriber can't silence the model responder (terminal-query-authority.md). + state.detachStream(request.streamId, false) + if (state.streams.size >= TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION) { + state.sendStreamError(request.streamId, TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR) + state.emit({ type: 'end', streamId: request.streamId }) + return null + } + return leaf.ptyId +} + +export function resolveMultiplexSubscribePty( + state: TerminalMultiplexConnection, + request: MultiplexSubscribeRequest +): string | null | Promise { + const { runtime, pendingPtyWaitControllers, registerBinaryStreamHandler, signal, emit } = state + state.detachStream(request.streamId, false) + state.cancelPendingPtyWaits(request.streamId) + + let leaf: { ptyId: string | null } | null + try { + // Why: binding the stream to whatever PTY now occupies a stale handle's pane would mirror the wrong terminal (#7718). + leaf = runtime.resolveLiveLeafForHandle(request.terminal) + } catch { + state.sendStreamError(request.streamId, 'terminal_handle_stale') + emit({ type: 'end', streamId: request.streamId }) + return null + } + if (leaf?.ptyId || !request.client) { + return finalizeResolvedMultiplexPty(state, request, leaf) + } + if (pendingPtyWaitControllers.size >= TERMINAL_MULTIPLEX_MAX_PENDING_PTY_WAITS_PER_CONNECTION) { + state.sendStreamError(request.streamId, TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR) + emit({ type: 'end', streamId: request.streamId }) + return null + } + + // Why: a never-mounted tab has no graph leaf to await; mounting the exact tab attaches its PTY without activating the worktree. + runtime.requestRendererTerminalTabMount(request.terminal) + const waitController = new AbortController() + const pendingControllers = pendingPtyWaitControllers.get(request.streamId) ?? new Set() + pendingControllers.add(waitController) + pendingPtyWaitControllers.set(request.streamId, pendingControllers) + if (signal?.aborted) { + waitController.abort() + } + // Why: the live slot handler does not exist until the PTY attaches; retain cancellation ownership while the pane is still pending. + const unregisterPendingHandler = registerBinaryStreamHandler(request.streamId, (frame) => { + if (frame.opcode === TerminalStreamOpcode.Unsubscribe) { + state.cancelPendingPtyWaits(request.streamId) + state.detachStream(request.streamId, false) + } + }) + return (async () => { + try { + const ptyId = await runtime.waitForLeafPtyId(request.terminal, 10_000, waitController.signal) + leaf = { ptyId } + } catch { + if (state.closed || signal?.aborted || waitController.signal.aborted) { + return null + } + // Fall through to the explicit no_connected_pty error below. + } finally { + const currentControllers = pendingPtyWaitControllers.get(request.streamId) + currentControllers?.delete(waitController) + if (currentControllers?.size === 0) { + pendingPtyWaitControllers.delete(request.streamId) + } + unregisterPendingHandler() + } + return finalizeResolvedMultiplexPty(state, request, leaf) + })() +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-output-batcher.ts b/src/main/runtime/rpc/methods/terminal/terminal-output-batcher.ts new file mode 100644 index 00000000000..4a5056946d6 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-output-batcher.ts @@ -0,0 +1,120 @@ +import type { TerminalOutputMeta } from '../../terminal-output-frame-chunks' +import { TERMINAL_OUTPUT_BATCH_MAX_BYTES } from '../../../../../shared/terminal-multiplex-flow-control' +import { measureTerminalStreamByteLength } from '../../terminal-stream-byte-length' +import { + sameTerminalOutputSourceIdentity, + type TerminalOutputSourceRange +} from '../../../../../shared/terminal-output-source-range' + +const TERMINAL_OUTPUT_FLUSH_MS = 5 +export type TerminalOutputBatcher = { + push: (data: string, meta?: TerminalOutputMeta) => void + flush: () => void + dispose: () => void +} + +export function createTerminalOutputBatcher( + onFlush: (data: string, meta?: TerminalOutputMeta) => void +): TerminalOutputBatcher { + let chunks: string[] = [] + let bytes = 0 + let lastSeq: number | undefined + let pendingCwd: string | undefined + let pendingRawLength = 0 + let pendingSourceRanges: TerminalOutputSourceRange[] = [] + let timer: ReturnType | null = null + + const clearTimer = (): void => { + if (!timer) { + return + } + clearTimeout(timer) + timer = null + } + + const flush = (): void => { + clearTimer() + if (chunks.length === 0 && pendingRawLength === 0) { + return + } + const data = chunks.length === 1 ? chunks[0]! : chunks.join('') + const meta = + typeof lastSeq === 'number' || pendingCwd !== undefined || pendingSourceRanges.length > 0 + ? { + ...(typeof lastSeq === 'number' ? { seq: lastSeq, rawLength: pendingRawLength } : {}), + ...(pendingCwd !== undefined ? { cwd: pendingCwd } : {}), + ...(pendingSourceRanges.length > 0 + ? { sourceRanges: Object.freeze(pendingSourceRanges.slice()) } + : {}) + } + : undefined + chunks = [] + bytes = 0 + lastSeq = undefined + pendingCwd = undefined + pendingRawLength = 0 + pendingSourceRanges = [] + onFlush(data, meta) + } + + return { + push(data: string, meta?: TerminalOutputMeta): void { + const rawLength = meta?.rawLength ?? data.length + if (!data && rawLength === 0) { + return + } + if (meta?.transformed || rawLength !== data.length) { + flush() + onFlush(data, { ...meta, rawLength, transformed: true }) + return + } + const nextSourceRanges = meta?.sourceRanges ?? [] + const lastSourceRange = pendingSourceRanges.at(-1) + const firstNextSourceRange = nextSourceRanges[0] + if ( + chunks.length > 0 && + (pendingSourceRanges.length > 0 !== nextSourceRanges.length > 0 || + (lastSourceRange && + firstNextSourceRange && + (!sameTerminalOutputSourceIdentity(lastSourceRange, firstNextSourceRange) || + lastSourceRange.displayEnd !== firstNextSourceRange.displayStart))) + ) { + flush() + } + if (meta?.cwd !== undefined) { + flush() + pendingCwd = meta.cwd + } + chunks.push(data) + pendingRawLength += rawLength + pendingSourceRanges.push(...nextSourceRanges) + const remainingBudget = Math.max(1, TERMINAL_OUTPUT_BATCH_MAX_BYTES - bytes) + const measurement = measureTerminalStreamByteLength(data, { + stopAfterBytes: remainingBudget + }) + bytes += measurement.byteLength + if (typeof meta?.seq === 'number') { + lastSeq = meta.seq + } + if (measurement.exceededLimit || bytes >= TERMINAL_OUTPUT_BATCH_MAX_BYTES) { + flush() + return + } + if (!timer) { + // Why: coalesce stream output before it crosses the network; desktop subscribers share the same burst boundary. + timer = setTimeout(flush, TERMINAL_OUTPUT_FLUSH_MS) + if (typeof timer.unref === 'function') { + timer.unref() + } + } + }, + flush, + dispose(): void { + clearTimer() + chunks = [] + bytes = 0 + pendingRawLength = 0 + pendingSourceRanges = [] + } + } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts new file mode 100644 index 00000000000..91ce5498506 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts @@ -0,0 +1,101 @@ +import { defineMethod, type RpcAnyMethod } from '../../core' +import { + TerminalHandle, + TerminalListParams, + TerminalRead, + TerminalRecoverPane, + TerminalRename, + TerminalResolveActive, + TerminalResolvePane +} from './unary-schemas' + +export const TERMINAL_QUERY_METHODS: RpcAnyMethod[] = [ + defineMethod({ + name: 'terminal.list', + params: TerminalListParams, + handler: async (params, { runtime }) => + runtime.listTerminals(params.worktree, params.limit, { + handles: params.handles, + requireFreshPtyLiveness: params.requireFreshPtyLiveness, + includeVisualLayouts: params.includeVisualLayouts + }) + }), + defineMethod({ + name: 'terminal.resolveActive', + params: TerminalResolveActive, + handler: async (params, { runtime }) => ({ + handle: await runtime.resolveActiveTerminal(params.worktree) + }) + }), + defineMethod({ + name: 'terminal.resolvePane', + params: TerminalResolvePane, + handler: async (params, { runtime }) => ({ + terminal: runtime.resolveTerminalPane(params.paneKey, params.worktreeId) + }) + }), + defineMethod({ + name: 'terminal.recoverPane', + params: TerminalRecoverPane, + handler: async (params, { runtime }) => ({ + terminal: await runtime.recoverTerminalPane( + params.paneKey, + params.worktreeId, + params.expectedTerminal + ) + }) + }), + defineMethod({ + name: 'terminal.show', + params: TerminalHandle, + handler: async (params, { runtime }) => ({ + terminal: await runtime.showTerminal(params.terminal) + }) + }), + defineMethod({ + name: 'terminal.read', + params: TerminalRead, + handler: async (params, { runtime }) => ({ + terminal: await runtime.readTerminal(params.terminal, { + cursor: params.cursor, + limit: params.limit, + screen: params.screen + }) + }) + }), + defineMethod({ + name: 'terminal.inspectProcess', + params: TerminalHandle, + handler: async (params, { runtime }) => ({ + process: await runtime.inspectTerminalProcess(params.terminal) + }) + }), + defineMethod({ + name: 'terminal.isRunningAgent', + params: TerminalHandle, + handler: async (params, { runtime }) => ({ + isRunningAgent: await runtime.isTerminalRunningAgent(params.terminal) + }) + }), + defineMethod({ + name: 'terminal.agentStatus', + params: TerminalHandle, + handler: async (params, { runtime }) => ({ + agentStatus: await runtime.getTerminalAgentStatus(params.terminal) + }) + }), + defineMethod({ + name: 'terminal.rename', + params: TerminalRename, + handler: async (params, { runtime }) => ({ + rename: await runtime.renameTerminal(params.terminal, params.title || null) + }) + }), + defineMethod({ + name: 'terminal.clearBuffer', + params: TerminalHandle, + handler: async (params, { runtime }) => ({ + clear: await runtime.clearTerminalBuffer(params.terminal) + }) + }) +] diff --git a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts new file mode 100644 index 00000000000..a4c58fbab68 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts @@ -0,0 +1,227 @@ +import { InvalidArgumentError, defineMethod, type RpcAnyMethod } from '../../core' +import { isTerminalQueryReply } from '../../../../../shared/terminal-query-reply' +import { assertTerminalAgentSendable } from '../../terminal-agent-send-guard' +import { TerminalSend } from './unary-schemas' +import { + assertTerminalSendExactPtyBinding, + assertTerminalSendTextWithinLimit, + commitMobileInputFloorClaim, + getTerminalSendGuardRefusedReason, + isTerminalInputLockedForClient, + isTerminalSendGuardNotWritable, + resolveMobileFloorClientId, + type MobileInputFloorClaimHolder +} from './terminal-input-delivery' +import { updateViewportForClient } from './terminal-viewport-update' + +export const TERMINAL_SEND_METHODS: RpcAnyMethod[] = [ + defineMethod({ + name: 'terminal.send', + params: TerminalSend, + handler: async (params, { runtime, clientId, signal }) => { + await assertTerminalSendTextWithinLimit(params.text) + await assertTerminalSendTextWithinLimit(params.resolvedLaunchDraft?.text) + const queryReplyClientId = clientId ?? params.client?.id + if ( + params.inputKind === 'query-reply' && + (!params.text || + !isTerminalQueryReply(params.text) || + params.enter === true || + params.interrupt === true || + params.agentPrompt === true || + params.requireAgentStatus !== undefined || + params.client?.type !== 'mobile' || + !queryReplyClientId || + (clientId !== undefined && params.client.id !== clientId)) + ) { + throw new InvalidArgumentError('Invalid terminal query reply') + } + // Why: a stale handle must fail with terminal_handle_stale, not evaluate driver/lock state against the wrong PTY (#7718). + const leaf = runtime.resolveLiveLeafForHandle(params.terminal) + const driver = leaf?.ptyId ? runtime.getDriver(leaf.ptyId) : null + if ( + params.inputKind === 'query-reply' && + leaf?.ptyId && + !runtime.isMobileTerminalQueryReplyAuthority(leaf.ptyId, queryReplyClientId!) + ) { + return { + send: { + handle: params.terminal, + accepted: false, + bytesWritten: 0 + } + } + } + if (leaf?.ptyId && isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) { + return { + send: { + handle: params.terminal, + accepted: false, + bytesWritten: 0 + } + } + } + if ( + leaf?.ptyId && + params.client?.type === 'desktop' && + params.claimViewport === true && + params.viewport + ) { + const claim = await updateViewportForClient( + runtime, + leaf.ptyId, + `send:${params.client.id}`, + params.client, + params.viewport, + 'desktop', + 'refresh', + true + ) + // Why: a stream-less request can't safely create ownership, so never write at stale geometry. + if (!claim.updated || isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) { + return { + send: { + handle: params.terminal, + accepted: false, + bytesWritten: 0 + } + } + } + } + const hasText = typeof params.text === 'string' && params.text.length > 0 + const hasSuffix = params.enter === true || params.interrupt === true + if (params.requireAgentStatus === 'sendable' && hasText && hasSuffix) { + // Why: guarded sends are two-phase; reject combined payload + submit so a guard flip can't cause partial delivery. + return { + send: { + handle: params.terminal, + accepted: false, + bytesWritten: 0 + } + } + } + // Why: recheck permission/no-agent state immediately before accepting the PTY write. + const assertSendPreconditions = + params.requireAgentStatus === 'sendable' + ? async (ptyId?: string): Promise => { + await assertTerminalAgentSendable({ + runtime, + handle: params.terminal, + assertWritable: () => { + assertTerminalSendExactPtyBinding(runtime, params.terminal, ptyId) + if (ptyId && isTerminalInputLockedForClient(runtime, ptyId, params.client)) { + throw new Error('terminal_guard_not_writable') + } + } + }) + } + : undefined + if (params.requireAgentStatus === 'sendable') { + try { + await assertSendPreconditions?.(leaf?.ptyId ?? undefined) + } catch (error) { + if (isTerminalSendGuardNotWritable(error)) { + return { + send: { + handle: params.terminal, + accepted: false, + bytesWritten: 0 + } + } + } + const refusedReason = getTerminalSendGuardRefusedReason(error) + if (!refusedReason) { + throw error + } + return { + send: { + handle: params.terminal, + accepted: false, + bytesWritten: 0, + refusedReason + } + } + } + } + const mobileFloorClientId = resolveMobileFloorClientId(driver, params.client) + const mobileFloorClaim: MobileInputFloorClaimHolder = { current: null } + const beforeWrite = assertSendPreconditions + const useSettledAgentPrompt = + params.agentPrompt === true && + hasText && + params.enter === true && + params.interrupt !== true && + params.client?.type === 'desktop' && + (await runtime.isTerminalRunningSettledPromptAgent(params.terminal)) + const reserveWrite = + params.inputKind !== 'query-reply' && leaf?.ptyId && mobileFloorClientId + ? (ptyId: string): void => { + const claim = runtime.beginMobileInputFloor(ptyId, mobileFloorClientId) + if (!claim) { + throw new Error('mobile_input_floor_unavailable') + } + mobileFloorClaim.current = claim + } + : undefined + let result + try { + result = useSettledAgentPrompt + ? await runtime.sendTerminalAgentPrompt(params.terminal, params.text!, { + beforeWrite, + signal + }) + : await runtime.sendTerminal( + params.terminal, + { + text: params.text, + enter: params.enter === true, + interrupt: params.interrupt === true + }, + { + beforeWrite, + ...(reserveWrite ? { reserveWrite } : {}), + ...(params.inputKind !== 'query-reply' && mobileFloorClientId + ? { afterWrite: () => commitMobileInputFloorClaim(mobileFloorClaim) } + : {}) + } + ) + } catch (error) { + mobileFloorClaim.current?.rollback() + const refusedReason = getTerminalSendGuardRefusedReason(error) + if (refusedReason) { + return { + send: { + handle: params.terminal, + accepted: false, + bytesWritten: 0, + refusedReason + } + } + } + if (isTerminalSendGuardNotWritable(error)) { + return { + send: { + handle: params.terminal, + accepted: false, + bytesWritten: 0 + } + } + } + throw error + } + if (result.accepted !== true) { + mobileFloorClaim.current?.rollback() + } + if ( + result.accepted === true && + params.enter === true && + params.client?.type === 'mobile' && + params.resolvedLaunchDraft + ) { + runtime.notifyNativeChatLaunchDraftResolved(params.terminal, params.resolvedLaunchDraft) + } + // Why: deliberate mobile input takes the floor (drives `* → mobile{clientId}`); clientless sends fall back to the current mobile driver. + return { send: result } + } + }) +] diff --git a/src/main/runtime/rpc/methods/terminal/terminal-snapshot-publication.ts b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-publication.ts new file mode 100644 index 00000000000..33b09a0ead9 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-publication.ts @@ -0,0 +1,209 @@ +import type { OrcaRuntimeService } from '../../../orca-runtime' +import { + TerminalStreamOpcode, + encodeTerminalStreamJson +} from '../../../../../shared/terminal-stream-protocol' +import { + MOBILE_SNAPSHOT_BYTE_BUDGET, + MOBILE_SUBSCRIBE_SCROLLBACK_ROWS +} from '../../../scrollback-limits' +import { terminalStreamByteLengthExceeds } from '../../terminal-stream-byte-length' +import { + iterateTerminalStreamTextPayloads, + requestedSnapshotScrollbackCandidates +} from './terminal-stream-replay' +import type { SerializedSnapshot, SnapshotFrameOptions } from './terminal-stream-types' + +const REQUESTED_SNAPSHOT_BYTE_BUDGET = 2 * 1024 * 1024 + +export async function serializeBudgetedRequestedSnapshot( + runtime: OrcaRuntimeService, + ptyId: string, + scrollbackRows: number | undefined +): Promise { + const requestedRows = scrollbackRows ?? 0 + for (const rows of requestedSnapshotScrollbackCandidates(scrollbackRows)) { + const serialized = await runtime.serializeAuthoritativeTerminalBuffer(ptyId, { + scrollbackRows: rows + }) + if (!serialized) { + return null + } + const scrollbackAnsi = + 'scrollbackAnsi' in serialized && typeof serialized.scrollbackAnsi === 'string' + ? serialized.scrollbackAnsi + : '' + const data = scrollbackAnsi + serialized.data + const overByteBudget = terminalStreamByteLengthExceeds(data, REQUESTED_SNAPSHOT_BYTE_BUDGET) + if (!overByteBudget || rows === 0) { + return { + ...serialized, + data, + scrollbackRows: rows, + truncatedByByteBudget: rows < requestedRows || overByteBudget + } + } + } + return null +} + +export function sendSnapshotFrames( + sendFrame: ( + opcode: TerminalStreamOpcode, + payload?: Uint8Array + ) => boolean | void, + options: SnapshotFrameOptions +): { bytes: number; chunks: number; published: boolean } { + if ( + sendFrame( + TerminalStreamOpcode.SnapshotStart, + encodeTerminalStreamJson({ + kind: options.kind, + cols: options.cols, + rows: options.rows, + requestId: options.requestId, + displayMode: options.displayMode, + reason: options.reason, + unavailable: options.unavailable, + seq: options.seq, + cwd: options.cwd, + source: options.source, + oscLinks: options.oscLinks, + pendingEscapeTailAnsi: options.pendingEscapeTailAnsi, + // Why conditional and additive: old clients ignore the unknown field, + // and a new client must read absence as unknown rather than zero, so + // no opcode or capability negotiation is involved (Rule 1 of + // docs/reference/remote-wire-compatibility.md). + // Why `seq` is required: the flags are only proven at this frame's own + // seq, so without a replay boundary the client cannot order them. + ...(typeof options.seq === 'number' && options.kittyKeyboardFlags !== undefined + ? { kittyKeyboardFlags: options.kittyKeyboardFlags } + : {}), + truncated: options.truncated === true, + truncatedByByteBudget: options.truncatedByByteBudget === true + }) + ) === false + ) { + return { bytes: 0, chunks: 0, published: false } + } + let chunks = 0 + let bytes = 0 + for (const chunk of iterateTerminalStreamTextPayloads(options.data)) { + if (sendFrame(TerminalStreamOpcode.SnapshotChunk, chunk) === false) { + return { bytes, chunks, published: false } + } + chunks++ + bytes += chunk.byteLength + } + const published = sendFrame(TerminalStreamOpcode.SnapshotEnd) !== false + return { bytes, chunks, published } +} + +export async function serializeBudgetedMobileSnapshot( + runtime: OrcaRuntimeService, + ptyId: string, + isMobile: boolean +): Promise { + if (!isMobile) { + const serialized = await runtime.serializeTerminalBuffer(ptyId, { scrollbackRows: 0 }) + return serialized + ? { + ...serialized, + data: (serialized.scrollbackAnsi ?? '') + serialized.data, + scrollbackRows: 0, + truncatedByByteBudget: false + } + : null + } + const candidates = [MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, 500, 250, 100, 25, 0] + for (const rows of candidates) { + const serialized = await runtime.serializeTerminalBuffer(ptyId, { scrollbackRows: rows }) + if (!serialized) { + return null + } + const data = (serialized.scrollbackAnsi ?? '') + serialized.data + const overByteBudget = terminalStreamByteLengthExceeds(data, MOBILE_SNAPSHOT_BYTE_BUDGET) + if (!overByteBudget || rows === 0) { + return { + ...serialized, + data, + scrollbackRows: rows, + truncatedByByteBudget: rows < MOBILE_SUBSCRIBE_SCROLLBACK_ROWS || overByteBudget + } + } + } + return null +} + +export async function serializeStableMobileRendererSnapshot( + runtime: OrcaRuntimeService, + ptyId: string +): Promise { + const candidates = [MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, 500, 250, 100, 25, 0] + let candidateIndex = 0 + for (let attempt = 0; attempt < candidates.length; attempt += 1) { + // Why: advance toward zero scrollback each retry so the final attempt always has a bounded payload. + candidateIndex = Math.max(candidateIndex, attempt) + const rows = candidates[candidateIndex] + const outputSequenceBefore = runtime.getPtyOutputSequence(ptyId) + const serialized = await runtime.serializeRendererTerminalBuffer(ptyId, { + scrollbackRows: rows + }) + const outputSequenceAfter = runtime.getPtyOutputSequence(ptyId) + if (outputSequenceBefore !== outputSequenceAfter) { + continue + } + if (!serialized) { + return null + } + const overByteBudget = terminalStreamByteLengthExceeds( + serialized.data, + MOBILE_SNAPSHOT_BYTE_BUDGET + ) + if (!overByteBudget || rows === 0) { + return { + ...serialized, + scrollbackRows: rows, + truncatedByByteBudget: rows < MOBILE_SUBSCRIBE_SCROLLBACK_ROWS || overByteBudget + } + } + candidateIndex += 1 + } + return null +} + +// Why: mobile xterm can't rewrap the HARD newlines baked into a restored snapshot, so a real reflow re-serializes and replays the FULL buffer at the new cols. +export async function sendMobileResizeRestream( + runtime: OrcaRuntimeService, + ptyId: string, + sendFrame: (opcode: TerminalStreamOpcode, payload?: Uint8Array) => void, + event: { cols: number; rows: number; displayMode: string; reason: string; seq?: number }, + shouldSend?: () => boolean +): Promise { + // Why: only a true geometry reflow rewraps scrollback; a dimensionless mode-change would re-send the whole buffer for nothing. + if (event.reason !== 'apply-layout' || runtime.isTerminalAlternateScreen(ptyId)) { + return false + } + const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, true) + if (!serialized) { + return false + } + if (shouldSend && !shouldSend()) { + return true + } + sendSnapshotFrames(sendFrame, { + kind: 'resized', + cols: serialized.cols, + rows: serialized.rows, + displayMode: event.displayMode, + reason: event.reason, + seq: event.seq ?? serialized.seq, + source: serialized.source, + cwd: serialized.cwd, + oscLinks: serialized.oscLinks, + truncated: false, + truncatedByByteBudget: serialized.truncatedByByteBudget, + data: serialized.data + }) + return true +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-stream-replay.ts b/src/main/runtime/rpc/methods/terminal/terminal-stream-replay.ts new file mode 100644 index 00000000000..5ffd1079a3c --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-stream-replay.ts @@ -0,0 +1,203 @@ +import type { TerminalReplyQuerySequence } from '../../../../../shared/terminal-reply-query-scan' +import { + iterateTerminalOutputFrameChunks, + sliceTerminalOutputSourceRanges, + type TerminalOutputFrameChunk, + type TerminalOutputMeta +} from '../../terminal-output-frame-chunks' +import { + measureTerminalStreamByteLength, + terminalStreamByteLength +} from '../../terminal-stream-byte-length' +import { TERMINAL_MULTIPLEX_PENDING_MAX_BYTES } from '../../../../../shared/terminal-multiplex-flow-control' +import type { TerminalMultiplexStream, TerminalOutputChunk } from './terminal-stream-types' + +export function appendPendingMultiplexOutput( + stream: TerminalMultiplexStream, + data: string, + meta?: TerminalOutputMeta +): void { + const remainingBudget = Math.max( + 1, + TERMINAL_MULTIPLEX_PENDING_MAX_BYTES - stream.pendingOutputBytes + ) + const measurement = measureTerminalStreamByteLength(data, { + stopAfterBytes: remainingBudget + }) + stream.pendingOutput.push({ data, bytes: measurement.byteLength, meta }) + stream.pendingOutputBytes += measurement.byteLength + const trimmed = trimPendingOutputToBudget(stream.pendingOutput, stream.pendingOutputBytes) + stream.pendingOutputBytes = trimmed.bytes + stream.pendingOutputOverflowed ||= trimmed.overflowed +} + +export function getOutputAfterSnapshotSeq( + chunk: TerminalOutputChunk, + snapshotSeq: number | undefined +): TerminalOutputChunk | null { + if ( + typeof snapshotSeq !== 'number' || + typeof chunk.meta?.seq !== 'number' || + typeof chunk.meta.rawLength !== 'number' + ) { + return chunk + } + if (chunk.meta.seq <= snapshotSeq) { + return null + } + const chunkStartSeq = chunk.meta.seq - chunk.meta.rawLength + if (chunkStartSeq >= snapshotSeq) { + return chunk + } + if (chunk.meta.transformed) { + return null + } + const offset = snapshotSeq - chunkStartSeq + return { + data: chunk.data.slice(offset), + bytes: chunk.bytes, + meta: { + ...chunk.meta, + rawLength: chunk.meta.rawLength - offset, + sourceRanges: sliceTerminalOutputSourceRanges( + chunk.meta.sourceRanges, + offset, + chunk.data.length + ) + } + } +} + +export function stripSnapshotBoundaryQuerySuffixes( + data: string, + dataStartSeq: number, + snapshotSeq: number, + queries: TerminalReplyQuerySequence[] +): string { + let output = '' + let offset = 0 + for (const query of queries) { + if (query.startSeq >= snapshotSeq || query.endSeq <= snapshotSeq) { + continue + } + const removeStart = Math.max(0, query.startSeq - dataStartSeq) + const removeEnd = Math.min(data.length, query.endSeq - dataStartSeq) + if (removeEnd <= offset || removeStart >= data.length) { + continue + } + output += data.slice(offset, removeStart) + offset = removeEnd + } + return output + data.slice(offset) +} + +export function appendAckPendingOutput( + stream: TerminalMultiplexStream, + chunk: TerminalOutputFrameChunk +): void { + stream.ackPendingOutput.push(chunk) + stream.ackPendingOutputBytes += chunk.bytes.byteLength + let omittedChunkCount = 0 + while ( + stream.ackPendingOutputBytes > TERMINAL_MULTIPLEX_PENDING_MAX_BYTES && + omittedChunkCount < stream.ackPendingOutput.length + ) { + stream.ackPendingOutputBytes -= stream.ackPendingOutput[omittedChunkCount]!.bytes.byteLength + omittedChunkCount += 1 + } + if (omittedChunkCount > 0) { + stream.ackPendingOutput.splice(0, omittedChunkCount) + stream.ackPendingOutputOverflowed = true + } +} + +export function trimPendingOutputToBudget( + pendingOutput: TerminalOutputChunk[], + pendingOutputBytes: number +): { bytes: number; overflowed: boolean } { + let omittedChunkCount = 0 + while ( + pendingOutputBytes > TERMINAL_MULTIPLEX_PENDING_MAX_BYTES && + omittedChunkCount < pendingOutput.length + ) { + const chunk = pendingOutput[omittedChunkCount] + pendingOutputBytes -= chunk.bytes + omittedChunkCount += 1 + } + if (omittedChunkCount > 0) { + pendingOutput.splice(0, omittedChunkCount) + } + return { bytes: pendingOutputBytes, overflowed: omittedChunkCount > 0 } +} + +export function trimPendingOutputCoveredBySnapshot( + pendingOutput: TerminalOutputChunk[], + snapshotSeq: number | undefined +): { chunks: TerminalOutputChunk[]; bytes: number } { + if (typeof snapshotSeq !== 'number') { + return { + chunks: pendingOutput, + bytes: pendingOutput.reduce((sum, chunk) => sum + chunk.bytes, 0) + } + } + const chunks: TerminalOutputChunk[] = [] + let bytes = 0 + for (const chunk of pendingOutput) { + const chunkSeq = chunk.meta?.seq + const rawLength = chunk.meta?.rawLength ?? chunk.data.length + if (typeof chunkSeq !== 'number' || rawLength !== chunk.data.length) { + chunks.push(chunk) + bytes += chunk.bytes + continue + } + const startSeq = chunkSeq - rawLength + if (snapshotSeq >= chunkSeq) { + continue + } + if (snapshotSeq <= startSeq) { + chunks.push(chunk) + bytes += chunk.bytes + continue + } + const data = chunk.data.slice(snapshotSeq - startSeq) + const slicedBytes = terminalStreamByteLength(data) + chunks.push({ data, bytes: slicedBytes, meta: undefined }) + bytes += slicedBytes + } + return { chunks, bytes } +} + +export function* iterateTerminalStreamTextPayloads( + data: string +): Generator> { + if (!data) { + return + } + for (const chunk of iterateTerminalOutputFrameChunks(data)) { + yield chunk.bytes + } +} + +export function isTerminalReadPayloadIncomplete(read: { + truncated: boolean + limited?: boolean +}): boolean { + // Why: a limited preview is an incomplete payload even when the retained buffer wasn't truncated. + return read.truncated || read.limited === true +} + +export function normalizeMultiplexSnapshotScrollbackRows( + value: number | undefined +): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return undefined + } + return Math.max(0, Math.min(50_000, Math.floor(value))) +} + +export function requestedSnapshotScrollbackCandidates(requestedRows: number | undefined): number[] { + const candidates = [requestedRows ?? 0, 1000, 500, 250, 100, 25, 0] + .filter((rows): rows is number => typeof rows === 'number') + .map((rows) => Math.max(0, Math.min(50_000, Math.floor(rows)))) + return [...new Set(candidates)] +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts b/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts new file mode 100644 index 00000000000..484a9f0f933 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts @@ -0,0 +1,100 @@ +import type { TerminalOscLinkRange } from '../../../../../shared/terminal-osc-link-ranges' +import type { TerminalSnapshotUnavailableReason } from '../../../../../shared/terminal-snapshot-unavailability' +import type { TerminalSourceRangeLedger } from '../../terminal-source-range-ledger' +import type { RemoteTerminalSourceRangeReplacementReservation } from '../../../remote-terminal-source-range-consumer' +import type { + TerminalOutputFrameChunk, + TerminalOutputMeta +} from '../../terminal-output-frame-chunks' +import type { TerminalOutputBatcher } from './terminal-output-batcher' + +export type SnapshotFrameOptions = { + kind: 'scrollback' | 'resized' + cols: number + rows: number + data: string + requestId?: number + displayMode?: string + reason?: string + seq?: number + cwd?: string | null + truncated?: boolean + truncatedByByteBudget?: boolean + // Why: distinguishes "I could not answer right now" from a genuinely empty buffer; omitted on success. + unavailable?: TerminalSnapshotUnavailableReason + source?: 'headless' | 'renderer' + oscLinks?: TerminalOscLinkRange[] + pendingEscapeTailAnsi?: string + /** Effective kitty flags proven at this frame's own `seq`. */ + kittyKeyboardFlags?: number +} + +export type SerializedSnapshot = { + data: string + scrollbackAnsi?: string + cols: number + rows: number + seq?: number + cwd?: string | null + source?: 'headless' | 'renderer' + oscLinks?: TerminalOscLinkRange[] + scrollbackRows: number + truncatedByByteBudget: boolean + pendingEscapeTailAnsi?: string + kittyKeyboardFlags?: number +} | null + +export type TerminalViewportClient = { + id: string + type?: 'mobile' | 'desktop' +} + +export type TerminalMultiplexStream = { + streamId: number + terminal: string + ptyId: string + client: TerminalViewportClient | undefined + isMobile: boolean + ackOutput: boolean + ackOutputSourceRanges: boolean + streamGeneration: string + sourceRangeLedger: TerminalSourceRangeLedger | null + sourceRangeConsumerAttached: boolean + sourceRangeReplacement: RemoteTerminalSourceRangeReplacementReservation | null + ackInFlightBytes: number + ackWindowBytes: number + supportsOutputPause: boolean + supportsWriteUnavailable: boolean + outputPaused: boolean + supportsDesktopViewportClaims: boolean + desktopClaimTail: Promise + // Whether THIS stream registered the width driver, so detach won't release a peer stream's floor. + registeredRemoteDesktopDriver: boolean + remoteDesktopSubscriptionKey: string + pendingRemoteDesktopViewport: { cols: number; rows: number } | null + buffering: boolean + ackPendingOutput: TerminalOutputFrameChunk[] + ackPendingOutputBytes: number + ackPendingOutputOverflowed: boolean + ackRecoverySnapshotInFlight: boolean + pendingOutput: TerminalOutputChunk[] + pendingOutputBytes: number + pendingOutputOverflowed: boolean + // Cols the mobile client last rewrapped to; re-stream full scrollback only when width actually changes. + lastResizeCols: number | undefined + resizeGeneration: number + outputBatcher: TerminalOutputBatcher + unsubscribeData: () => void + unsubscribeResize: () => void + unsubscribeFit: () => void + unsubscribeDriver: () => void + unregisterBinaryHandler: () => void + // Why: the runtime drops the exit-waiter only on real PTY exit; abort on detach so a never-exiting agent terminal doesn't leak the waiter. + exitWaiterAbort: AbortController +} + +export type TerminalOutputChunk = { + data: string + bytes: number + meta?: TerminalOutputMeta +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts new file mode 100644 index 00000000000..bd16382d747 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts @@ -0,0 +1,95 @@ +import { defineStreamingMethod, type RpcAnyMethod } from '../../core' +import { TerminalSubscribe } from './stream-schemas' +import { isTerminalReadPayloadIncomplete } from './terminal-stream-replay' +import { runTerminalBinarySubscription } from './terminal-legacy-subscribe-binary' +import { + runTerminalJsonSubscription, + runTerminalLeaseSubscription +} from './terminal-legacy-simple-subscriptions' +import type { TerminalSubscriptionArgs } from './terminal-legacy-subscription-types' + +export const TERMINAL_SUBSCRIBE_METHODS: RpcAnyMethod[] = [ + // Streams live terminal output over WebSocket; mobile clients pass client+viewport for server-side auto-fit. + defineStreamingMethod({ + name: 'terminal.subscribe', + params: TerminalSubscribe, + handler: async ( + params, + { runtime, connectionId, sendBinary, registerBinaryStreamHandler, signal }, + emit + ) => { + let leaf = runtime.resolveLeafForHandle(params.terminal) + const isMobile = params.client?.type === 'mobile' + const serializerGenerationBeforeAnyMount = isMobile + ? (runtime.getRendererTerminalSerializerGenerationForHandle?.(params.terminal) ?? 0) + : 0 + let rendererMountRequestedBeforePty = false + const useBinaryStream = params.capabilities?.terminalBinaryStream === 1 && Boolean(sendBinary) + if (signal?.aborted) { + return + } + + if (!leaf?.ptyId && params.client) { + rendererMountRequestedBeforePty = runtime.requestRendererTerminalTabMount(params.terminal) + try { + const ptyId = await runtime.waitForLeafPtyId(params.terminal, 10_000, signal) + leaf = { ptyId } + } catch { + if (signal?.aborted) { + return + } + } + } + if (!leaf?.ptyId) { + const read = await runtime.readTerminal(params.terminal) + emit({ + type: 'subscribed', + streamId: null, + lines: read.tail, + truncated: isTerminalReadPayloadIncomplete(read) + }) + emit({ type: 'end' }) + return + } + if (isMobile && (!useBinaryStream || !sendBinary)) { + throw new Error('binary_terminal_stream_required') + } + + const ptyId = leaf.ptyId + const clientId = params.client?.id + const missingHeadlessStateBeforeMobileFit = + isMobile && + (rendererMountRequestedBeforePty || runtime.hasHeadlessTerminalState?.(ptyId) === false) + const args: TerminalSubscriptionArgs = { + params, + runtime, + connectionId, + sendBinary, + registerBinaryStreamHandler, + signal, + emit, + ptyId, + clientId, + isMobile, + supportsDesktopViewportClaims: params.capabilities?.desktopViewportClaims === 1, + supportsWriteUnavailable: params.capabilities?.writeUnavailable === 1, + rendererMountRequestedBeforePty, + missingHeadlessStateBeforeMobileFit, + serializerGenerationBeforeMobileFit: missingHeadlessStateBeforeMobileFit + ? rendererMountRequestedBeforePty + ? serializerGenerationBeforeAnyMount + : runtime.getRendererTerminalSerializerGeneration(ptyId) + : 0 + } + if (isMobile && params.capabilities?.mobileInputLeaseOnly === 1 && Boolean(clientId)) { + await runTerminalLeaseSubscription(args) + return + } + if (!useBinaryStream) { + await runTerminalJsonSubscription(args) + return + } + await runTerminalBinarySubscription(args) + } + }) +] diff --git a/src/main/runtime/rpc/methods/terminal/terminal-subscription-stream-id.ts b/src/main/runtime/rpc/methods/terminal/terminal-subscription-stream-id.ts new file mode 100644 index 00000000000..66c50b3f8c3 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-subscription-stream-id.ts @@ -0,0 +1,5 @@ +let nextTerminalStreamId = 1 + +export function allocateTerminalSubscriptionStreamId(): number { + return nextTerminalStreamId++ +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts new file mode 100644 index 00000000000..71feee4f24d --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts @@ -0,0 +1,120 @@ +import { z } from 'zod' +import { defineMethod, type RpcAnyMethod } from '../../core' +import { TerminalHandle } from './unary-schemas' +import { + TerminalSetAutoRestoreFit, + TerminalSetDisplayMode, + TerminalUnsubscribe, + TerminalUpdateViewport +} from './viewport-schemas' +import { updateViewportForClient } from './terminal-viewport-update' + +export const TERMINAL_VIEWPORT_METHODS_BEFORE_STREAMS: RpcAnyMethod[] = [ + defineMethod({ + name: 'terminal.setDisplayMode', + params: TerminalSetDisplayMode, + handler: async (params, { runtime }) => { + // Why: a stale handle must fail with terminal_handle_stale, not mutate the wrong PTY's display mode/viewport (#7718). + const leaf = runtime.resolveLiveLeafForHandle(params.terminal) + if (!leaf?.ptyId) { + throw new Error('no_connected_pty') + } + // Why: late-bind viewport for desktop-subscribed callers; otherwise an 'auto' toggle skips phone-fit and nothing resizes. + if (params.viewport && params.client?.id) { + runtime.updateMobileSubscriberViewport(leaf.ptyId, params.client.id, params.viewport) + } + if (params.client && params.client.type === 'mobile' && params.mode !== 'desktop') { + runtime.markMobileActor(leaf.ptyId, params.client.id) + } + runtime.setMobileDisplayMode(leaf.ptyId, params.mode) + await runtime.applyMobileDisplayMode(leaf.ptyId) + return { mode: params.mode, seq: runtime.getLayout(leaf.ptyId)?.seq } + } + }), + defineMethod({ + name: 'terminal.restoreFit', + params: TerminalHandle, + handler: async (params, { runtime }) => { + // Why: a stale handle must fail with terminal_handle_stale, not reclaim the wrong PTY to desktop dims (#7718). + const leaf = runtime.resolveLiveLeafForHandle(params.terminal) + if (!leaf?.ptyId) { + throw new Error('no_connected_pty') + } + return { restored: await runtime.reclaimTerminalForDesktop(leaf.ptyId) } + } + }), + defineMethod({ + name: 'terminal.getDisplayMode', + params: TerminalHandle, + handler: async (params, { runtime }) => { + const leaf = runtime.resolveLeafForHandle(params.terminal) + const mode = leaf?.ptyId ? runtime.getMobileDisplayMode(leaf.ptyId) : 'auto' + const isPhoneFitted = leaf?.ptyId ? runtime.isMobileSubscriberActive(leaf.ptyId) : false + return { mode, isPhoneFitted } + } + }), + defineMethod({ + name: 'terminal.updateViewport', + params: TerminalUpdateViewport, + handler: async (params, { runtime }) => { + // Why: a stale handle must fail with terminal_handle_stale, not write viewport state to the wrong PTY (#7718). + const leaf = runtime.resolveLiveLeafForHandle(params.terminal) + if (!leaf?.ptyId) { + throw new Error('no_connected_pty') + } + const viewportUpdate = await updateViewportForClient( + runtime, + leaf.ptyId, + `viewport:${params.client.id}`, + params.client, + params.viewport, + 'mobile', + // Why: one-shot RPC with no disconnect hook — refresh the existing stream-owned floor, never create a leak-prone one. + 'refresh', + params.claim === true + ) + return { ...viewportUpdate, seq: runtime.getLayout(leaf.ptyId)?.seq } + } + }) +] + +export const TERMINAL_VIEWPORT_METHODS_AFTER_STREAMS: RpcAnyMethod[] = [ + defineMethod({ + name: 'terminal.unsubscribe', + params: TerminalUnsubscribe, + handler: async (params, { runtime, connectionId }) => { + // Why: only the connection that owns the subscription may retire it — a stale + // unsubscribe from the pre-reconnect socket names an id the replacement now owns. + let unsubscribed = runtime.cleanupSubscriptionIfOwnedByConnection( + params.subscriptionId, + connectionId + ) + // Why: older builds send a bare-handle subscriptionId, so also try the reconstructed `${terminal}:${clientId}` composite key. + // Why AND over the calls that ran: a clientless stream registers under the bare id + // and a client-scoped one under the composite, so either call can be the real + // teardown. Reporting false needs one genuine refusal, not merely a missing id. + if (params.client && !params.subscriptionId.includes(':')) { + unsubscribed = + runtime.cleanupSubscriptionIfOwnedByConnection( + `${params.subscriptionId}:${params.client.id}`, + connectionId + ) && unsubscribed + } + return { unsubscribed } + } + }), + defineMethod({ + name: 'terminal.getAutoRestoreFit', + params: z.object({}), + handler: async (_params, { runtime }) => ({ + ms: runtime.getMobileAutoRestoreFitMs() + }) + }), + defineMethod({ + name: 'terminal.setAutoRestoreFit', + params: TerminalSetAutoRestoreFit, + handler: async (params, { runtime }) => ({ + ms: runtime.setMobileAutoRestoreFitMs(params.ms) + }) + }) +] diff --git a/src/main/runtime/rpc/methods/terminal/terminal-viewport-update.ts b/src/main/runtime/rpc/methods/terminal/terminal-viewport-update.ts new file mode 100644 index 00000000000..cfcaa3a0896 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-viewport-update.ts @@ -0,0 +1,38 @@ +import type { OrcaRuntimeService } from '../../../orca-runtime' +import type { TerminalViewportClient } from './terminal-stream-types' + +export async function updateViewportForClient( + runtime: OrcaRuntimeService, + ptyId: string, + subscriptionKey: string, + client: TerminalViewportClient, + viewport: { cols: number; rows: number }, + defaultType: 'mobile' | 'desktop', + // Why: the one-shot RPC has no disconnect hook, so 'refresh' only updates a stream-owned floor; stream paths that own cleanup 'register'. + registration: 'register' | 'refresh' = 'register', + claim = false +): Promise<{ updated: boolean; applied: boolean }> { + const type = client.type ?? defaultType + if (type === 'mobile') { + return runtime.updateMobileViewport(ptyId, client.id, viewport) + } + // Why: stream attachment observes geometry without taking control; a later claim frame makes it authoritative. + const updated = + registration === 'refresh' + ? await runtime.refreshRemoteDesktopViewer( + ptyId, + client.id, + viewport.cols, + viewport.rows, + claim + ) + : await runtime.updateRemoteDesktopViewer( + ptyId, + subscriptionKey, + client.id, + viewport.cols, + viewport.rows, + claim + ) + return { updated, applied: updated } +} diff --git a/src/main/runtime/rpc/methods/terminal/unary-schemas.ts b/src/main/runtime/rpc/methods/terminal/unary-schemas.ts new file mode 100644 index 00000000000..f60b418ca05 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/unary-schemas.ts @@ -0,0 +1,203 @@ +import { z } from 'zod' +import { OptionalFiniteNumber, OptionalString, requiredString } from '../../schemas' +import { TERMINAL_PANE_SPLIT_SOURCES } from '../../../../../shared/feature-education-telemetry' +import { isTuiAgent } from '../../../../../shared/tui-agent-config' + +export const TerminalHandle = z.object({ + terminal: requiredString('Missing terminal handle') +}) + +export const TerminalFocus = TerminalHandle.extend({ + navigation: z.enum(['caller', 'host']).optional() +}) + +export const TerminalListParams = z.object({ + worktree: OptionalString, + limit: OptionalFiniteNumber, + handles: z + .array(requiredString('Missing terminal handle').pipe(z.string().max(256))) + .max(64) + .optional(), + requireFreshPtyLiveness: z.boolean().optional(), + // Why: layouts are ~31% of a large listing and only the human CLI formatter + // reads them. Absent means "include" so pre-flag clients keep rendering them. + includeVisualLayouts: z.boolean().optional() +}) + +export const TerminalResolveActive = z.object({ + worktree: OptionalString +}) + +export const TerminalResolvePane = z.object({ + paneKey: requiredString('Missing pane key'), + worktreeId: OptionalString +}) + +export const TerminalRecoverPane = z.object({ + paneKey: requiredString('Missing pane key'), + worktreeId: requiredString('Missing worktree ID'), + expectedTerminal: requiredString('Missing expected terminal handle').optional() +}) + +export const TerminalRead = TerminalHandle.extend({ + cursor: z + .unknown() + .transform((value) => { + if (value === undefined) { + return undefined + } + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) { + return Number.NaN + } + return value + }) + .pipe( + z + .number() + .optional() + .refine((v) => v === undefined || Number.isFinite(v), { + message: 'Cursor must be a non-negative integer' + }) + ) + .optional(), + limit: OptionalFiniteNumber, + // Why: optional so an older host that does not understand it simply drops the key and answers + // with its usual stream read; the response's `source` is what tells the caller which it got. + screen: z.literal(true).optional() +}).refine((params) => !(params.screen === true && params.cursor !== undefined), { + // Why: a cursor pages through accumulated output; a screen is the current frame with nothing + // behind it. Honoring both would answer with rendered lines carrying the stream's pagination + // metadata — two frames of reference in one payload, which is the confusion `source` exists to + // remove. The CLI already refuses the pair, but the RPC is reachable without it. + message: 'Cursor cannot be combined with a screen read' +}) + +// Why: preserve the legacy contract — `title: string | null` only, `undefined` rejected, so the CLI's "reset" signal stays distinct. +export const TerminalRename = TerminalHandle.extend({ + title: z.custom((value) => value === null || typeof value === 'string', { + message: 'Missing --title (pass empty string or null to reset)' + }) +}) + +export const TerminalSend = TerminalHandle.extend({ + text: OptionalString, + enter: z.unknown().optional(), + interrupt: z.unknown().optional(), + // Why: older hosts strip this optional intent and retain their direct-send behavior. + agentPrompt: z.literal(true).optional(), + resolvedLaunchDraft: z + .object({ + text: z.string(), + createdAt: z.number().finite() + }) + .optional(), + requireAgentStatus: z.enum(['sendable']).optional(), + // Why: terminal-generated replies are valid input but must not transfer the shared terminal floor. + inputKind: z.enum(['query-reply']).optional(), + // Why: identifies the caller for the driver state machine; when absent (older clients) the server falls back to the most recent mobile actor (docs/mobile-presence-lock.md). + client: z + .object({ + id: requiredString('Missing client ID'), + type: z.enum(['mobile', 'desktop']).default('desktop').optional() + }) + .optional(), + viewport: z + .object({ + cols: z.number().int().min(1).max(1000), + rows: z.number().int().min(1).max(500) + }) + .optional(), + claimViewport: z.literal(true).optional() +}) + +export const TerminalViewport = z.object({ + cols: z.number().int().min(1).max(1000), + rows: z.number().int().min(1).max(500) +}) + +export const TerminalWait = TerminalHandle.extend({ + for: z.custom<'exit' | 'tui-idle'>((value) => value === 'exit' || value === 'tui-idle', { + message: 'Invalid --for value. Supported: exit, tui-idle' + }), + timeoutMs: OptionalFiniteNumber +}) + +export const TerminalCreateParams = z.object({ + worktree: OptionalString, + clientMutationId: z.string().min(1).max(128).optional(), + reconcileExisting: z.boolean().optional(), + command: OptionalString, + startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(), + env: z.record(z.string(), z.string()).optional(), + envToDelete: z.array(z.string().min(1).max(256)).max(32).optional(), + launchConfig: z + .object({ + agentCommand: z.string().optional(), + agentArgs: z.string(), + agentEnv: z.record(z.string(), z.string()), + ompResumeFilePath: z + .string() + .min(1) + .max(32 * 1024) + .optional() + }) + .optional(), + resumeProviderSession: z + .object({ + key: z.enum(['session_id', 'conversation_id']), + id: z.string().min(1).max(512), + transcriptPath: z.string().min(1).max(32_768).optional() + }) + .optional(), + launchToken: OptionalString, + launchAgent: z.string().refine(isTuiAgent).optional(), + terminalColorQueryReplies: z + .object({ + foreground: z.string().max(128).optional(), + background: z.string().max(128).optional() + }) + .optional(), + title: OptionalString, + focus: z.unknown().optional(), + rendererBacked: z.unknown().optional(), + activate: z.unknown().optional(), + presentation: z.enum(['background', 'focused']).optional(), + tabId: OptionalString, + leafId: OptionalString +}) + +export const TerminalSplit = TerminalHandle.extend({ + direction: z + .unknown() + .transform((v) => (v === 'vertical' || v === 'horizontal' ? v : undefined)) + .pipe(z.union([z.enum(['vertical', 'horizontal']), z.undefined()])) + .optional(), + command: OptionalString, + env: z.record(z.string(), z.string()).optional(), + telemetrySource: z.enum(TERMINAL_PANE_SPLIT_SOURCES).optional() +}) + +export const TerminalStop = z.object({ + worktree: requiredString('Missing worktree selector') +}) + +export const TerminalSleep = TerminalStop + +export const TerminalStopExact = TerminalStop.extend({ + expectedPtyIds: z.array(requiredString('Missing PTY ID')).min(1), + keepHistory: z.boolean().optional(), + targetOnly: z.boolean().optional() +}) + +export const AgentTeamsTmuxCompat = z.object({ + teamId: requiredString('Missing agent team ID'), + token: requiredString('Missing agent team token'), + envPane: requiredString('Missing tmux pane identity'), + cwd: OptionalString, + argv: z.array(z.string()) +}) + +export const AgentTeamsPrepareLaunch = z.object({ + paneKey: requiredString('Missing pane key'), + env: z.record(z.string(), z.string()).optional() +}) diff --git a/src/main/runtime/rpc/methods/terminal/viewport-schemas.ts b/src/main/runtime/rpc/methods/terminal/viewport-schemas.ts new file mode 100644 index 00000000000..d9b76d66ea1 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/viewport-schemas.ts @@ -0,0 +1,51 @@ +import { z } from 'zod' +import { requiredString } from '../../schemas' + +const TerminalHandle = z.object({ terminal: requiredString('Missing terminal handle') }) + +export const TerminalSetDisplayMode = TerminalHandle.extend({ + // Why: 'auto' = mobile drives dims while subscribed (desktop restores on last-leave); 'desktop' = no resize, mobile scales to fit. + mode: z.enum(['auto', 'desktop']), + // Why: identifies the caller for the driver state machine; optional for older mobile clients. + client: z + .object({ + id: requiredString('Missing client ID'), + type: z.enum(['mobile', 'desktop']).default('desktop').optional() + }) + .optional(), + // Why: carries the measured viewport so an 'auto' toggle on a viewport-less record can phone-fit instead of no-op'ing. + viewport: z + .object({ + cols: z.number().int().positive(), + rows: z.number().int().positive() + }) + .optional() +}) + +export const TerminalUnsubscribe = z.object({ + subscriptionId: requiredString('Missing subscription ID'), + // Why: lets the server rebuild the composite `${terminal}:${clientId}` cleanup key when older clients pass a bare subscriptionId (docs/mobile-presence-lock.md). + client: z + .object({ + id: requiredString('Missing client ID') + }) + .optional() +}) + +// Why: in-place update avoids an unsubscribe→resubscribe that flashed the lock banner and stranded the PTY at phone dims (docs/mobile-presence-lock.md). +export const TerminalUpdateViewport = TerminalHandle.extend({ + client: z.object({ + id: requiredString('Missing client ID'), + type: z.enum(['mobile', 'desktop']).default('mobile').optional() + }), + viewport: z.object({ + cols: z.number().int().min(20).max(240), + rows: z.number().int().min(8).max(120) + }), + claim: z.boolean().optional() +}) + +// Why: phone-fit auto-restore preference (docs/mobile-fit-hold.md); `null` = Indefinite, finite ms clamped to [5_000, 60min] server-side. +export const TerminalSetAutoRestoreFit = z.object({ + ms: z.number().nullable() +}) diff --git a/src/main/runtime/rpc/methods/worktree-rm-host-qualification.test.ts b/src/main/runtime/rpc/methods/worktree-rm-host-qualification.test.ts index d394caa30cd..68f8b040479 100644 --- a/src/main/runtime/rpc/methods/worktree-rm-host-qualification.test.ts +++ b/src/main/runtime/rpc/methods/worktree-rm-host-qualification.test.ts @@ -4,14 +4,18 @@ import type { RpcRequest } from '../core' import { RpcDispatcher } from '../dispatcher' import { WORKTREE_METHODS } from './worktree' -function makeRuntime(): OrcaRuntimeService { +function makeRuntime(repoHostIds: (string | undefined)[] = ['local']): OrcaRuntimeService { return { getRuntimeId: () => 'test-runtime', + listRepos: () => + repoHostIds.map((executionHostId) => ({ id: 'repo-1', path: '/repo', executionHostId })), showManagedWorktree: vi.fn().mockResolvedValue({ id: 'wt-1', hostId: 'local' }), removeManagedWorktree: vi.fn().mockResolvedValue({}) } as unknown as OrcaRuntimeService } +const WORKTREE_ID = 'repo-1::/repo/wt' + function makeRequest(params: unknown): RpcRequest { return { id: 'req-1', authToken: 'tok', method: 'worktree.rm', params } } @@ -35,6 +39,93 @@ describe('worktree.rm host qualification', () => { expect(response).toMatchObject({ ok: true, result: { removed: true } }) }) + it("resolves a paired client's own name for this host to our spelling", async () => { + const runtime = makeRuntime(['local']) + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest({ + worktree: `id:${WORKTREE_ID}`, + hostId: 'runtime:env-1', + force: true, + runHooks: false + }) + ) + + expect(runtime.removeManagedWorktree).toHaveBeenCalledWith( + `id:${WORKTREE_ID}`, + true, + false, + false, + 'local' + ) + expect(response).toMatchObject({ ok: true, result: { removed: true } }) + }) + + it('keeps a client-minted stamp this store still uses for its own repo', async () => { + const runtime = makeRuntime(['runtime:env-1']) + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + await dispatcher.dispatch( + makeRequest({ + worktree: `id:${WORKTREE_ID}`, + hostId: 'runtime:env-1', + force: true, + runHooks: false + }) + ) + + expect(runtime.removeManagedWorktree).toHaveBeenCalledWith( + `id:${WORKTREE_ID}`, + true, + false, + false, + 'runtime:env-1' + ) + }) + + it('fails closed when the repo id carries both spellings', async () => { + const runtime = makeRuntime(['local', 'runtime:env-1']) + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest({ + worktree: `id:${WORKTREE_ID}`, + hostId: 'runtime:env-1', + force: true, + runHooks: false + }) + ) + + expect(response).toMatchObject({ + ok: false, + error: { message: expect.stringContaining('ambiguous across hosts') } + }) + expect(runtime.removeManagedWorktree).not.toHaveBeenCalled() + }) + + it('leaves an ssh qualifier alone, since we do proxy those onward', async () => { + const runtime = makeRuntime(['ssh:target-a']) + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + await dispatcher.dispatch( + makeRequest({ + worktree: `id:${WORKTREE_ID}`, + hostId: 'ssh:target-a', + force: true, + runHooks: false + }) + ) + + expect(runtime.removeManagedWorktree).toHaveBeenCalledWith( + `id:${WORKTREE_ID}`, + true, + false, + false, + 'ssh:target-a' + ) + }) + it.each([['bogus'], ['ssh:'], ['runtime:'], [''], [null], [42]])( 'fails closed when an explicit host id is malformed: %j', async (hostId) => { diff --git a/src/main/runtime/rpc/methods/worktree-schemas.ts b/src/main/runtime/rpc/methods/worktree-schemas.ts index ce02c032daf..53419d80e18 100644 --- a/src/main/runtime/rpc/methods/worktree-schemas.ts +++ b/src/main/runtime/rpc/methods/worktree-schemas.ts @@ -174,7 +174,7 @@ export const WorktreeRemove = WorktreeSelector.extend({ }) export const WorktreeForceDeleteBranch = WorktreeSelector.extend({ - hostId: OptionalString, + hostId: OptionalExecutionHostId, branchName: z .unknown() .transform((v) => (typeof v === 'string' ? v : '')) diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 7503d65d623..080d68a71a6 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -6,6 +6,7 @@ import { import { buildCliWorkspaceProvenance } from '../../../../shared/cli-workspace-provenance' import { defineMethod, type RpcMethod } from '../core' import { buildManagedWorktreeCreateArgs } from './worktree-create-args' +import { resolvePairedCallerHostId } from './paired-caller-host-id' import { resolveRuntimeNavigationTarget } from '../../../../shared/runtime-navigation' import { resolveRpcWorkspaceCreatorProvenance } from '../workspace-creator-context' import { WorktreeCreate, WorktreePrefetchCreateBase } from './worktree-create-schemas' @@ -204,10 +205,15 @@ export const WORKTREE_METHODS: RpcMethod[] = [ name: 'worktree.rm', params: WorktreeRemove, handler: async (params, { runtime }) => { + // Translate a paired client's runtime-local host spelling before host-qualified reads. + let resolvedHostId = resolvePairedCallerHostId( + () => runtime.listRepos(), + params.worktree, + params.hostId + ) // Older mobile clients omit hostId, so resolve through the ambiguity gate // before pinning removal. An ambiguous selector still fails closed: two // hosts own the id and an unqualified client cannot say which it meant. - let resolvedHostId = params.hostId if (!resolvedHostId) { try { resolvedHostId = (await runtime.showManagedWorktree(params.worktree)).hostId @@ -237,18 +243,24 @@ export const WORKTREE_METHODS: RpcMethod[] = [ defineMethod({ name: 'worktree.forceDeleteBranch', params: WorktreeForceDeleteBranch, - handler: async (params, { runtime }) => - params.hostId + handler: async (params, { runtime }) => { + const hostId = resolvePairedCallerHostId( + () => runtime.listRepos(), + params.worktree, + params.hostId + ) + return hostId ? runtime.forceDeletePreservedBranch( params.worktree, params.branchName, params.expectedHead, - params.hostId + hostId ) : runtime.forceDeletePreservedBranch( params.worktree, params.branchName, params.expectedHead ) + } }) ] diff --git a/src/main/ssh/ssh-connection-sftp-namespace.test.ts b/src/main/ssh/ssh-connection-sftp-namespace.test.ts index e79f16d65e0..dd568e4032c 100644 --- a/src/main/ssh/ssh-connection-sftp-namespace.test.ts +++ b/src/main/ssh/ssh-connection-sftp-namespace.test.ts @@ -296,25 +296,33 @@ describe('SshConnection SFTP namespace resolution', () => { }) it('aborts a transfer stuck in discovery and reports a confirmed channel close', async () => { - const sftp = createFakeSftp({ pendingRealpath: true }) - const conn = connectedTo([sftp]) - const controller = new AbortController() + vi.useFakeTimers() + try { + const sftp = createFakeSftp({ pendingRealpath: true }) + const conn = connectedTo([sftp]) + const controller = new AbortController() - const write = conn.writeFile(`${SHELL_RELAY_DIR}/.version`, 'v', { - hostPlatform: linux, - sftpNamespace: fileNamespace('.version'), - signal: controller.signal - }) - await vi.waitFor(() => expect(sftp.realpathCalls).toHaveLength(1)) - controller.abort() + const write = conn + .writeFile(`${SHELL_RELAY_DIR}/.version`, 'v', { + hostPlatform: linux, + sftpNamespace: fileNamespace('.version'), + signal: controller.signal + }) + .catch((err: Error) => err) + await vi.waitFor(() => expect(sftp.realpathCalls).toHaveLength(1)) + controller.abort() + await vi.advanceTimersByTimeAsync(5_000) - const error = (await write.catch((err: Error) => err)) as Error & { - sshChannelCloseConfirmed?: boolean + const error = (await write) as Error & { + sshChannelCloseConfirmed?: boolean + } + expect(error.name).toBe('AbortError') + expect(error.sshChannelCloseConfirmed).toBe(true) + expect(sftp.endCalls).toBe(1) + expect(sftp.writtenPaths).toEqual([]) + } finally { + vi.useRealTimers() } - expect(error.name).toBe('AbortError') - expect(error.sshChannelCloseConfirmed).toBe(true) - expect(sftp.endCalls).toBe(1) - expect(sftp.writtenPaths).toEqual([]) }) it('reports an unconfirmed close when the aborted session never closes', async () => { @@ -389,7 +397,9 @@ describe('SshConnection SFTP namespace resolution', () => { }) .catch((err: Error) => err) await vi.waitFor(() => expect(sftp.pendingRealpathCallbacks).toHaveLength(1)) + vi.useFakeTimers() controller.abort() + await vi.advanceTimersByTimeAsync(5_000) const error = (await write) as Error & { sshChannelCloseConfirmed?: boolean } expect(error).toMatchObject({ @@ -398,13 +408,15 @@ describe('SshConnection SFTP namespace resolution', () => { }) completeRealpath(sftp.pendingRealpathCallbacks[0]!) - await new Promise((resolve) => setImmediate(resolve)) + await Promise.resolve() + await Promise.resolve() expect(unhandledRejection).not.toHaveBeenCalled() expect(sftp.writtenPaths).toEqual([]) expect(sftp.endCalls).toBe(1) } finally { process.off('unhandledRejection', unhandledRejection) + vi.useRealTimers() } } ) diff --git a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts index 2b8fc30c817..89c38a6003f 100644 --- a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts +++ b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts @@ -50,6 +50,7 @@ type CapturedStatus = { connectionId: string | null payload: { state: string + workingMode?: 'monitoring' prompt: string agentType?: string toolName?: string @@ -184,6 +185,7 @@ function captureAgentStatuses(events: CapturedStatus[]): void { connectionId: event.connectionId, payload: { state: event.payload.state, + ...(event.payload.workingMode ? { workingMode: event.payload.workingMode } : {}), prompt: event.payload.prompt, agentType: event.payload.agentType, toolName: event.payload.toolName @@ -298,6 +300,42 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { }) }) + it('preserves Claude monitoring mode across the SSH relay boundary', async () => { + relay = createFakeRelay() + vi.mocked(deployAndLaunchRelay).mockResolvedValue({ + transport: relay.transport, + serverBuildId: 'test-relay-build', + platform: 'linux-x64' + }) + const events: CapturedStatus[] = [] + captureAgentStatuses(events) + session = createSession('conn-monitoring') + await session.establish({} as SshConnection) + + relay.notifyAgentHook( + makeEnvelope({ + source: 'claude', + claudeRunningNonAgentTask: true, + payload: { + state: 'working', + workingMode: 'monitoring', + prompt: 'watch the build', + agentType: 'claude' + } + }) + ) + + await waitForStatusCount(events, 1) + expect(events[0]).toMatchObject({ + connectionId: 'conn-monitoring', + payload: { state: 'working', workingMode: 'monitoring', agentType: 'claude' } + }) + expect(agentHookServer.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + workingMode: 'monitoring' + }) + }) + it('stamps SSH ownership and settles only the exact manual compact identity', async () => { relay = createFakeRelay() vi.mocked(deployAndLaunchRelay).mockResolvedValue({ @@ -330,17 +368,23 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { }) relay.notifyAgentHook(compactEnvelope('UserPromptSubmit', 'working')) + await waitForStatusCount(events, 1) + + // Why: PreCompact fires before the compact is validated, so it may never move the pane — + // over SSH just as locally. relay.notifyAgentHook(compactEnvelope('PreCompact', 'working')) - await waitForStatusCount(events, 2) + await new Promise((resolve) => setImmediate(resolve)) + expect(events).toHaveLength(1) + relay.notifyAgentHook({ ...compactEnvelope('PostCompact', 'done'), providerPromptId: undefined }) await new Promise((resolve) => setImmediate(resolve)) - expect(events).toHaveLength(2) + expect(events).toHaveLength(1) relay.notifyAgentHook(compactEnvelope('PostCompact', 'done')) - await waitForStatusCount(events, 3) + await waitForStatusCount(events, 2) expect(events.at(-1)).toMatchObject({ connectionId: 'conn-compact', @@ -351,13 +395,15 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { ).toMatchObject({ source: 'claude', providerPromptId: COMPACT_PROMPT_ID, - compactTrigger: undefined, - connectionId: 'conn-compact' + connectionId: 'conn-compact', + // Why: a compact that finished must not read as a completed turn to notification and + // automation consumers, over SSH just as locally. + payload: { sessionBoundary: true } }) relay.notifyAgentHook(compactEnvelope('PostCompact', 'done')) await new Promise((resolve) => setImmediate(resolve)) - expect(events).toHaveLength(3) + expect(events).toHaveLength(2) }) it('clears stamped status on reconnect loss but not final shutdown', async () => { diff --git a/src/main/startup/hydrate-shell-path.test.ts b/src/main/startup/hydrate-shell-path.test.ts index f096dd03ed3..60c8dc0bd17 100644 --- a/src/main/startup/hydrate-shell-path.test.ts +++ b/src/main/startup/hydrate-shell-path.test.ts @@ -6,6 +6,7 @@ import { _resetHydrateShellPathCache, hydrateShellPath, mergePathSegments, + _setLaunchPathForTests, type HydrationResult } from './hydrate-shell-path' @@ -39,6 +40,7 @@ describe('hydrateShellPath', () => { }) afterEach(() => { + vi.restoreAllMocks() if (originalPath === undefined) { delete process.env.PATH } else { @@ -177,6 +179,57 @@ describe('hydrateShellPath', () => { vi.useRealTimers() } }) + + async function captureProbeEnv(shell = '/bin/bash'): Promise { + const proc = createMockShellProcess() + spawnMock.mockReturnValue(proc) + const resultPromise = hydrateShellPath({ shellOverride: shell, force: true }) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) + proc.emit('close', 0) + await resultPromise + return spawnMock.mock.calls[0][2].env + } + + it('probes with the launch PATH so seeded fallbacks cannot pin nvm', async () => { + process.env.PATH = `/home/u/.nvm/versions/node/v26.7.0/bin${delimiter}/usr/bin` + _setLaunchPathForTests('/usr/bin') + + expect((await captureProbeEnv()).PATH).toBe('/usr/bin') + }) + + it('uses the PATH captured at module load, not a later mutation of process.env', async () => { + // Why: this is the anti-regression guard. patchPackagedProcessPath mutates + // process.env.PATH after this module is evaluated; the probe must not see it. + const seeded = `/seeded/newest-nvm/bin${delimiter}/usr/bin` + process.env.PATH = seeded + + expect((await captureProbeEnv()).PATH).not.toBe(seeded) + }) + + it('sets a probe marker so rc files can take a fast path', async () => { + expect((await captureProbeEnv()).ORCA_SHELL_PATH_PROBE).toBe('1') + }) + + it('overwrites the captured key in place so Windows never carries both Path and PATH', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + const originalWindowsPath = process.env.Path + delete process.env.PATH + process.env.PATH = 'C:\\Users\\u\\.nvm\\versions\\node\\v22.9.0\\bin;C:\\Windows' + process.env.Path = 'C:\\Users\\u\\.nvm\\versions\\node\\v22.9.0\\bin;C:\\Windows' + _setLaunchPathForTests('C:\\Windows', 'Path') + + try { + const env = await captureProbeEnv('C:/Git/bin/bash.exe') + expect(env.Path).toBe('C:\\Windows') + expect(env.PATH).toBeUndefined() + } finally { + if (originalWindowsPath === undefined) { + delete process.env.Path + } else { + process.env.Path = originalWindowsPath + } + } + }) }) describe('mergePathSegments', () => { diff --git a/src/main/startup/hydrate-shell-path.ts b/src/main/startup/hydrate-shell-path.ts index db41fe3f462..3deeb30a738 100644 --- a/src/main/startup/hydrate-shell-path.ts +++ b/src/main/startup/hydrate-shell-path.ts @@ -32,6 +32,23 @@ export type HydrationResult = let cached: Promise | null = null let probeQueue = Promise.resolve() +// Why: patchPackagedProcessPath seeds a version manager's newest install dir +// ahead of PATH. nvm's startup `use` honors whatever node is already on PATH over +// the user's `default` alias, so a probe inheriting that seed comes back pinned to +// the newest install and every terminal loses the global CLIs installed under +// `default`. Snapshot PATH here instead: module init runs while the import graph is +// evaluated, strictly before any statement in main's body, so it cannot observe the +// seeds — and unlike an explicit hand-off from the seeding site, there is no call +// ordering left for a later refactor to break. Windows keys it `Path`, and a Git Bash +// user whose nvm uses the POSIX ~/.nvm/versions/node layout is seeded there too, so +// capture the key alongside the value. +const LAUNCH_PATH_KEY = + process.platform === 'win32' && process.env.Path !== undefined ? 'Path' : 'PATH' +const LAUNCH_PATH = process.env[LAUNCH_PATH_KEY] ?? null +// Why: rc files that exec into a multiplexer or start a heavy prompt can outrun the +// probe budget. This lets them detect the probe and take a fast path. +const PROBE_MARKER_ENV_VAR = 'ORCA_SHELL_PATH_PROBE' +let launchPathOverride: { key: string; value: string } | null = null let configuredWindowsShell = 'powershell.exe' let configuredWindowsGitBashPath: string | null = null let configuredWindowsFallbackShell: string | null = null @@ -42,6 +59,7 @@ const windowsPathOwnership = new WindowsShellPathOwnership() export function _resetHydrateShellPathCache(): void { cached = null probeQueue = Promise.resolve() + launchPathOverride = null configuredWindowsShell = 'powershell.exe' configuredWindowsGitBashPath = null configuredWindowsFallbackShell = null @@ -94,6 +112,60 @@ function parseCapturedPath(stdout: string, pathDelimiter: string = delimiter): s ] } +/** @internal - tests need a launch PATH the runner's own environment cannot supply. */ +export function _setLaunchPathForTests(value: string, key = 'PATH'): void { + launchPathOverride = { key, value } +} + +function isLaunchPathKey(name: string, key: string): boolean { + return process.platform === 'win32' ? name.toLowerCase() === key.toLowerCase() : name === key +} + +function applyLaunchPath(env: NodeJS.ProcessEnv, key: string, value: string | null): void { + if (value === null) { + return + } + // Why: Windows resolves env names case-insensitively even though a spread is + // a plain object, so never leave both the captured key and another casing. + for (const name of Object.keys(env)) { + if (name !== key && isLaunchPathKey(name, key)) { + delete env[name] + } + } + env[key] = value +} + +function shellProbeEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env, [PROBE_MARKER_ENV_VAR]: '1' } + const key = launchPathOverride?.key ?? LAUNCH_PATH_KEY + const value = launchPathOverride?.value ?? LAUNCH_PATH + applyLaunchPath(env, key, value) + return env +} + +/** Run a synchronous launcher without passing Orca's seeded PATH to its child. */ +export function runWithLaunchPath(action: () => T): T { + const key = launchPathOverride?.key ?? LAUNCH_PATH_KEY + const value = launchPathOverride?.value ?? LAUNCH_PATH + if (value === null) { + return action() + } + const previous = Object.entries(process.env).filter(([name]) => isLaunchPathKey(name, key)) + applyLaunchPath(process.env, key, value) + try { + return action() + } finally { + for (const name of Object.keys(process.env)) { + if (isLaunchPathKey(name, key)) { + delete process.env[name] + } + } + for (const [name, previousValue] of previous) { + process.env[name] = previousValue + } + } +} + function shellPathProbe(shell: string): { args: string[]; pathDelimiter: string } { if (process.platform !== 'win32') { const command = `printf '%s' '${DELIMITER}'; printf '%s' "$PATH"; printf '%s' '${DELIMITER}'` @@ -125,7 +197,7 @@ function spawnShellAndReadPath(shell: string): Promise { // it layer its own rc files on top. Do NOT forward stdio — some shells // (oh-my-zsh setups, powerlevel10k) print a lot to stderr on startup, // and we don't want that in Orca's console. - env: process.env, + env: shellProbeEnv(), stdio: ['ignore', 'pipe', 'ignore'], detached: false, windowsHide: true diff --git a/src/main/text-generation/commit-message-text-generation.ts b/src/main/text-generation/commit-message-text-generation.ts index e4077b3bddf..67785adc6bc 100644 --- a/src/main/text-generation/commit-message-text-generation.ts +++ b/src/main/text-generation/commit-message-text-generation.ts @@ -19,7 +19,8 @@ import { } from '../../shared/pull-request-generation' import { cleanGeneratedCommitMessage, - excerptAgentFailureOutput + excerptAgentFailureOutput, + sanitizeAgentFailureDetail } from '../../shared/commit-message-prompt' import { captureAgentGenerationFailureOutput, @@ -196,40 +197,6 @@ function formatAgentCliFailureMessage( : withMacTailscaleDnsHint(message, detail) } -function sanitizeAgentFailureDetail(detail: string | null): string | null { - // Cf covers bidi overrides (U+202E etc.) that could visually reorder the - // persisted, client-synced detail. - const trimmed = detail - ?.replace(/[\p{Cc}\p{Cf}]+/gu, ' ') - .replace(/\s+/g, ' ') - .trim() - if (!trimmed) { - return null - } - // Why: agent stderr often includes local or SSH repo paths. Persisting those - // into worktree metadata leaks environment details into synced renderer state. - const redacted = trimmed - .replace( - /\\\\[^\s"'`<>\\]+\\(?:[^\s"'`<>\\]+(?:\s+[^\s"'`<>\\]+)*(?=\\)\\)*[^\s"'`<>\\]+/g, - '[path]' - ) - // Only backslashes may repeat: JSON provider bodies double them - // (`C:\\Users\\name\\…`), while a URL's `://` must stay single so remedy - // links like `https://…` survive redaction. - .replace( - /[A-Za-z]:(?:\\+|\/)(?:[^\s"'`<>\\/|:*?]+(?:\s+[^\s"'`<>\\/|:*?]+)*(?=[\\/])(?:\\+|\/))*[^\s"'`<>\\/|:*?]+/g, - '[path]' - ) - // Why: require ≥2 segments (one internal `/`) so provider remedy tokens like - // `/login` survive while multi-segment paths (`/Users/name/repo`) still redact. - // `=:,` prefixes catch key=/path value:/path list,/path shapes in provider bodies. - .replace( - /(^|[\s"'`(=:,])\/(?:[^\s"'`<>/]+(?:\s+[^\s"'`<>/]+)*(?=\/)\/)+[^\s"'`<>/]+/g, - '$1[path]' - ) - return redacted.length > 240 ? `${redacted.slice(0, 240).trimEnd()}...` : redacted -} - function userFacingUnsafeWindowsBatchArgs(label: string): string { return `${label} cannot be run as a Windows batch command with the prompt in argv. Remove {prompt} so Orca sends the prompt on stdin.` } diff --git a/src/main/updater.quit-and-install.test.ts b/src/main/updater.quit-and-install.test.ts index 2ec23352bf2..945538b3895 100644 --- a/src/main/updater.quit-and-install.test.ts +++ b/src/main/updater.quit-and-install.test.ts @@ -12,6 +12,10 @@ const { resetUpdaterMocks } = await vi.hoisted(async () => (await import('./updater-test-harness')).createUpdaterMocks()) +const { launchPathScope } = vi.hoisted(() => ({ + launchPathScope: { active: false, calls: 0 } +})) + vi.mock('electron', () => moduleFactories.electron()) vi.mock('electron-updater', () => moduleFactories.electronUpdater()) vi.mock('./electron-updater-loader', () => moduleFactories.electronUpdaterLoader()) @@ -25,10 +29,23 @@ vi.mock('./update-install-exit-watchdog', () => moduleFactories.updateInstallExi vi.mock('./updater-prerelease-feed', () => moduleFactories.updaterPrereleaseFeed()) vi.mock('./local-builds/local-build-switch', () => moduleFactories.localBuildSwitch()) vi.mock('./local-builds/local-build-feed-server', () => moduleFactories.localBuildFeedServer()) +vi.mock('./startup/hydrate-shell-path', () => ({ + runWithLaunchPath: (action: () => unknown): unknown => { + launchPathScope.active = true + launchPathScope.calls += 1 + try { + return action() + } finally { + launchPathScope.active = false + } + } +})) describe('updater', () => { beforeEach(() => { resetUpdaterMocks() + launchPathScope.active = false + launchPathScope.calls = 0 }) it('still surfaces updater error events while a download is in flight', async () => { @@ -118,8 +135,12 @@ describe('updater', () => { expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(2) }) - it('defers quitAndInstall through the shared main-process entrypoint', async () => { + it('defers quitAndInstall and runs its launcher within the launch PATH scope', async () => { vi.useFakeTimers() + let launcherSawLaunchPathScope = false + autoUpdaterMock.quitAndInstall.mockImplementation(() => { + launcherSawLaunchPathScope = launchPathScope.active + }) const mainWindow = { webContents: { send: vi.fn() } } const { setupAutoUpdater, quitAndInstall } = await import('./updater') @@ -135,6 +156,9 @@ describe('updater', () => { await vi.advanceTimersByTimeAsync(1) expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledWith(false, true) + expect(launcherSawLaunchPathScope).toBe(true) + expect(launchPathScope.calls).toBe(1) + expect(launchPathScope.active).toBe(false) }) it('runs pre-quit cleanup before local PTY cleanup during update install', async () => { diff --git a/src/main/updater.ts b/src/main/updater.ts index 640906197c8..912c681cfad 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -21,6 +21,7 @@ import { killAllPty } from './ipc/pty' import { withUpdaterSpan } from './observability/instrumentation' import { loadElectronAutoUpdater, type ElectronAutoUpdater } from './electron-updater-loader' import { writeMainThreadDiagnosticMarker } from './diagnostics/main-thread-churn-probe' +import { runWithLaunchPath } from './startup/hydrate-shell-path' import { beginMacUpdateDownload, deferMacQuitUntilInstallerReady, @@ -788,7 +789,9 @@ async function performQuitAndInstall(): Promise { // Why: BaseUpdater logs child stderr but drops it from the 'error' event, so retain it for the span of this call. beginLinuxPackageInstallDiagnosticCapture(getTrackedLinuxPackageArtifact()?.path ?? null) try { - getAutoUpdater().quitAndInstall(supervisorOwnsRelaunch, !supervisorOwnsRelaunch) + runWithLaunchPath(() => + getAutoUpdater().quitAndInstall(supervisorOwnsRelaunch, !supervisorOwnsRelaunch) + ) } finally { const diagnostic = endLinuxPackageInstallDiagnosticCapture() // Why: a synchronous 'error' already consumed and reset this attempt; re-stashing would leak it into the next one. diff --git a/src/main/win32-utils.test.ts b/src/main/win32-utils.test.ts index 381fef6643e..4fa9436e19e 100644 --- a/src/main/win32-utils.test.ts +++ b/src/main/win32-utils.test.ts @@ -6,9 +6,11 @@ import { getCmdExePath, getRegExePath, getSpawnArgsForWindows, + wrapWindowsStartWait, isPermissionError, isWindowsBatchScript, resolveWindowsCommand, + UnsafeWindowsBatchArgumentsError, WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL } from './win32-utils' @@ -119,6 +121,46 @@ describe('getSpawnArgsForWindows', () => { }) }) + it('wraps an interactive login in start /wait with an empty title argv', () => { + withPlatform('win32', () => { + const { spawnCmd, spawnArgs } = wrapWindowsStartWait(getCmdExePath(), [ + '/d', + '/c', + 'C:\\Tools\\claude.cmd', + 'auth', + 'login', + '--claudeai' + ]) + expect(spawnCmd).toBe(getCmdExePath()) + expect(spawnArgs).toEqual([ + '/d', + '/c', + 'start', + '', + '/wait', + getCmdExePath(), + '/d', + '/c', + 'C:\\Tools\\claude.cmd', + 'auth', + 'login', + '--claudeai' + ]) + expect(spawnArgs[3]).toBe('') + expect(spawnArgs).not.toContain('/B') + expect(spawnArgs).not.toContain('""') + }) + }) + + it('rejects cmd metacharacters in executable paths passed through start /wait', () => { + expect(() => wrapWindowsStartWait('C:\\Users\\A%B\\codex.exe', ['login'])).toThrow( + UnsafeWindowsBatchArgumentsError + ) + expect(() => wrapWindowsStartWait('C:\\Tools\\codex.exe', ['log&in'])).toThrow( + UnsafeWindowsBatchArgumentsError + ) + }) + it('leaves .exe GUI launches alone even when detachedGui is requested', () => { withPlatform('win32', () => { const { spawnCmd, spawnArgs } = getSpawnArgsForWindows( diff --git a/src/main/win32-utils.ts b/src/main/win32-utils.ts index 151dc9306f0..5798f22be5a 100644 --- a/src/main/win32-utils.ts +++ b/src/main/win32-utils.ts @@ -5,6 +5,7 @@ import { existsSync } from 'node:fs' export { getCmdExePath, getSpawnArgsForWindows, + wrapWindowsStartWait, isWindowsBatchScript, WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR, WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL, diff --git a/src/main/window/createMainWindow-terminal-focus-shortcuts.test.ts b/src/main/window/createMainWindow-terminal-focus-shortcuts.test.ts index ba87f891737..9b88c60800c 100644 --- a/src/main/window/createMainWindow-terminal-focus-shortcuts.test.ts +++ b/src/main/window/createMainWindow-terminal-focus-shortcuts.test.ts @@ -341,6 +341,64 @@ describe('createMainWindow', () => { expect(webContents.send).not.toHaveBeenCalled() }) + it('suppresses auto-repeat workspace deletes from before-input-event', () => { + const windowHandlers: Record void> = {} + const webContents = { + on: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn(), + isDevToolsOpened: vi.fn(), + openDevTools: vi.fn(), + closeDevTools: vi.fn() + } + const browserWindowInstance = { + webContents, + on: vi.fn(), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => true), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(), + loadURL: vi.fn() + } + browserWindowMock.mockImplementation(function () { + return browserWindowInstance + }) + + createMainWindow(null) + + const isDarwin = process.platform === 'darwin' + const input = { + type: 'keyDown', + code: 'Backspace', + key: 'Backspace', + meta: isDarwin, + control: !isDarwin, + alt: false, + shift: true + } + windowHandlers['before-input-event']({ preventDefault: vi.fn() } as never, input) + expect(webContents.send).toHaveBeenCalledWith('ui:deleteCurrentWorkspace') + + webContents.send.mockClear() + const repeatPreventDefault = vi.fn() + windowHandlers['before-input-event']({ preventDefault: repeatPreventDefault } as never, { + ...input, + isAutoRepeat: true + }) + + expect(repeatPreventDefault).toHaveBeenCalledOnce() + expect(webContents.send).not.toHaveBeenCalled() + }) + it('lets Terminal-first pass risky app shortcuts through when terminal input is focused', () => { const windowHandlers: Record void> = {} const webContents = { @@ -624,7 +682,7 @@ describe('createMainWindow', () => { expect(webContents.send).toHaveBeenNthCalledWith(2, 'ui:openQuickOpen') }) - it('forwards the configured workspace delete shortcut while terminal input is focused', () => { + it('forwards the default workspace delete shortcut while terminal input is focused', () => { const windowHandlers: Record void> = {} const webContents = { on: vi.fn((event, handler) => { @@ -662,7 +720,7 @@ describe('createMainWindow', () => { getSettings: () => ({ terminalShortcutPolicy: 'terminal-first' }) } as never, { - getKeybindings: () => ({ 'workspace.delete': ['Mod+Shift+Backspace'] }) + getKeybindings: () => ({}) } ) diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 07e24b452b9..dac90bec357 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -811,7 +811,10 @@ export function createMainWindow( return true } - if (action.type === 'toggleQuickCommandsMenu' && isAutoRepeat) { + if ( + (action.type === 'toggleQuickCommandsMenu' || action.type === 'deleteCurrentWorkspace') && + isAutoRepeat + ) { event.preventDefault() return true } diff --git a/src/preload/api/agent-status-api.ts b/src/preload/api/agent-status-api.ts index 900b3ea4490..fd6da88e351 100644 --- a/src/preload/api/agent-status-api.ts +++ b/src/preload/api/agent-status-api.ts @@ -30,6 +30,9 @@ export type AgentStatusApi = { getMigrationUnsupportedSnapshot: () => Promise /** Drop a paneKey from the main-process hook cache and on-disk last-status file. Fire-and-forget. */ drop: (paneKey: string) => void + /** Retire a pane whose agent process is proven gone — clears the row AND the per-pane caches a + * dismissal deliberately keeps. Not `drop`: that one is a user dismissal of a live pane's row. */ + reconcileEndedProcess: (paneKey: string) => void /** Drop every cached hook status under one terminal tab prefix. Fire-and-forget. */ dropByTabPrefix: (tabId: string) => void /** Permanently retire one pane's hook authority while siblings stay live. */ diff --git a/src/preload/index.ts b/src/preload/index.ts index b8a00c6d647..4c30124a6da 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -5032,6 +5032,9 @@ const api = { drop: (paneKey: string): void => { ipcRenderer.send('agentStatus:drop', paneKey) }, + reconcileEndedProcess: (paneKey: string): void => { + ipcRenderer.send('agentStatus:reconcileEndedProcess', paneKey) + }, /** Drop all cached hook statuses under one terminal tab prefix; fired on explicit tab close even without a local row. */ dropByTabPrefix: (tabId: string): void => { ipcRenderer.send('agentStatus:dropByTabPrefix', tabId) diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index 3458814ed5b..f80de335e43 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -76,7 +76,7 @@ describe('RelayAgentHookServer', () => { } }) - it('forwards Claude background-work evidence with the normalized status', async () => { + it('forwards Claude background monitoring until its authoritative inventory drains', async () => { const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() const server = new RelayAgentHookServer({ endpointDir: dir, forward }) await server.start() @@ -99,7 +99,40 @@ describe('RelayAgentHookServer', () => { expect(forward.mock.calls[0][0]).toMatchObject({ claudeRunningNonAgentTask: true, - payload: { state: 'working', agentType: 'claude' } + payload: { state: 'working', workingMode: 'monitoring', agentType: 'claude' } + }) + + await fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + payload: { + hook_event_name: 'UserPromptSubmit', + prompt: + 'shell-1completed' + } + }) + }) + await fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + payload: { hook_event_name: 'Stop', background_tasks: [], session_crons: [] } + }) + }) + + expect(forward).toHaveBeenCalledTimes(3) + expect(forward.mock.calls[2][0]).toMatchObject({ + claudeRunningNonAgentTask: false, + payload: { state: 'done', agentType: 'claude' } }) } finally { server.stop() diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index 7dc54d820d5..fb37dc64e3d 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -16,7 +16,6 @@ import { HOOK_REQUEST_SLOWLORIS_MS, normalizeHookPayload, readRequestBody, - resolveCachedClaudeCompactOwnership, resolveHookSource, writeEndpointFile, type AgentHookEventPayload, @@ -249,8 +248,7 @@ export class RelayAgentHookServer { return } const event = normalizeHookPayload(this.state, source, body, this.env, { - allowUnanchoredPreCompact: true, - allowUnanchoredPostCompact: true + deferCompactOwnershipToClient: true }) if (event) { // TODO: once normalizeHookPayload returns validated env/version, drop bodyEnv/bodyVersion and source them from the listener result. @@ -286,8 +284,10 @@ export class RelayAgentHookServer { if (event.payload.state !== 'done' || event.payload.lastAssistantMessage) { this.retryScheduler.clearAssistantMessageRetry(event.paneKey) } - const previous = this.state.lastStatusByPaneKey.get(event.paneKey) - const cachedEvent = resolveCachedClaudeCompactOwnership(previous, event) + // Why: keep PostCompact identity in the replay cache so the client can re-run ownership when + // it reconnects. Stripping it would let a cold relay replay a completion as an ordinary `done` + // row and resurrect a pane that the client had already retired. + const cachedEvent = event // Why: delete-then-set makes Map insertion order = recency, so the cap below evicts the longest-idle pane. this.state.lastStatusByPaneKey.delete(event.paneKey) this.state.lastStatusByPaneKey.set(event.paneKey, cachedEvent) diff --git a/src/relay/git-handler-remote-sync.test.ts b/src/relay/git-handler-remote-sync.test.ts index 59054fad9e9..4f7adfd5499 100644 --- a/src/relay/git-handler-remote-sync.test.ts +++ b/src/relay/git-handler-remote-sync.test.ts @@ -3,7 +3,7 @@ * sync, fast-forward, and the narrow review-head fetches for GitHub pull * requests and GitLab merge requests. */ -import { describe, expect, it, beforeEach, afterEach } from 'vitest' +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest' import * as fs from 'node:fs/promises' import * as path from 'node:path' import { mkdtempSync, writeFileSync } from 'node:fs' @@ -14,16 +14,20 @@ import { gitInit, gitCommit, type MockDispatcher } from './git-handler-test-setu import { createGitHandlerRelay, createGitTempDir, - removeGitTempDir + removeGitTempDir, + type GitSpyTarget } from './git-handler-test-harness' describe('GitHandler', () => { let dispatcher: MockDispatcher let tmpDir: string + let gitTarget: GitSpyTarget beforeEach(() => { tmpDir = createGitTempDir() - ;({ dispatcher } = createGitHandlerRelay()) + const relay = createGitHandlerRelay() + dispatcher = relay.dispatcher + gitTarget = relay.handler as unknown as GitSpyTarget }) afterEach(async () => { @@ -154,6 +158,252 @@ describe('GitHandler', () => { } }) + it('rebases from the original fork point after a remote force-push', async () => { + const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-git-rebase-bare-')) + const producerParent = mkdtempSync(path.join(tmpdir(), 'relay-git-rebase-producer-')) + const producerDir = path.join(producerParent, 'repo') + try { + execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' }) + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'base.txt'), 'base') + gitCommit(tmpDir, 'base') + const branch = execFileSync('git', ['branch', '--show-current'], { + cwd: tmpDir, + encoding: 'utf-8' + }).trim() + const forkPoint = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: tmpDir, + encoding: 'utf-8' + }).trim() + execFileSync('git', ['remote', 'add', 'origin', bareDir], { cwd: tmpDir, stdio: 'pipe' }) + execFileSync('git', ['push', '--set-upstream', 'origin', branch], { + cwd: tmpDir, + stdio: 'pipe' + }) + + execFileSync('git', ['clone', bareDir, producerDir], { stdio: 'pipe' }) + execFileSync('git', ['checkout', branch], { cwd: producerDir, stdio: 'pipe' }) + execFileSync('git', ['config', 'user.email', 'test@test.com'], { + cwd: producerDir, + stdio: 'pipe' + }) + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: producerDir, stdio: 'pipe' }) + writeFileSync(path.join(producerDir, 'discarded.txt'), 'discarded') + gitCommit(producerDir, 'discarded remote commit') + execFileSync('git', ['push'], { cwd: producerDir, stdio: 'pipe' }) + execFileSync('git', ['fetch', 'origin'], { cwd: tmpDir, stdio: 'pipe' }) + + execFileSync('git', ['checkout', '-b', 'feature', `origin/${branch}`], { + cwd: tmpDir, + stdio: 'pipe' + }) + writeFileSync(path.join(tmpDir, 'topic.txt'), 'topic') + gitCommit(tmpDir, 'topic commit') + + execFileSync('git', ['reset', '--hard', forkPoint], { cwd: producerDir, stdio: 'pipe' }) + writeFileSync(path.join(producerDir, 'replacement.txt'), 'replacement') + gitCommit(producerDir, 'replacement remote commit') + execFileSync('git', ['push', '--force', 'origin', branch], { + cwd: producerDir, + stdio: 'pipe' + }) + + await dispatcher.callRequest('git.rebaseFromBase', { + worktreePath: tmpDir, + baseRef: `origin/${branch}` + }) + + await expect(fs.access(path.join(tmpDir, 'replacement.txt'))).resolves.toBeUndefined() + await expect(fs.access(path.join(tmpDir, 'topic.txt'))).resolves.toBeUndefined() + await expect(fs.access(path.join(tmpDir, 'discarded.txt'))).rejects.toThrow() + expect( + execFileSync('git', ['rev-parse', `origin/${branch}`], { + cwd: tmpDir, + encoding: 'utf-8' + }).trim() + ).toBe( + execFileSync('git', ['rev-parse', branch], { + cwd: producerDir, + encoding: 'utf-8' + }).trim() + ) + expect( + execFileSync('git', ['for-each-ref', '--format=%(refname)', 'refs/orca/rebase'], { + cwd: tmpDir, + encoding: 'utf-8' + }).trim() + ).toBe('') + } finally { + await Promise.all([ + fs.rm(bareDir, { recursive: true, force: true }), + fs.rm(producerParent, { recursive: true, force: true }) + ]) + } + }, 15_000) + + it('rebases the selected linked worktree without moving the source worktree', async () => { + const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-git-linked-rebase-bare-')) + const producerParent = mkdtempSync(path.join(tmpdir(), 'relay-git-linked-rebase-producer-')) + const producerDir = path.join(producerParent, 'repo') + const targetParent = mkdtempSync(path.join(tmpdir(), 'relay-git-linked-rebase-target-')) + const targetDir = path.join(targetParent, 'feature') + try { + execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' }) + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'base.txt'), 'base') + gitCommit(tmpDir, 'base') + const baseBranch = execFileSync('git', ['branch', '--show-current'], { + cwd: tmpDir, + encoding: 'utf-8' + }).trim() + execFileSync('git', ['remote', 'add', 'origin', bareDir], { cwd: tmpDir, stdio: 'pipe' }) + execFileSync('git', ['push', '--set-upstream', 'origin', baseBranch], { + cwd: tmpDir, + stdio: 'pipe' + }) + execFileSync('git', ['worktree', 'add', '-b', 'feature', targetDir, 'HEAD'], { + cwd: tmpDir, + stdio: 'pipe' + }) + writeFileSync(path.join(targetDir, 'topic.txt'), 'topic') + gitCommit(targetDir, 'topic') + writeFileSync(path.join(tmpDir, 'source-dirty.txt'), 'leave me alone') + const sourceHeadBefore = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: tmpDir, + encoding: 'utf-8' + }).trim() + const sourceStatusBefore = execFileSync('git', ['status', '--short'], { + cwd: tmpDir, + encoding: 'utf-8' + }) + + execFileSync('git', ['clone', bareDir, producerDir], { stdio: 'pipe' }) + execFileSync('git', ['config', 'user.email', 'test@test.com'], { + cwd: producerDir, + stdio: 'pipe' + }) + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: producerDir, stdio: 'pipe' }) + execFileSync('git', ['checkout', baseBranch], { cwd: producerDir, stdio: 'pipe' }) + writeFileSync(path.join(producerDir, 'latest.txt'), 'latest') + gitCommit(producerDir, 'latest base') + const latestBaseOid = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: producerDir, + encoding: 'utf-8' + }).trim() + execFileSync('git', ['push', 'origin', baseBranch], { cwd: producerDir, stdio: 'pipe' }) + + await dispatcher.callRequest('git.rebaseFromBase', { + worktreePath: targetDir, + baseRef: `origin/${baseBranch}` + }) + + expect(execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir }).toString().trim()).toBe( + sourceHeadBefore + ) + expect(execFileSync('git', ['status', '--short'], { cwd: tmpDir, encoding: 'utf-8' })).toBe( + sourceStatusBefore + ) + expect( + execFileSync('git', ['rev-parse', 'HEAD~1'], { cwd: targetDir, encoding: 'utf-8' }).trim() + ).toBe(latestBaseOid) + expect( + execFileSync('git', ['reflog', '-1', '--format=%gs'], { + cwd: targetDir, + encoding: 'utf-8' + }) + ).toContain('rebase (finish)') + expect( + execFileSync('git', ['for-each-ref', '--format=%(refname)', 'refs/orca/rebase'], { + cwd: targetDir, + encoding: 'utf-8' + }).trim() + ).toBe('') + } finally { + await Promise.all([ + fs.rm(bareDir, { recursive: true, force: true }), + fs.rm(producerParent, { recursive: true, force: true }), + fs.rm(targetParent, { recursive: true, force: true }) + ]) + } + }, 15_000) + + it('fast-forwards an unborn branch from the selected remote base', async () => { + const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-git-unborn-bare-')) + const producerDir = mkdtempSync(path.join(tmpdir(), 'relay-git-unborn-producer-')) + try { + execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' }) + gitInit(producerDir) + writeFileSync(path.join(producerDir, 'base.txt'), 'base') + gitCommit(producerDir, 'base') + const branch = execFileSync('git', ['branch', '--show-current'], { + cwd: producerDir, + encoding: 'utf-8' + }).trim() + execFileSync('git', ['remote', 'add', 'origin', bareDir], { + cwd: producerDir, + stdio: 'pipe' + }) + execFileSync('git', ['push', 'origin', branch], { cwd: producerDir, stdio: 'pipe' }) + + gitInit(tmpDir) + execFileSync('git', ['remote', 'add', 'origin', bareDir], { cwd: tmpDir, stdio: 'pipe' }) + + await dispatcher.callRequest('git.rebaseFromBase', { + worktreePath: tmpDir, + baseRef: `origin/${branch}` + }) + + await expect(fs.access(path.join(tmpDir, 'base.txt'))).resolves.toBeUndefined() + expect(execFileSync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: tmpDir })).toBeTruthy() + } finally { + await Promise.all([ + fs.rm(bareDir, { recursive: true, force: true }), + fs.rm(producerDir, { recursive: true, force: true }) + ]) + } + }) + + it('cancels the active rebase fetch and still removes its private ref', async () => { + const controller = new AbortController() + let rejectFetch!: (error: Error) => void + const fetchStarted = new Promise((resolve) => { + vi.spyOn(gitTarget, 'git').mockImplementation(async (args, _cwd, options) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'merge-base') { + return { stdout: 'fork-point\n', stderr: '' } + } + if (args[0] === 'fetch') { + resolve() + return new Promise((_resolve, reject) => { + rejectFetch = reject + options?.signal?.addEventListener('abort', () => reject(new Error('aborted')), { + once: true + }) + }) + } + return { stdout: '', stderr: '' } + }) + }) + + const request = dispatcher.callRequest( + 'git.rebaseFromBase', + { worktreePath: tmpDir, baseRef: 'origin/main' }, + { isStale: () => false, signal: controller.signal } + ) + await fetchStarted + controller.abort() + await expect(request).rejects.toThrow('aborted') + + const calls = vi.mocked(gitTarget.git).mock.calls + expect(calls.some(([args]) => args[0] === 'rebase')).toBe(false) + const cleanup = calls.find(([args]) => args[0] === 'update-ref') + expect(cleanup).toBeDefined() + expect(cleanup?.[2]?.signal).toBeUndefined() + expect(rejectFetch).toBeTypeOf('function') + }) + it('fetches the explicit publish target remote', async () => { const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-git-fork-bare-')) try { diff --git a/src/relay/git-handler-termination.test.ts b/src/relay/git-handler-termination.test.ts new file mode 100644 index 00000000000..86189374c53 --- /dev/null +++ b/src/relay/git-handler-termination.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn() })) + +vi.mock('../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) + +import { createGitHandlerRelay } from './git-handler-test-harness' + +type GitTerminationTarget = { + git( + args: string[], + cwd: string, + options: { signal?: AbortSignal; terminationBarrier: true; timeout?: number } + ): Promise<{ stdout: string; stderr: string }> +} + +describe('GitHandler termination barrier', () => { + beforeEach(() => runProcessMock.mockReset()) + + it('rejects a zero-exit result that crossed its timeout', async () => { + runProcessMock.mockResolvedValue({ + code: 0, + signal: null, + stdout: '', + stderr: '', + timedOut: true + }) + const { handler } = createGitHandlerRelay() + const target = handler as unknown as GitTerminationTarget + + await expect( + target.git(['status'], '/repo', { terminationBarrier: true, timeout: 1 }) + ).rejects.toThrow('git status timed out.') + }) + + it('rejects a zero-exit result after caller cancellation', async () => { + runProcessMock.mockResolvedValue({ + code: 0, + signal: null, + stdout: '', + stderr: '', + timedOut: false + }) + const controller = new AbortController() + controller.abort() + const { handler } = createGitHandlerRelay() + const target = handler as unknown as GitTerminationTarget + + await expect( + target.git(['status'], '/repo', { + signal: controller.signal, + terminationBarrier: true + }) + ).rejects.toMatchObject({ name: 'AbortError' }) + }) +}) diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 0558f710970..b3e5cb96fcb 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -1,4 +1,5 @@ /* eslint-disable max-lines -- Why: centralizes the git RPC protocol surface so local and SSH git behavior stay in one dispatch table. */ +import { randomUUID } from 'node:crypto' import { execFile, spawn, type ExecFileOptions } from 'node:child_process' import { promisify } from 'node:util' import * as path from 'node:path' @@ -60,7 +61,14 @@ import { import { upstreamOnlyCommitsArePatchEquivalent } from '../shared/git-upstream-status' import { assertGitPushTargetShape } from '../shared/git-push-target-validation' import { getPublishTargetStatus, type GitCommandRunner } from '../shared/git-publish-target-status' -import { resolveGitRemoteRebaseSource } from '../shared/git-rebase-source' +import { isNoWriteFetchHeadUnsupportedError } from '../shared/git-fetch-head-capability' +import { runWithGitWorktreeOperationLock } from '../shared/git-worktree-operation-lock' +import { resolveGitFetchHeadCommand, runWithGitFetchHeadLock } from '../shared/git-fetch-head-lock' +import { + REBASE_FROM_BASE_OPERATION_TIMEOUT_MS, + REBASE_SOURCE_FETCH_TIMEOUT_MS, + resolveGitRemoteRebaseSource +} from '../shared/git-rebase-source' import type { GitPushTarget } from '../shared/worktree/types' import { getEffectiveGitUpstreamStatus, @@ -96,10 +104,12 @@ import { endSubprocessStdin } from '../shared/subprocess-stdin-write' import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache' import { invalidateGitBranchLineTotalInFlight } from '../shared/git-branch-line-total' import { streamRelayGitStdout } from './git-stdout-stream' +import { runProcess } from '../shared/child-process/run-process' const execFileAsync = promisify(execFile) const MAX_GIT_BUFFER = 10 * 1024 * 1024 const BULK_CHUNK_SIZE = 100 +const GIT_REBASE_PROCESS_FALLBACK_TIMEOUT_MS = 2_147_000_000 function resolveSubmoduleStatusArea( params: Record @@ -178,6 +188,47 @@ function execFileWithStdin( }) } +async function runGitToTermination( + args: string[], + options: ExecFileOptions, + stdin: string | undefined +): Promise<{ stdout: string; stderr: string }> { + const result = await runProcess({ + program: 'git', + args, + cwd: typeof options.cwd === 'string' ? options.cwd : undefined, + env: options.env, + timeoutMs: + typeof options.timeout === 'number' + ? options.timeout + : GIT_REBASE_PROCESS_FALLBACK_TIMEOUT_MS, + maxOutputBytes: typeof options.maxBuffer === 'number' ? options.maxBuffer : MAX_GIT_BUFFER, + signal: options.signal, + terminationBarrier: true, + ...(stdin === undefined ? {} : { input: stdin }) + }) + if (result.code === 0 && !result.timedOut && !options.signal?.aborted) { + return { stdout: result.stdout, stderr: result.stderr } + } + const error = new Error( + result.timedOut + ? `git ${args[0] ?? 'command'} timed out.` + : options.signal?.aborted + ? 'The operation was aborted.' + : result.stderr.trim() || `git ${args[0] ?? 'command'} failed.` + ) + if (options.signal?.aborted) { + error.name = 'AbortError' + } + throw Object.assign(error, { + code: result.code, + killed: result.timedOut || result.signal !== null || options.signal?.aborted === true, + signal: result.signal, + stdout: result.stdout, + stderr: result.stderr + }) +} + export class GitHandler { private dispatcher: RelayDispatcher private readonly gitDiffReadDedupe = new InFlightPromiseDedupe() @@ -246,9 +297,9 @@ export class GitHandler { this.fetchGitLabMergeRequestHead(p) ) this.dispatcher.onRequest('git.push', (p) => this.push(p)) - this.dispatcher.onRequest('git.pull', (p) => this.pull(p)) - this.dispatcher.onRequest('git.fastForward', (p) => this.fastForward(p)) - this.dispatcher.onRequest('git.rebaseFromBase', (p) => this.rebaseFromBase(p)) + this.dispatcher.onRequest('git.pull', (p, context) => this.pull(p, context)) + this.dispatcher.onRequest('git.fastForward', (p, context) => this.fastForward(p, context)) + this.dispatcher.onRequest('git.rebaseFromBase', (p, context) => this.rebaseFromBase(p, context)) this.dispatcher.onRequest('git.branchDiff', (p, context) => this.branchDiff(p, context)) this.dispatcher.onRequest('git.commitDiff', (p, context) => this.commitDiff(p, context)) this.dispatcher.onRequest('git.listWorktrees', (p, context) => this.listWorktrees(p, context)) @@ -329,25 +380,36 @@ export class GitHandler { nonInteractive?: boolean stdin?: string timeout?: number + terminationBarrier?: boolean } ): Promise<{ stdout: string; stderr: string }> { - const env = opts?.nonInteractive ? buildRelayUnattendedGitEnv() : buildRelayGitEnv() - if (opts?.disableOptionalLocks) { - env.GIT_OPTIONAL_LOCKS = '0' + const expandedCwd = expandTilde(cwd) + const run = async (): Promise<{ stdout: string; stderr: string }> => { + const env = opts?.nonInteractive ? buildRelayUnattendedGitEnv() : buildRelayGitEnv() + if (opts?.disableOptionalLocks) { + env.GIT_OPTIONAL_LOCKS = '0' + } + const execOptions = { + cwd: expandedCwd, + env, + encoding: 'utf-8', + maxBuffer: opts?.maxBuffer ?? MAX_GIT_BUFFER, + timeout: opts?.timeout, + signal: opts?.signal + } satisfies ExecFileOptions + if (opts?.terminationBarrier) { + return runGitToTermination(args, execOptions, opts.stdin) + } + if (opts?.stdin !== undefined) { + return execFileWithStdin('git', args, execOptions, opts.stdin) + } + const { stdout, stderr } = await execFileAsync('git', args, execOptions) + return { stdout: String(stdout), stderr: String(stderr) } } - const execOptions = { - cwd: expandTilde(cwd), - env, - encoding: 'utf-8', - maxBuffer: opts?.maxBuffer ?? MAX_GIT_BUFFER, - timeout: opts?.timeout, - signal: opts?.signal - } satisfies ExecFileOptions - if (opts?.stdin !== undefined) { - return execFileWithStdin('git', args, execOptions, opts.stdin) - } - const { stdout, stderr } = await execFileAsync('git', args, execOptions) - return { stdout: String(stdout), stderr: String(stderr) } + const command = resolveGitFetchHeadCommand(args, expandedCwd) + return command.needsLock + ? runWithGitFetchHeadLock(command.cwd, opts?.signal, run, command.gitDir) + : run() } private async gitBuffer(args: string[], cwd: string): Promise { @@ -1081,7 +1143,21 @@ export class GitHandler { } } - private async pullWithArgs(params: Record, pullArgs: string[]) { + private async pullWithArgs( + params: Record, + pullArgs: string[], + signal?: AbortSignal + ) { + const worktreePath = params.worktreePath as string + return runWithGitWorktreeOperationLock(worktreePath, signal, () => + this.runPullWithArgsUnlocked(params, pullArgs) + ) + } + + private async runPullWithArgsUnlocked( + params: Record, + pullArgs: string[] + ): Promise { this.clearGitMutationReadCaches() const worktreePath = params.worktreePath as string const runPull = async (effectiveArgs: string[]): Promise => { @@ -1119,30 +1195,109 @@ export class GitHandler { } } - private async pull(params: Record) { + private async pull(params: Record, context?: RequestContext) { // Why: plain `git pull` honors user merge/rebase/ff policy. - await this.pullWithArgs(params, []) + await this.pullWithArgs(params, [], context?.signal) } - private async fastForward(params: Record) { - await this.pullWithArgs(params, ['--ff-only']) + private async fastForward(params: Record, context?: RequestContext) { + await this.pullWithArgs(params, ['--ff-only'], context?.signal) } - private async rebaseFromBase(params: Record) { + private async rebaseFromBase(params: Record, context?: RequestContext) { + return runWithGitWorktreeOperationLock(params.worktreePath as string, context?.signal, () => + this.runRebaseFromBase(params, context) + ) + } + + private async runRebaseFromBase(params: Record, context?: RequestContext) { this.clearGitMutationReadCaches() const worktreePath = params.worktreePath as string const baseRef = params.baseRef as string + let rebaseRef: string | null = null + const controller = new AbortController() + const abortFromContext = () => controller.abort() + if (context?.signal?.aborted) { + controller.abort() + } else { + context?.signal?.addEventListener('abort', abortFromContext, { once: true }) + } + const timeout = setTimeout(() => controller.abort(), REBASE_FROM_BASE_OPERATION_TIMEOUT_MS) try { try { const source = await resolveGitRemoteRebaseSource( - ((args) => this.git(args, worktreePath)) as GitCommandRunner, + ((args) => + this.git(args, worktreePath, { + signal: controller.signal, + terminationBarrier: true + })) as GitCommandRunner, baseRef ) - await this.git(['pull', '--rebase', source.remoteName, source.branchName], worktreePath) + let forkPoint: string | null = null + let hasHead = true + try { + const { stdout } = await this.git( + ['merge-base', '--fork-point', `refs/remotes/${source.displayName}`, 'HEAD'], + worktreePath, + { signal: controller.signal, terminationBarrier: true } + ) + forkPoint = stdout.trim() || null + } catch { + // A first fetch or an unhelpful reflog falls back to Git's merge-base behavior. + try { + await this.git(['rev-parse', '--verify', 'HEAD'], worktreePath, { + signal: controller.signal, + terminationBarrier: true + }) + } catch { + hasHead = false + } + } + // Why: concurrent fetches can replace FETCH_HEAD and remote-tracking refs between fetch and rebase. + rebaseRef = `refs/orca/rebase/${randomUUID()}` + const fetchArgs = [ + source.remoteName, + `+refs/heads/${source.branchName}:${rebaseRef}`, + `+refs/heads/${source.branchName}:refs/remotes/${source.displayName}` + ] + await this.gitCapabilities.runWithFallback( + 'fetch-no-write-fetch-head', + () => + this.git(['fetch', '--no-write-fetch-head', ...fetchArgs], worktreePath, { + timeout: REBASE_SOURCE_FETCH_TIMEOUT_MS, + signal: controller.signal, + terminationBarrier: true + }), + () => + this.git(['fetch', ...fetchArgs], worktreePath, { + timeout: REBASE_SOURCE_FETCH_TIMEOUT_MS, + signal: controller.signal, + terminationBarrier: true + }), + isNoWriteFetchHeadUnsupportedError + ) + await this.git( + hasHead + ? forkPoint + ? ['rebase', '--onto', rebaseRef, forkPoint] + : ['rebase', rebaseRef] + : ['merge', '--ff-only', rebaseRef], + worktreePath, + { signal: controller.signal, terminationBarrier: true } + ) } catch (error) { throw new Error(normalizeGitErrorMessage(error, 'pull')) } } finally { + if (rebaseRef) { + try { + await this.git(['update-ref', '-d', rebaseRef], worktreePath) + } catch { + // Cleanup must not hide the fetch or rebase result. + } + } + clearTimeout(timeout) + context?.signal?.removeEventListener('abort', abortFromContext) this.clearGitMutationReadCaches() } } diff --git a/src/renderer/src/app-shell/app-command-handlers-workspace-delete.test.ts b/src/renderer/src/app-shell/app-command-handlers-workspace-delete.test.ts new file mode 100644 index 00000000000..7ffc3896a66 --- /dev/null +++ b/src/renderer/src/app-shell/app-command-handlers-workspace-delete.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store/types' +import type { HoveredWorkspaceDeleteTarget } from '../components/sidebar/hovered-workspace-delete' +import type { AppShortcutState, ShortcutDispatchInput } from './app-command-handlers' + +const mocks = vi.hoisted(() => ({ + deleteHoveredWorkspaceImmediately: vi.fn(), + hoveredTarget: { kind: 'worktree', worktree: {} } as HoveredWorkspaceDeleteTarget | null, + store: {} as AppState +})) + +vi.mock('../store', () => ({ + useAppStore: Object.assign(vi.fn(), { getState: () => mocks.store }) +})) + +vi.mock('../components/sidebar/hovered-workspace-delete', () => ({ + deleteHoveredWorkspaceImmediately: mocks.deleteHoveredWorkspaceImmediately, + resolveHoveredWorkspaceDeleteTarget: () => mocks.hoveredTarget +})) + +vi.mock('@/lib/floating-workspace-terminal-actions', () => ({ + isFloatingWorkspacePanelFocused: () => false +})) + +vi.mock('@/lib/terminal-shortcut-capture-notification', () => ({ + showTerminalShortcutCaptureNotification: vi.fn() +})) + +import { createAppCommandHandlers } from './app-command-handlers' + +function shortcutState(overrides: Partial = {}): AppShortcutState { + return { + activeView: 'terminal', + activeWorktreeId: 'repo::/feature', + actions: {} as AppShortcutState['actions'], + creationLayoutActive: false, + floatingTerminalEnabled: false, + floatingTerminalOpen: false, + floatingVisibleTabCount: 0, + keybindings: {}, + openFloatingWorkspaceMaximized: vi.fn(), + pluginCommands: [], + setFloatingTerminalOpen: vi.fn(), + terminalShortcutPolicy: 'orca-first', + workspaceChromeActive: true, + ...overrides + } +} + +function shortcutInput(): ShortcutDispatchInput { + return { + target: null, + defaultPrevented: false, + preventDefault: vi.fn() + } +} + +describe('workspace delete app command', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.store = { activeWorktreeId: 'repo::/feature' } as AppState + mocks.hoveredTarget = { kind: 'worktree', worktree: {} as never } + }) + + it('claims the chord and immediately deletes the active workspace', () => { + const input = shortcutInput() + const handler = createAppCommandHandlers(shortcutState(), input, 'terminal').get( + 'workspace.delete' + ) + + expect(handler?.()).toBe(true) + expect(input.preventDefault).toHaveBeenCalledOnce() + expect(mocks.deleteHoveredWorkspaceImmediately).toHaveBeenCalledWith( + mocks.store, + mocks.hoveredTarget + ) + }) + + it('does not claim the chord without a hovered workspace', () => { + const input = shortcutInput() + const handler = createAppCommandHandlers(shortcutState(), input).get('workspace.delete') + + mocks.hoveredTarget = null + expect(handler?.()).toBe(false) + expect(input.preventDefault).not.toHaveBeenCalled() + expect(mocks.deleteHoveredWorkspaceImmediately).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/app-shell/app-command-handlers.ts b/src/renderer/src/app-shell/app-command-handlers.ts index 11c69425552..99915597e67 100644 --- a/src/renderer/src/app-shell/app-command-handlers.ts +++ b/src/renderer/src/app-shell/app-command-handlers.ts @@ -5,6 +5,10 @@ import { requestScrollToCurrentWorkspaceRevealAndRename } from '@/lib/scroll-to- import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification' import { shouldShowWorktreeHistoryControls } from '../lib/titlebar-worktree-history-controls' import { TOGGLE_WORKSPACE_BOARD_EVENT } from '../components/sidebar/useWorkspaceBoardPanel' +import { + deleteHoveredWorkspaceImmediately, + resolveHoveredWorkspaceDeleteTarget +} from '../components/sidebar/hovered-workspace-delete' import { useAppStore } from '../store' import type { usePluginCommands } from '@/store/plugin-panels' import { isGitRepoKind } from '../../../shared/repo-kind' @@ -191,6 +195,22 @@ export function createAppCommandHandlers( }) } ], + [ + 'workspace.delete', + () => { + if (floatingWorkspaceFocused) { + return false + } + const store = useAppStore.getState() + const target = resolveHoveredWorkspaceDeleteTarget(store) + if (!target) { + return false + } + return claim('workspace.delete', () => { + deleteHoveredWorkspaceImmediately(store, target) + }) + } + ], [ 'workspace.openBoard', () => { diff --git a/src/renderer/src/app-shell/use-document-appearance.test.tsx b/src/renderer/src/app-shell/use-document-appearance.test.tsx new file mode 100644 index 00000000000..b06b9c7517c --- /dev/null +++ b/src/renderer/src/app-shell/use-document-appearance.test.tsx @@ -0,0 +1,78 @@ +// @vitest-environment happy-dom +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../shared/constants' +import { useAppStore } from '../store' + +const mocks = vi.hoisted(() => ({ + applyDocumentTheme: vi.fn(), + buildAppFontFamily: vi.fn((fontFamily: string | null | undefined) => fontFamily ?? '') +})) + +vi.mock('../lib/document-theme', () => ({ + applyDocumentTheme: mocks.applyDocumentTheme +})) + +vi.mock('@/lib/app-font-family', () => ({ + buildAppFontFamily: mocks.buildAppFontFamily +})) + +vi.mock('../runtime/sync-runtime-graph', () => ({ + scheduleRuntimeGraphSync: vi.fn() +})) + +import { useDocumentAppearance } from './use-document-appearance' + +const initialState = useAppStore.getState() + +describe('useDocumentAppearance', () => { + beforeEach(() => { + mocks.applyDocumentTheme.mockReset() + mocks.buildAppFontFamily.mockClear() + useAppStore.setState({ + settings: { + ...getDefaultSettings('/tmp'), + theme: 'dark' + } + }) + }) + + afterEach(() => { + useAppStore.setState(initialState, true) + }) + + it('ignores unrelated settings object replacements', () => { + const { unmount } = renderHook(() => useDocumentAppearance()) + + expect(mocks.applyDocumentTheme).toHaveBeenCalledTimes(1) + expect(mocks.buildAppFontFamily).toHaveBeenCalledTimes(1) + + act(() => { + const settings = useAppStore.getState().settings! + useAppStore.setState({ + settings: { ...settings, editorAutoSave: !settings.editorAutoSave } + }) + }) + + expect(mocks.applyDocumentTheme).toHaveBeenCalledTimes(1) + expect(mocks.buildAppFontFamily).toHaveBeenCalledTimes(1) + unmount() + }) + + it('still applies changed theme and font values', () => { + const { unmount } = renderHook(() => useDocumentAppearance()) + + act(() => { + const settings = useAppStore.getState().settings! + useAppStore.setState({ + settings: { ...settings, theme: 'light', appFontFamily: 'Monaco' } + }) + }) + + expect(mocks.applyDocumentTheme).toHaveBeenLastCalledWith('light') + expect(mocks.applyDocumentTheme).toHaveBeenCalledTimes(2) + expect(mocks.buildAppFontFamily).toHaveBeenLastCalledWith('Monaco') + expect(mocks.buildAppFontFamily).toHaveBeenCalledTimes(2) + unmount() + }) +}) diff --git a/src/renderer/src/app-shell/use-document-appearance.ts b/src/renderer/src/app-shell/use-document-appearance.ts index 7f0c3f8af72..3d4c8e23daf 100644 --- a/src/renderer/src/app-shell/use-document-appearance.ts +++ b/src/renderer/src/app-shell/use-document-appearance.ts @@ -6,17 +6,18 @@ import { useAppStore } from '../store' /** Applies the settings-driven theme and app font to the document root. */ export function useDocumentAppearance(): void { - const settings = useAppStore((s) => s.settings) + const theme = useAppStore((s) => s.settings?.theme) + const appFontFamily = useAppStore((s) => s.settings?.appFontFamily) useEffect(() => { - if (!settings) { + if (!theme) { return } - if (settings.theme === 'dark') { + if (theme === 'dark') { applyDocumentTheme('dark') return undefined - } else if (settings.theme === 'light') { + } else if (theme === 'light') { applyDocumentTheme('light') return undefined } @@ -30,12 +31,12 @@ export function useDocumentAppearance(): void { } mq.addEventListener('change', handler) return () => mq.removeEventListener('change', handler) - }, [settings]) + }, [theme]) useEffect(() => { document.documentElement.style.setProperty( '--app-font-family', - buildAppFontFamily(settings?.appFontFamily) + buildAppFontFamily(appFontFamily) ) - }, [settings?.appFontFamily]) + }, [appFontFamily]) } diff --git a/src/renderer/src/app-shell/use-global-keybindings.ts b/src/renderer/src/app-shell/use-global-keybindings.ts index d40d928a20c..78051a58ed5 100644 --- a/src/renderer/src/app-shell/use-global-keybindings.ts +++ b/src/renderer/src/app-shell/use-global-keybindings.ts @@ -236,6 +236,9 @@ export function useGlobalKeybindings(args: { } const handlers = createAppCommandHandlers(state, input, context) + if (matchShortcut('workspace.delete') && handlers.get('workspace.delete')?.()) { + return + } for (const actionId of PLUGIN_COMMAND_ALIAS_ACTION_IDS) { if (matchShortcut(actionId) && handlers.get(actionId)?.()) { return diff --git a/src/renderer/src/components/AgentStateDot.test.ts b/src/renderer/src/components/AgentStateDot.test.ts index ed032d2bbf9..6304b1cfd68 100644 --- a/src/renderer/src/components/AgentStateDot.test.ts +++ b/src/renderer/src/components/AgentStateDot.test.ts @@ -42,6 +42,15 @@ describe('AgentStateDot', () => { expect(markup).toContain('motion-reduce:border-t-yellow-500') }) + it('renders monitoring as a static yellow radio glyph', () => { + const markup = renderMarkup('monitoring') + + expect(markup).toContain('aria-label="Monitoring background tasks"') + expect(markup).toContain('lucide-radio') + expect(markup).toContain('text-yellow-500') + expect(markup).not.toContain('data-agent-spinner') + }) + it('renders done as an emerald check icon', () => { const markup = renderMarkup('done') diff --git a/src/renderer/src/components/AgentStateDot.tsx b/src/renderer/src/components/AgentStateDot.tsx index 0c182480d98..4f361d01f87 100644 --- a/src/renderer/src/components/AgentStateDot.tsx +++ b/src/renderer/src/components/AgentStateDot.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { CircleCheck } from 'lucide-react' +import { CircleCheck, Radio } from 'lucide-react' import { cn } from '@/lib/utils' import { AgentQuestionIcon } from '@/components/AgentQuestionIcon' import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner' @@ -17,6 +17,7 @@ import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner' export type AgentDotState = | 'working' + | 'monitoring' | 'blocked' | 'waiting' | 'interrupted' @@ -37,6 +38,8 @@ export function agentStateLabel(state: AgentDotState): string { switch (state) { case 'working': return 'Working' + case 'monitoring': + return 'Monitoring background tasks' case 'blocked': return 'Blocked' case 'waiting': @@ -81,6 +84,17 @@ export const AgentStateDot = React.memo(function AgentStateDot({ ) } + if (state === 'monitoring') { + return ( + + + ) + } + if (state === 'done') { // Why: the dashboard lists many agents, so a check glyph scans well for // agent-reported completion and keeps 'done' visually distinct from diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 49bc620914a..1f035a234eb 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -119,7 +119,7 @@ import { TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS, countEvictionExemptTabRoutes, formatEvictionExemptRouteCounts, - getTerminalWorktreeParkingInputsKey, + createTerminalWorktreeTopologyProjection, hasPendingRetentionSpawnWork, selectForceParkEvictableTabIds, selectRetentionForceParkedTerminalWorktrees, @@ -192,11 +192,13 @@ import { } from './terminal-pane/use-manual-terminal-worktree-parking' import { EDITOR_TAB_CONTENT_TYPES, getEditorCmdSaveFileId } from './editor/editor-cmd-save-target' import { getClientCreationActionPolicy } from '@/lib/client-creation-action-policy' +import type { WorktreeTabBucketProjection } from '@/lib/worktree-tab-bucket-projection' const EditorPanel = lazy(() => import('./editor/EditorPanel')) // Why: gate handler runs after a dialog advances so a stray carry-over click can't act on the next dialog; ~200ms absorbs a physical double-click while staying responsive. const CLOSE_DIALOG_DEBOUNCE_MS = 200 +const EMPTY_TERMINAL_TABS: TerminalTab[] = [] type TerminalStoreSnapshot = ReturnType function showClientCreationActionError(error: unknown): void { @@ -293,8 +295,19 @@ function getKeybindingContext(target: EventTarget | null): KeybindingContext { ? 'terminal' : 'app' } +function LiveTerminalTabBar( + props: Omit, 'tabs'> +): React.JSX.Element { + const tabs = useAppStore((state) => state.tabsByWorktree[props.worktreeId] ?? EMPTY_TERMINAL_TABS) + return +} function Terminal(): React.JSX.Element | null { + const terminalTopologyProjectionRef = useRef | null>(null) + terminalTopologyProjectionRef.current ??= createTerminalWorktreeTopologyProjection() const mountedWorktreeIdsRef = useRef(new Set()) // Why an array: browser-guest eviction needs activation order (LRU), not just membership. const browserGuestWorktreeRecencyRef = useRef([]) @@ -325,10 +338,11 @@ function Terminal(): React.JSX.Element | null { getResolvedExecutionHostIdForWorktree(s, renderedActiveWorktreeId) ) const activeView = useAppStore((s) => s.activeView) - const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) - const terminalWorktreeParkingInputsKey = useMemo( - () => getTerminalWorktreeParkingInputsKey(tabsByWorktree), - [tabsByWorktree] + // Why: terminal titles are leaf chrome. The root host only subscribes to + // mount/parking semantics; a real transition publishes fresh tab objects, + // while LiveTerminalTabBar reads title-only updates from the active bucket. + const tabsByWorktree = useAppStore((s) => + terminalTopologyProjectionRef.current!.project(s.tabsByWorktree) ) const pendingStartupByTabId = useAppStore((s) => s.pendingStartupByTabId) const terminalParkingEnabled = useAppStore((s) => s.settings?.terminalHiddenViewParking !== false) @@ -1184,7 +1198,7 @@ function Terminal(): React.JSX.Element | null { pendingStartupByTabId, pairedRuntimeParkingEnvironmentIds, renderedActiveWorktreeId, - terminalWorktreeParkingInputsKey, + tabsByWorktree, terminalParkingEnabled, terminalParkingRevision, terminalProviderSnapshotCapabilityRevision, @@ -1774,9 +1788,13 @@ function Terminal(): React.JSX.Element | null { } const currentTabs = state.browserTabsByWorktree[owningWorktreeId] ?? [] if (currentTabs.length <= 1) { - destroyWorkspaceWebviews(state.browserPagesByWorkspace, tabId) + const hasUnifiedEntry = Object.values(state.unifiedTabsByWorktree).some((tabs) => + tabs.some((tab) => tab.contentType === 'browser' && tab.entityId === tabId) + ) closeBrowserTab(tabId) - if (state.activeWorktreeId === owningWorktreeId) { + // closeBrowserTab announces the MRU target before guest teardown can trigger bridge fallback. + destroyWorkspaceWebviews(state.browserPagesByWorkspace, tabId) + if (!hasUnifiedEntry && state.activeWorktreeId === owningWorktreeId) { const worktreeFile = state.openFiles.find((file) => file.worktreeId === owningWorktreeId) if (worktreeFile) { setActiveFile(worktreeFile.id) @@ -1793,24 +1811,11 @@ function Terminal(): React.JSX.Element | null { } return } - if (state.activeWorktreeId === owningWorktreeId && tabId === state.activeBrowserTabId) { - const idx = currentTabs.findIndex((tab) => tab.id === tabId) - const nextTab = currentTabs[idx + 1] ?? currentTabs[idx - 1] - if (nextTab) { - setActiveBrowserTab(nextTab.id) - } - } - destroyWorkspaceWebviews(state.browserPagesByWorkspace, tabId) closeBrowserTab(tabId) + // closeBrowserTab announces the MRU target before guest teardown can trigger bridge fallback. + destroyWorkspaceWebviews(state.browserPagesByWorkspace, tabId) }, - [ - closeBrowserTab, - setActiveBrowserTab, - setActiveFile, - setActiveTab, - setActiveTabType, - setActiveWorktree - ] + [closeBrowserTab, setActiveFile, setActiveTab, setActiveTabType, setActiveWorktree] ) const handlePtyExit = useCallback( @@ -1879,8 +1884,8 @@ function Terminal(): React.JSX.Element | null { } else if ( (state.browserTabsByWorktree[activeWorktreeId] ?? []).some((tab) => tab.id === id) ) { - destroyWorkspaceWebviews(state.browserPagesByWorkspace, id) closeBrowserTab(id) + destroyWorkspaceWebviews(state.browserPagesByWorkspace, id) } else if (unifiedTab?.contentType === 'simulator') { // Why: simulator tabs live only in the unified-tab store, so the // entity-store checks above never match them. @@ -2466,10 +2471,9 @@ function Terminal(): React.JSX.Element | null { !effectiveActiveLayout && titlebarTabsTarget && createPortal( - { expect(testContainer.textContent).toContain('Recent Worktrees') }) + it('keeps duplicate persisted tab ids as separate recent rows and digit targets', async () => { + await renderPalette(makeDuplicateRecentTabState()) + + expect( + getRenderedRowIds().filter( + (id) => id === 'workspace-tab:tab-duplicate' || id.includes(':workspace-tab:tab-duplicate') + ) + ).toEqual(['workspace-tab:tab-duplicate', 'palette-dup:1:workspace-tab:tab-duplicate']) + + await act(async () => { + emitCmdJRowIndexJump(1) + }) + await flushEffects() + + expect(activateWorkspaceTabPaletteResult).toHaveBeenCalledWith( + expect.objectContaining({ tabId: 'tab-duplicate', worktreeId: 'wt-beta' }) + ) + }) + it('caps the recent section so the worktree header stays above the fold', async () => { await renderPalette(makeManyTabState(12)) @@ -645,7 +665,7 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { expect(getTabRowIds()).toContain('tab-alpha') }) - it('excludes the current tab when its agent is merely done', async () => { + it.each([undefined, true])('excludes current terminal outcomes', async (interrupted) => { await renderPalette( makeRecentTabState({ activeWorktreeId: 'wt-alpha', @@ -654,7 +674,9 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { activeTabIdByWorktree: { 'wt-alpha': 'term-alpha' }, activeTabTypeByWorktree: { 'wt-alpha': 'terminal' }, agentStatusByPaneKey: { - [makePaneKey('term-alpha', LEAF_ID)]: makeAgentEntry('term-alpha', 'done', Date.now()) + [makePaneKey('term-alpha', LEAF_ID)]: makeAgentEntry('term-alpha', 'done', Date.now(), { + interrupted + }) } }) ) diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 9a54ed398c7..25644b7ec51 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -350,9 +350,56 @@ function isCurrentOpenTabItem(item: OpenTabPaletteItem): boolean { return item.type === 'browser-page' ? item.result.isCurrentPage : item.result.isCurrentTab } +/** Not the command id: two hosts — or a duplicate snapshot — can publish the same tab id. */ +function getRecentTabOccurrenceBase(item: OpenTabPaletteItem): string { + if (item.type === 'browser-page') { + const result = item.result + return JSON.stringify([ + item.type, + item.id, + result.executionHostId ?? '', + result.worktreeId, + result.workspaceId, + result.pageId + ]) + } + if (item.type === 'simulator-tab') { + const result = item.result + // Why no groupId: it changes when a tab is regrouped mid-open, and the frozen + // order must keep resolving the row; tabId already identifies it within a host. + return JSON.stringify([ + item.type, + item.id, + result.executionHostId ?? '', + result.worktreeId, + result.tabId + ]) + } + const result = item.result + return JSON.stringify([ + item.type, + item.id, + result.executionHostId ?? '', + result.worktreeId, + result.tabId, + result.entityId + ]) +} + +function buildRecentTabOccurrenceIds(items: readonly OpenTabPaletteItem[]): string[] { + const nextOrdinalByBase = new Map() + return items.map((item) => { + const base = getRecentTabOccurrenceBase(item) + const ordinal = nextOrdinalByBase.get(base) ?? 0 + nextOrdinalByBase.set(base, ordinal + 1) + return `recent-tab:${base}:${ordinal}` + }) +} + /** An open tab's recent-section row plus the inputs inclusion needs. */ type OpenTabRecentRow = { item: OpenTabPaletteItem + occurrenceId: string worktree: Worktree row: RecentWorkspaceTabRow } @@ -402,7 +449,7 @@ function shouldIncludeOpenTabInRecentSection({ unreadAgentCompletionPanes }) }) - return badge != null && badge !== 'done' + return badge != null && badge !== 'done' && badge !== 'interrupted' } function PaletteRowShortcutBadge({ @@ -1419,17 +1466,22 @@ function WorktreeJumpPaletteContent({ // once inclusion would drop it (a current tab that quiets down), or the pip blanks mid-open. const openTabRecentRows = useMemo(() => { const entries: OpenTabRecentRow[] = [] - for (const item of openTabItems) { + const occurrenceIds = buildRecentTabOccurrenceIds(openTabItems) + for (const [index, item] of openTabItems.entries()) { const worktree = resolveWorktree(item.result.worktreeId, item.result.executionHostId) if (!worktree) { continue } + const occurrenceId = occurrenceIds[index] entries.push({ item, + occurrenceId, worktree, row: { id: item.id, + occurrenceId, worktreeId: worktree.id, + worktreeHostId: worktree.hostId, unifiedTabId: item.type === 'browser-page' ? null : item.result.tabId, terminalTab: item.type === 'workspace-tab' && item.result.contentType === 'terminal' @@ -1442,8 +1494,8 @@ function WorktreeJumpPaletteContent({ return entries }, [openTabItems, resolveWorktree, terminalTabsById]) - const recentTabRowById = useMemo( - () => new Map(openTabRecentRows.map(({ row }) => [row.id, row])), + const recentTabRowByItem = useMemo( + () => new Map(openTabRecentRows.map(({ item, row }) => [item, row])), [openTabRecentRows] ) @@ -1558,15 +1610,16 @@ function WorktreeJumpPaletteContent({ visible ]) - // Why: walk the frozen order rather than re-sorting the tab list — the frozen ids are the ranking, - // so agent churn never reshuffles rows under the cursor. Cap after resolving, not before: a chip - // applied mid-open narrows `openTabItems`, and capping first would leave the section empty. + // Why walk the frozen order, and cap after resolving: agent churn must not reshuffle rows, and a + // mid-open chip narrows `openTabItems` — capping first would leave the section empty. const recentTabItems = useMemo(() => { - const itemById = new Map(openTabItems.map((item) => [item.id, item])) + const itemByOccurrenceId = new Map( + openTabRecentRows.map(({ occurrenceId, item }) => [occurrenceId, item]) + ) return recentTabOrder - .flatMap((id) => itemById.get(id) ?? []) + .flatMap((occurrenceId) => itemByOccurrenceId.get(occurrenceId) ?? []) .slice(0, EMPTY_QUERY_RECENT_TAB_CAP) - }, [openTabItems, recentTabOrder]) + }, [openTabRecentRows, recentTabOrder]) const settingsResults = useMemo( () => buildCmdJSettingsResults(settingsSections), @@ -1873,10 +1926,10 @@ function WorktreeJumpPaletteContent({ ]) // Why: badges number the snapshotted recent rows only — ⌘N is meaningless on a typed query. - const recentTabShortcutIndexById = useMemo( + const recentTabShortcutIndexByItem = useMemo( () => new Map( - hasQuery ? [] : paletteSections.visibleOpenTabItems.map((item, index) => [item.id, index]) + hasQuery ? [] : paletteSections.visibleOpenTabItems.map((item, index) => [item, index]) ), [hasQuery, paletteSections] ) @@ -3450,7 +3503,7 @@ function WorktreeJumpPaletteContent({ ) // Why regardless of query: a searched-for tab is exactly when you need to know it's // still working — the map covers every open tab, not just the recent section. - const recentRow = recentTabRowById.get(entry.id) ?? null + const recentRow = recentTabRowByItem.get(entry) ?? null return ( )} @@ -3594,7 +3647,7 @@ function WorktreeJumpPaletteContent({ )} @@ -3672,7 +3725,7 @@ function WorktreeJumpPaletteContent({ )} diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.test.ts b/src/renderer/src/components/activity/ActivityPrototypePage.test.ts index 480cc7fff50..48bdbb3a250 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.test.ts +++ b/src/renderer/src/components/activity/ActivityPrototypePage.test.ts @@ -156,6 +156,27 @@ describe('buildActivityEvents', () => { }) }) + it('projects monitoring into thread rows and status groups', () => { + const result = makeActivityResult({ + entries: { + [PANE_KEY]: { + ...makeWorkingEntryWithoutHistory(), + workingMode: 'monitoring' + } + } + }) + const threads = makeThreads(result) + const groups = groupActivityThreadsByStatus(threads) + + expect(result.liveAgentByPaneKey[PANE_KEY].state).toBe('monitoring') + expect(threads[0].currentAgentState).toBe('monitoring') + expect(groups[0]).toMatchObject({ + id: 'monitoring', + label: 'Monitoring background tasks', + state: 'monitoring' + }) + }) + it('uses orchestration display metadata for live thread titles', () => { const result = makeActivityResult({ entries: { diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index 6157c796d71..a6a1df6dc9f 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -11,7 +11,7 @@ import { TerminalSquare } from 'lucide-react' -import { AgentStateDot, agentStateLabel } from '@/components/AgentStateDot' +import { AgentStateDot, agentStateLabel, type AgentDotState } from '@/components/AgentStateDot' import { AgentIcon } from '@/lib/agent-catalog' import { agentTypeToIconAgent, @@ -88,8 +88,15 @@ import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text' type ThreadReadFilter = 'all' | 'unread' type ActivityGroupBy = 'status' | 'project' | 'worktree' | 'agent' type ActivityEventState = Extract -type ActivityLiveAgentState = Extract -type ActivityStatusGroupId = 'working' | 'blocked' | 'waiting' | 'done' | 'interrupted' +type ActivityHookLiveAgentState = Extract +type ActivityLiveAgentState = ActivityHookLiveAgentState | 'monitoring' +type ActivityStatusGroupId = + | 'working' + | 'monitoring' + | 'blocked' + | 'waiting' + | 'done' + | 'interrupted' type ActivityEvent = { id: string @@ -137,7 +144,7 @@ type ActivityThreadGroup = { key: string id?: ActivityStatusGroupId label: string - state?: AgentStatusState + state?: AgentDotState threads: AgentPaneThread[] } @@ -158,6 +165,7 @@ const ACTIVITY_TERMINAL_LOADING_LABEL_DELAY_MS = 180 const ACTIVITY_THREAD_RESPONSE_RENDER_PREVIEW_MAX_LENGTH = 320 const ACTIVITY_STATUS_GROUP_ORDER: ActivityStatusGroupId[] = [ 'working', + 'monitoring', 'blocked', 'waiting', 'done', @@ -474,7 +482,9 @@ function isActivityEventState(state: AgentStatusState): state is ActivityEventSt return state === 'done' || state === 'blocked' || state === 'waiting' } -function isActivityLiveAgentState(state: AgentStatusState): state is ActivityLiveAgentState { +function isActivityHookLiveAgentState( + state: AgentStatusState +): state is ActivityHookLiveAgentState { return state === 'working' || state === 'blocked' || state === 'waiting' } @@ -482,10 +492,15 @@ function freshActivityLiveAgentState( entry: AgentStatusEntry, now: number ): ActivityLiveAgentState | null { - if (!isActivityLiveAgentState(entry.state)) { + if ( + !isActivityHookLiveAgentState(entry.state) || + !isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS) + ) { return null } - return isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS) ? entry.state : null + return entry.state === 'working' && entry.workingMode === 'monitoring' + ? 'monitoring' + : entry.state } function standaloneActivityWorktree(worktreeId: string): Worktree { @@ -822,7 +837,7 @@ export function buildAgentPaneThreads(args: { agentType: liveAgent.agentType, currentAgentState: liveAgent.state, currentAgentEntry: liveAgent.entry, - responsePreview: statusPreviewForEntry(liveAgent.entry, liveAgent.state), + responsePreview: statusPreviewForEntry(liveAgent.entry, liveAgent.entry.state), latestTimestamp: liveAgent.timestamp, latestEvent: null, events: [], @@ -840,7 +855,7 @@ export function buildAgentPaneThreads(args: { existing.currentAgentEntry = liveAgent.entry existing.responsePreview = statusPreviewForEntry( liveAgent.entry, - liveAgent.state, + liveAgent.entry.state, existing.responsePreview ) existing.latestTimestamp = liveAgent.timestamp @@ -960,7 +975,7 @@ function EventRepoBadge({ repo }: { repo: Repo | null }): React.JSX.Element | nu ) } -function threadAgentState(thread: AgentPaneThread): AgentStatusState { +function threadAgentState(thread: AgentPaneThread): AgentDotState { return thread.currentAgentState ?? thread.latestEvent?.state ?? 'done' } @@ -1024,10 +1039,12 @@ function threadStatusGroupId(thread: AgentPaneThread): ActivityStatusGroupId { if (!thread.currentAgentState && state === 'done' && thread.latestEvent?.entry.interrupted) { return 'interrupted' } - return state === 'working' || state === 'blocked' || state === 'waiting' ? state : 'done' + return state === 'working' || state === 'monitoring' || state === 'blocked' || state === 'waiting' + ? state + : 'done' } -function threadStatusGroupState(id: ActivityStatusGroupId): AgentStatusState { +function threadStatusGroupState(id: ActivityStatusGroupId): AgentDotState { return id === 'interrupted' ? 'done' : id } diff --git a/src/renderer/src/components/automations/AutomationDetail.tsx b/src/renderer/src/components/automations/AutomationDetail.tsx index ef491c816c6..c1ed6165295 100644 --- a/src/renderer/src/components/automations/AutomationDetail.tsx +++ b/src/renderer/src/components/automations/AutomationDetail.tsx @@ -16,6 +16,7 @@ import { import type { AutomationTargetAvailability } from './automation-target-availability' import { getAutomationSourceDisplay } from './automation-source-display' import { translate } from '@/i18n/i18n' +import { AutomationPromptDisclosure } from './AutomationPromptDisclosure' type AutomationDetailProps = { automation: Automation | null @@ -301,21 +302,10 @@ export function AutomationDetail({ /> -
-
- {translate('auto.components.automations.AutomationDetail.007c8ad874', 'Prompt')} -
-
-
-
- {translate('auto.components.automations.AutomationDetail.007c8ad874', 'Prompt')} -
-

- {automation.prompt} -

-
-
-
+ ) } diff --git a/src/renderer/src/components/automations/AutomationPromptDisclosure.test.tsx b/src/renderer/src/components/automations/AutomationPromptDisclosure.test.tsx new file mode 100644 index 00000000000..6ac693aea82 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationPromptDisclosure.test.tsx @@ -0,0 +1,234 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import type React from 'react' +import { act, cleanup, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import { i18n } from '@/i18n/i18n' +import { AutomationDetail } from './AutomationDetail' +import { AutomationPromptDisclosure } from './AutomationPromptDisclosure' + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children} +})) + +let promptNaturalHeight = 0 +let resizeCallback: ResizeObserverCallback | null = null + +class PromptResizeObserver implements ResizeObserver { + constructor(callback: ResizeObserverCallback) { + resizeCallback = callback + } + + disconnect(): void {} + observe(): void {} + unobserve(): void {} +} + +function makeAutomation( + updatedAt: number, + prompt = `Synthetic opening.\n${'Synthetic detail. '.repeat(80)}` +): Automation { + return { + id: 'synthetic-automation', + name: 'Synthetic automation', + prompt, + precheck: null, + agentId: 'codex', + projectId: 'synthetic-project', + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'local_host_service', + workspaceMode: 'existing', + workspaceId: 'synthetic-workspace', + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY', + dtstart: 1, + enabled: false, + nextRunAt: 2, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 1, + updatedAt + } +} + +const detailCallbacks = { + onRunNow: vi.fn(), + onEdit: vi.fn(), + onToggle: vi.fn(), + onDelete: vi.fn() +} + +describe('AutomationPromptDisclosure', () => { + beforeEach(async () => { + await i18n.changeLanguage('en') + vi.stubGlobal('ResizeObserver', PromptResizeObserver) + vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation( + function (this: HTMLElement) { + if (this.tagName !== 'P') { + return 0 + } + return this.classList.contains('line-clamp-4') + ? Math.min(promptNaturalHeight, 80) + : promptNaturalHeight + } + ) + vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation( + function (this: HTMLElement) { + return this.tagName === 'P' ? promptNaturalHeight : 0 + } + ) + }) + + afterEach(async () => { + cleanup() + resizeCallback = null + vi.restoreAllMocks() + vi.unstubAllGlobals() + await i18n.changeLanguage('en') + }) + + it('leaves a short prompt fully readable without a disclosure control', () => { + promptNaturalHeight = 40 + render() + + expect(screen.getByText('Synthetic short prompt.')).toBeVisible() + expect(screen.queryByRole('button', { name: 'Show more' })).not.toBeInTheDocument() + }) + + it('labels the prompt correctly in Spanish', async () => { + promptNaturalHeight = 40 + await i18n.changeLanguage('es') + render() + + expect(screen.getByText('Prompt')).toBeVisible() + expect(screen.queryByText('Inmediato')).not.toBeInTheDocument() + }) + + it('reveals the complete long prompt from the keyboard and keeps it selectable', async () => { + promptNaturalHeight = 240 + const prompt = `Synthetic opening.\n${'Synthetic detail. '.repeat(80)}\nSYNTHETIC-END-MARKER` + const user = userEvent.setup() + render() + + const content = screen.getByText(/SYNTHETIC-END-MARKER/) + const toggle = screen.getByRole('button', { name: 'Show more' }) + expect(screen.getAllByText('Prompt')).toHaveLength(1) + expect(content).toHaveClass('line-clamp-4', 'select-text', '[overflow-wrap:anywhere]') + expect(toggle).toHaveAttribute('aria-expanded', 'false') + expect(toggle).toHaveAttribute('aria-controls', content.id) + expect(toggle).toHaveAttribute('data-variant', 'ghost') + expect(toggle).toHaveAttribute('data-size', 'xs') + expect(toggle).not.toHaveClass('h-auto', 'p-0') + + await user.tab() + expect(toggle).toHaveFocus() + await user.keyboard('{Enter}') + + expect(screen.getByRole('button', { name: 'Show less' })).toHaveAttribute( + 'aria-expanded', + 'true' + ) + expect(content).not.toHaveClass('line-clamp-4') + expect(content).toHaveTextContent('SYNTHETIC-END-MARKER') + + await user.click(screen.getByRole('button', { name: 'Show less' })) + expect(screen.getByRole('button', { name: 'Show more' })).toHaveAttribute( + 'aria-expanded', + 'false' + ) + expect(content).toHaveClass('line-clamp-4') + + await user.click(screen.getByRole('button', { name: 'Show more' })) + act(() => resizeCallback?.([], {} as ResizeObserver)) + expect(screen.getByRole('button', { name: 'Show less' })).toHaveFocus() + + await user.click(screen.getByRole('button', { name: 'Show less' })) + expect(screen.getByRole('button', { name: 'Show more' })).toHaveFocus() + expect(content).toHaveClass('line-clamp-4') + }) + + it('offers disclosure after a narrow resize makes the prompt overflow', () => { + promptNaturalHeight = 60 + render() + expect(screen.queryByRole('button', { name: 'Show more' })).not.toBeInTheDocument() + + promptNaturalHeight = 180 + act(() => resizeCallback?.([], {} as ResizeObserver)) + + expect(screen.getByRole('button', { name: 'Show more' })).toBeVisible() + }) + + it('moves focus to the fully visible prompt when resizing removes the disclosure', () => { + promptNaturalHeight = 180 + render() + const content = screen.getByText('Synthetic prompt that fits after widening.') + screen.getByRole('button', { name: 'Show more' }).focus() + + promptNaturalHeight = 60 + act(() => resizeCallback?.([], {} as ResizeObserver)) + + expect(screen.queryByRole('button', { name: 'Show more' })).not.toBeInTheDocument() + expect(content).toHaveFocus() + }) + + it('preserves expansion and focus across unrelated automation updates', async () => { + promptNaturalHeight = 240 + const user = userEvent.setup() + const { rerender } = render( + + ) + + await user.click(screen.getByRole('button', { name: 'Show more' })) + const showLess = screen.getByRole('button', { name: 'Show less' }) + showLess.focus() + + rerender( + + ) + + expect(screen.getByRole('button', { name: 'Show less' })).toHaveFocus() + + promptNaturalHeight = 40 + rerender( + + ) + + expect(screen.getByText('Synthetic replacement prompt.')).toBeVisible() + expect(screen.queryByRole('button', { name: 'Show less' })).not.toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/automations/AutomationPromptDisclosure.tsx b/src/renderer/src/components/automations/AutomationPromptDisclosure.tsx new file mode 100644 index 00000000000..b5eefc7dd74 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationPromptDisclosure.tsx @@ -0,0 +1,93 @@ +import React, { useCallback, useEffect, useId, useRef, useState } from 'react' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' + +export function AutomationPromptDisclosure({ prompt }: { prompt: string }): React.JSX.Element { + const contentId = useId() + const expandedRef = useRef(false) + const toggleRef = useRef(null) + const [promptElement, setPromptElement] = useState(null) + const [expanded, setExpanded] = useState(false) + const [overflows, setOverflows] = useState(false) + + const measureOverflow = useCallback((element: HTMLParagraphElement) => { + const nextOverflows = element.scrollHeight > element.clientHeight + 1 + if (!nextOverflows && document.activeElement === toggleRef.current) { + element.focus({ preventScroll: true }) + } + setOverflows((current) => (current === nextOverflows ? current : nextOverflows)) + }, []) + + useEffect(() => { + if (!promptElement || expanded) { + return + } + + const updateOverflow = () => { + if (!expandedRef.current) { + measureOverflow(promptElement) + } + } + updateOverflow() + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', updateOverflow) + return () => window.removeEventListener('resize', updateOverflow) + } + + const observer = new ResizeObserver(updateOverflow) + observer.observe(promptElement) + return () => observer.disconnect() + }, [expanded, measureOverflow, promptElement]) + + const toggleExpanded = (): void => { + const nextExpanded = !expanded + expandedRef.current = nextExpanded + setExpanded(nextExpanded) + } + + return ( +
+
+
+ {translate('auto.components.automations.AutomationEditorDialog.058c23cb3f', 'Prompt')} +
+ {overflows || expanded ? ( + + ) : null} +
+
+

+ {prompt} +

+
+
+ ) +} diff --git a/src/renderer/src/components/cmd-j/PaletteFilterFieldOptions.tsx b/src/renderer/src/components/cmd-j/PaletteFilterFieldOptions.tsx index cfea2c37f67..95372319a33 100644 --- a/src/renderer/src/components/cmd-j/PaletteFilterFieldOptions.tsx +++ b/src/renderer/src/components/cmd-j/PaletteFilterFieldOptions.tsx @@ -275,7 +275,7 @@ export function PaletteFilterFieldOptions({ aria-label={group.heading} tabIndex={-1} onKeyDown={handleListKeyDown} - className="popover-scroll-content scrollbar-sleek overflow-y-auto py-1" + className="scrollbar-sleek overflow-y-auto py-1" style={{ maxHeight: FILTER_OPTION_LIST_MAX_HEIGHT }} >
diff --git a/src/renderer/src/components/cmd-j/PaletteFilterMenu.tsx b/src/renderer/src/components/cmd-j/PaletteFilterMenu.tsx index d352363e630..d9f8b77ac7a 100644 --- a/src/renderer/src/components/cmd-j/PaletteFilterMenu.tsx +++ b/src/renderer/src/components/cmd-j/PaletteFilterMenu.tsx @@ -25,7 +25,7 @@ function CategoryRoot({ onOpenField: (field: PaletteFilterField) => void }): React.JSX.Element { return ( - + {groups.map((group) => { const selectedCount = group.selected.length @@ -183,7 +183,7 @@ export default function PaletteFilterMenu({ collisionBoundary={portalContainer ?? undefined} onKeyDown={handleKeyDown} onCloseAutoFocus={handleCloseAutoFocus} - className="w-[290px] p-0" + className="popover-wheel-scroll w-[290px] p-0" > {activeGroup == null ? ( diff --git a/src/renderer/src/components/cmd-j/palette-live-status.test.tsx b/src/renderer/src/components/cmd-j/palette-live-status.test.tsx index 9e8a51a6e80..d0a9ea6b619 100644 --- a/src/renderer/src/components/cmd-j/palette-live-status.test.tsx +++ b/src/renderer/src/components/cmd-j/palette-live-status.test.tsx @@ -37,20 +37,27 @@ function makeTerminalTab(id: string, worktreeId: string): TerminalTab { } } -function makeAgentEntry(tabId: string, state: AgentStatusState): AgentStatusEntry { +function makeAgentEntry( + tabId: string, + state: AgentStatusState, + overrides: Partial = {} +): AgentStatusEntry { return { state, prompt: '', updatedAt: Date.now(), stateStartedAt: Date.now(), paneKey: makePaneKey(tabId, LEAF), - stateHistory: [] + stateHistory: [], + ...overrides } } -function setAgentState(state: AgentStatusState): void { +function setAgentState(state: AgentStatusState, overrides: Partial = {}): void { useAppStore.setState((s) => ({ - agentStatusByPaneKey: { [makePaneKey('term-a', LEAF)]: makeAgentEntry('term-a', state) }, + agentStatusByPaneKey: { + [makePaneKey('term-a', LEAF)]: makeAgentEntry('term-a', state, overrides) + }, agentStatusEpoch: s.agentStatusEpoch + 1 })) } @@ -118,6 +125,21 @@ describe('palette live status', () => { expect(dotLabels()).toEqual(['Needs permission']) }) + it('shows monitoring when a covered pane retains a working title', async () => { + setAgentState('working', { workingMode: 'monitoring' }) + useAppStore.setState({ + tabsByWorktree: { + 'wt-a': [{ ...makeTerminalTab('term-a', 'wt-a'), title: 'claude [working]' }] + } + } as Partial) + + await render() + + expect(testContainer.querySelector('[data-spinner]')).toBeNull() + expect(testContainer.querySelector('.lucide-radio')?.classList).toContain('text-yellow-500') + expect(dotLabels()).toEqual(['Monitoring background tasks']) + }) + // Why this is the whole point: the palette body no longer subscribes to agent status, so if the // dot didn't hold its own subscription it would freeze at whatever it showed when you opened. it('re-renders the dot without re-rendering the frozen body around it', async () => { @@ -234,6 +256,31 @@ describe('palette live status', () => { expect(testContainer.querySelector('[title="Needs permission"]')).not.toBeNull() }) + it('shows monitoring with a static radio instead of the working spinner', async () => { + setAgentState('working', { workingMode: 'monitoring' }) + await act(async () => { + testRoot.render( + + } + /> + + ) + }) + + expect(testContainer.querySelector('[data-spinner]')).toBeNull() + expect(testContainer.querySelector('.lucide-radio')?.classList).toContain('text-yellow-500') + expect(dotLabels()).toEqual(['Monitoring background tasks']) + expect(testContainer.querySelector('[title="Monitoring background tasks"]')).not.toBeNull() + }) + it('shows only the content icon when a terminal-backed row is inactive', async () => { useAppStore.setState({ ptyIdsByTabId: {} diff --git a/src/renderer/src/components/cmd-j/palette-live-status.tsx b/src/renderer/src/components/cmd-j/palette-live-status.tsx index 5c755ad4164..377b1527343 100644 --- a/src/renderer/src/components/cmd-j/palette-live-status.tsx +++ b/src/renderer/src/components/cmd-j/palette-live-status.tsx @@ -9,6 +9,7 @@ import { type TabPaneInputSources } from '@/components/sidebar/smart-attention' import { cn } from '@/lib/utils' +import { isExplicitAgentStatusFresh } from '@/lib/agent-status' import { getLiveAgentStatusByWorktreeId } from '@/lib/worktree-activity-state' import { getWorktreeStatus, @@ -26,6 +27,11 @@ import { } from '@/components/tab-bar/terminal-tab-activity-status' import { translate } from '@/i18n/i18n' import type { LiveAgentWorktreeStatus } from '@/lib/worktree-activity-state' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry +} from '../../../../shared/agent-status-types' +import { parsePaneKey } from '../../../../shared/stable-pane-id' import type { BrowserWorkspace } from '../../../../shared/browser-workspace-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { Worktree } from '../../../../shared/worktree/types' @@ -33,6 +39,7 @@ import type { Worktree } from '../../../../shared/worktree/types' /** Confines the app's hottest status subscriptions here so only the dots re-render on their churn. */ type PaletteLiveStatus = { liveAgentStatusByWorktreeId: ReadonlyMap + agentStatusPaneIdsByTabId: Record> paneSources: TabPaneInputSources tabsByWorktree: Record browserTabsByWorktree: Record @@ -85,17 +92,19 @@ export function PaletteLiveStatusProvider({ // Why: `now` decides freshness, so both derivations must read it on the same tick — otherwise a // "done" dot can outlive its window while the worktree row beside it has already decayed. const now = Date.now() + const entriesByTabId = buildExplicitEntriesByTabId( + agentStatusByPaneKey, + migrationUnsupportedByPtyId + ) return { liveAgentStatusByWorktreeId: getLiveAgentStatusByWorktreeId( agentStatusByPaneKey, tabsByWorktree, now ), + agentStatusPaneIdsByTabId: buildLiveAgentStatusPaneIdsByTabId(entriesByTabId, now), paneSources: { - entriesByTabId: buildExplicitEntriesByTabId( - agentStatusByPaneKey, - migrationUnsupportedByPtyId - ), + entriesByTabId, ptyIdsByTabId, runtimePaneTitlesByTabId, terminalLayoutsByTabId @@ -124,6 +133,32 @@ export function PaletteLiveStatusProvider({ ) } +function buildLiveAgentStatusPaneIdsByTabId( + entriesByTabId: ReadonlyMap, + now: number +): Record> { + const paneIdsByTabId: Record> = {} + for (const [tabId, entries] of entriesByTabId) { + const paneIds = new Set() + for (const entry of entries) { + if ( + entry.restoredUnconfirmed !== true && + !isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS) + ) { + continue + } + const paneId = parsePaneKey(entry.paneKey)?.leafId + if (paneId) { + paneIds.add(paneId) + } + } + if (paneIds.size > 0) { + paneIdsByTabId[tabId] = paneIds + } + } + return paneIdsByTabId +} + const EMPTY_LIVE_INPUTS = Object.freeze({ agentStatusByPaneKey: {}, runtimePaneTitlesByTabId: {}, @@ -158,7 +193,10 @@ export function PaletteWorktreeStatusDot({ live.browserTabsByWorktree[worktree.id] ?? [], live.paneSources.ptyIdsByTabId, live.paneSources.runtimePaneTitlesByTabId, - { liveAgentStatus: live.liveAgentStatusByWorktreeId.get(worktree.id) } + { + liveAgentStatus: live.liveAgentStatusByWorktreeId.get(worktree.id), + agentStatusPaneIdsByTabId: live.agentStatusPaneIdsByTabId + } ) return ( <> diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx index d98592232c0..d3bff0f3936 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx @@ -10,6 +10,7 @@ import { TooltipProvider } from '@/components/ui/tooltip' import { AgentKanbanCard } from './AgentKanbanCard' const agentIconRender = vi.fn() +const agentStateDotRender = vi.fn() vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: () => { @@ -19,7 +20,10 @@ vi.mock('@/lib/agent-catalog', () => ({ })) vi.mock('@/components/AgentStateDot', () => ({ - AgentStateDot: () => + AgentStateDot: ({ state }: { state: string }) => { + agentStateDotRender(state) + return + } })) function card(overrides: Partial = {}): DashboardCard { @@ -284,6 +288,24 @@ describe('AgentKanbanCard', () => { expect(screen.getByText('2m')).toBeInTheDocument() }) + it('rerenders when a working card enters monitoring', () => { + const initial = card() + const { rerender } = renderCard({ card: initial, now: 2_000 }) + expect(agentStateDotRender).toHaveBeenLastCalledWith('working') + + rerender( + + + + ) + + expect(agentStateDotRender).toHaveBeenLastCalledWith('monitoring') + }) + it('rerenders when the repo icon changes', () => { const onOpenTerminal = vi.fn() const initial = card({ startedAt: 1_000 }) diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx index faef58dc7b4..f2cc370dcdd 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx @@ -87,6 +87,7 @@ function sameCard(a: DashboardCard, b: DashboardCard): boolean { a.agentType === b.agentType && a.bucket === b.bucket && a.dotState === b.dotState && + a.workingMode === b.workingMode && a.task === b.task && a.lastUserMessage === b.lastUserMessage && a.lastAgentMessage === b.lastAgentMessage && diff --git a/src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx index 4a6e29c15a9..56039ad4db7 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx @@ -84,6 +84,7 @@ describe('AgentMapScene project labels', () => { agents: [], statusCounts: { working: 0, + monitoring: 0, blocked: 0, waiting: 0, done: 0, diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx index f4999a012bd..55a2d5f547e 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx @@ -63,7 +63,10 @@ function WorktreeDetails({ onDone: () => void }): React.JSX.Element { const activeCount = - worktree.statusCounts.working + worktree.statusCounts.blocked + worktree.statusCounts.waiting + worktree.statusCounts.working + + worktree.statusCounts.monitoring + + worktree.statusCounts.blocked + + worktree.statusCounts.waiting const doneCount = worktree.statusCounts.done + worktree.statusCounts['done-seen'] return ( diff --git a/src/renderer/src/components/dashboard-popout/agent-map-filter.ts b/src/renderer/src/components/dashboard-popout/agent-map-filter.ts index 455d32892ed..698bd64b49c 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-filter.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-filter.ts @@ -22,6 +22,9 @@ export function agentMapState(card: DashboardCard): AgentMapState { if (state === 'done-seen') { return 'done' } + if (state === 'monitoring') { + return 'working' + } return state } diff --git a/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts index 841197437e6..67081f39084 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts @@ -137,7 +137,12 @@ function baselineBox( * idle neighbour has to drop its own. */ function labelPriority(worktree: AgentMapWorktreeRing): number { const attention = worktree.statusCounts.blocked + worktree.statusCounts.waiting - return attention * 1_000_000 + worktree.statusCounts.working * 10_000 + worktree.agents.length + return ( + attention * 1_000_000 + + worktree.statusCounts.working * 10_000 + + worktree.statusCounts.monitoring * 1_000 + + worktree.agents.length + ) } /** Worth drawing before collisions are considered: all-idle workspaces stay diff --git a/src/renderer/src/components/dashboard-popout/agent-map-layout.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-layout.test.ts index 3ac0f104f34..3304c45537f 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-layout.test.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-layout.test.ts @@ -551,6 +551,9 @@ describe('agent map layout', () => { for (const dotState of ['working', 'blocked', 'waiting', 'idle'] as const) { expect(agentMapNodeStatus(card({ dotState }))).toBe(dotState) } + expect(agentMapNodeStatus(card({ dotState: 'working', workingMode: 'monitoring' }))).toBe( + 'monitoring' + ) expect(agentMapNodeStatus(card({ dotState: 'done', unseen: true }))).toBe('done') expect(agentMapNodeStatus(card({ dotState: 'done', unseen: false }))).toBe('done-seen') const shortBlocked = card({ dotState: 'blocked', startedAt: NOW - 60_000 }) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-node-metadata.ts b/src/renderer/src/components/dashboard-popout/agent-map-node-metadata.ts index 6a3b004af26..34db8ccfc31 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-node-metadata.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-node-metadata.ts @@ -1,6 +1,7 @@ import { dashboardCardDisplayState, type DashboardCard, + type DashboardCardDisplayState, type DashboardCardDotState } from '../../../../shared/dashboard-snapshot' @@ -8,7 +9,7 @@ import { * acknowledged finish into `idle`, which is right for bucket counts but loses the one * distinction the map exists to show: finished-and-unread vs finished-and-still-yours. * Kept local so `DashboardCardDotState` — which crosses the pop-out bridge — is unchanged. */ -export type AgentMapNodeStatus = DashboardCardDotState | 'done-seen' +export type AgentMapNodeStatus = DashboardCardDisplayState | 'done-seen' export function agentMapDurationMinutes(card: DashboardCard, now: number): number { if (!Number.isFinite(card.startedAt) || card.startedAt <= 0) { @@ -89,7 +90,7 @@ export function selectAgentMapRecentFlareStatuses( export type AgentMapStatusCounts = Record export function emptyAgentMapStatusCounts(): AgentMapStatusCounts { - return { working: 0, blocked: 0, waiting: 0, done: 0, 'done-seen': 0, idle: 0 } + return { working: 0, monitoring: 0, blocked: 0, waiting: 0, done: 0, 'done-seen': 0, idle: 0 } } /** Finished work you have already opened is still yours to land, but it is not asking for diff --git a/src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.test.ts index 58c98e56a38..1ecbdb0beb1 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.test.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.test.ts @@ -35,4 +35,9 @@ describe('agentMapWorktreeActiveStatus', () => { expect(agentMapWorktreeActiveStatus(counts({ done: 2, idle: 1 }))).toBe('done') expect(agentMapWorktreeActiveStatus(counts({ 'done-seen': 2, idle: 1 }))).toBeNull() }) + + it('keeps passive monitoring out of the active-worktree glow', () => { + expect(agentMapWorktreeActiveStatus(counts({ monitoring: 1 }))).toBeNull() + expect(agentMapWorktreeActiveStatus(counts({ working: 1, monitoring: 1 }))).toBe('working') + }) }) diff --git a/src/renderer/src/components/dashboard-popout/agent-map.css b/src/renderer/src/components/dashboard-popout/agent-map.css index af1844aacd5..189540305a7 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map.css +++ b/src/renderer/src/components/dashboard-popout/agent-map.css @@ -420,6 +420,10 @@ stroke: var(--color-yellow-500); } +.fleet-status-monitoring .agent-map-agent-mark { + stroke: var(--color-yellow-500); +} + .fleet-status-blocked .agent-map-agent-mark { stroke: var(--color-red-500); } diff --git a/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts index 98f9ec369c5..1d2de1e3624 100644 --- a/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts +++ b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts @@ -86,6 +86,19 @@ describe('patchDashboardSnapshotFromAgentStatus', () => { }) }) + it('patches same-state working into passive monitoring', () => { + const result = patchDashboardSnapshotFromAgentStatus( + snapshot(), + event({ state: 'working', workingMode: 'monitoring', stateStartedAt: 100 }) + ) + + expect(result.snapshot.cards[0]).toMatchObject({ + bucket: 'working', + dotState: 'working', + workingMode: 'monitoring' + }) + }) + it('ignores stale, wrong-workspace, and session-only events', () => { const original = snapshot() expect( diff --git a/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts index 816474eb70f..33ac5a1f737 100644 --- a/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts +++ b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts @@ -49,7 +49,11 @@ export function patchDashboardSnapshotFromAgentStatus( const stateChanged = event.stateStartedAt > card.stateChangedAt const unseen = stateChanged ? card.startedAt !== 0 : card.unseen const dotState = event.state - const bucket = dashboardBucketForDotState(dashboardCardDisplayState({ dotState, unseen })) + const workingMode = + event.state === 'working' && event.workingMode === 'monitoring' ? event.workingMode : undefined + const bucket = dashboardBucketForDotState( + dashboardCardDisplayState({ dotState, workingMode, unseen }) + ) const nextCard: DashboardCard = { ...card, ...(event.agentType ? { agentType: event.agentType } : {}), @@ -62,6 +66,7 @@ export function patchDashboardSnapshotFromAgentStatus( : {}), bucket, dotState, + workingMode, unseen, stateChangedAt: stateChanged ? event.stateStartedAt : card.stateChangedAt, statusUpdatedAt: event.receivedAt, diff --git a/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx b/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx index 9a9c4e45a74..3376be9102a 100644 --- a/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx +++ b/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx @@ -4,9 +4,10 @@ import { act } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { renderHook } from '@testing-library/react' import type { DashboardCard, DashboardSnapshot } from '../../../../shared/dashboard-snapshot' -import type { - AgentStatusClearIpcPayload, - AgentStatusIpcPayload +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusClearIpcPayload, + type AgentStatusIpcPayload } from '../../../../shared/agent-status-types' import { useDashboardSnapshot } from './useDashboardSnapshot' @@ -238,4 +239,31 @@ describe('useDashboardSnapshot', () => { expect(requestSnapshot).toHaveBeenCalledTimes(1) vi.useRealTimers() }) + + it('refreshes when a monitoring card crosses the stale boundary', () => { + vi.useFakeTimers() + const statusUpdatedAt = 1_000 + vi.setSystemTime(statusUpdatedAt) + renderHook(() => useDashboardSnapshot()) + act(() => + apply( + snapshot([ + card({ + dotState: 'working', + workingMode: 'monitoring', + statusUpdatedAt + }) + ]) + ) + ) + requestSnapshot.mockClear() + + act(() => vi.advanceTimersByTime(AGENT_STATUS_STALE_AFTER_MS)) + expect(requestSnapshot).not.toHaveBeenCalled() + act(() => vi.advanceTimersByTime(250)) + expect(requestSnapshot).not.toHaveBeenCalled() + act(() => vi.advanceTimersByTime(1)) + expect(requestSnapshot).toHaveBeenCalledTimes(1) + vi.useRealTimers() + }) }) diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx index 4ce41472d49..9a95a6293e8 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx @@ -348,6 +348,26 @@ describe('DashboardAgentRow', () => { expect(activeToolMarkup).toContain('ListDir') }) + it('renders monitoring without a spinner or active tool line', () => { + const markup = renderRow( + makeAgent( + {}, + { + workingMode: 'monitoring', + prompt: '', + toolName: 'Bash', + toolInput: 'pnpm dev' + } + ) + ) + + expect(markup).toContain('Monitoring background tasks') + expect(markup).toContain('lucide-radio') + expect(classTokens(markup)).toContain('text-yellow-500') + expect(markup).not.toContain('data-agent-spinner') + expect(markup).not.toContain('data-agent-row-tool-slot') + }) + it('names what a blocked approval is waiting on', () => { const markup = renderRow( makeAgent( diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index 96c52f12d22..8bc76da2709 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -16,9 +16,13 @@ import { useAgentRowConversationName } from './use-agent-row-conversation-name' import { lastEnteredDoneAt } from './agent-finished-timestamp' // Why: narrow the dashboard's rollup states to shared dot states, defaulting unknowns to 'idle' so a row never crashes. -function asDotState(state: AgentStatusState | 'idle'): AgentDotState { +function asDotState( + state: AgentStatusState | 'idle', + workingMode?: DashboardAgentRowData['entry']['workingMode'] +): AgentDotState { switch (state) { case 'working': + return workingMode === 'monitoring' ? 'monitoring' : 'working' case 'blocked': case 'waiting': case 'done': @@ -143,12 +147,14 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ const conversationName = useAgentRowConversationName(agent) const prompt = conversationName ?? getAgentRowPrimaryText(agent.entry) // Why: prompt is '' when unknown, so fall back to the state label to keep the row labeled. - const displayLabel = prompt || agentStateLabel(asDotState(agent.state)) + const displayLabel = prompt || agentStateLabel(asDotState(agent.state, agent.entry.workingMode)) const model = agent.entry.model?.trim() ?? '' - const isWorking = agent.state === 'working' + const isMonitoring = agent.state === 'working' && agent.entry.workingMode === 'monitoring' + const isWorking = agent.state === 'working' && !isMonitoring // Why: 'working' names the running tool and 'waiting' names what an approval is blocked on; // anywhere else a leftover tool line reads as still-running. See showsAgentToolPreview. - const showsTool = showsAgentToolPreview(agent.state) + // Monitoring is excluded too: the lead turn is over, so its last tool line is stale. + const showsTool = showsAgentToolPreview(agent.state) && !isMonitoring const toolName = showsTool ? (agent.entry.toolName?.trim() ?? '') : '' const toolInput = showsTool ? (agent.entry.toolInput?.trim() ?? '') : '' const lastAssistantMessage = agent.entry.lastAssistantMessage?.trim() ?? '' @@ -164,7 +170,9 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ }` : [formatAgentTypeLabel(agent.agentType), model].filter(Boolean).join(' · ') // Why: interrupted is a terminal outcome, so surface it in the leading state dot. - const dotState: AgentDotState = isInterrupted ? 'interrupted' : asDotState(agent.state) + const dotState: AgentDotState = isInterrupted + ? 'interrupted' + : asDotState(agent.state, agent.entry.workingMode) const dotTooltipLabel = stateDotTooltipLabel(agent, dotState) // Why: always show the chevron so the row's right edge doesn't flicker as content grows/shrinks. diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts index ad032c1684d..bae9892736d 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts @@ -201,6 +201,23 @@ describe('buildDashboardSnapshot', () => { expect(card.unseen).toBe(true) }) + it('keeps monitoring in the working bucket with a passive dot state', () => { + const snapshot = buildDashboardSnapshot( + baseState({ + agentStatusByPaneKey: { + [PANE_KEY]: entry({ state: 'working', workingMode: 'monitoring' }) + } + }), + NOW + ) + + expect(snapshot.cards[0]).toMatchObject({ + bucket: 'working', + dotState: 'working', + workingMode: 'monitoring' + }) + }) + it('publishes terminal-backed orchestrated workers under their direct parent', () => { const snapshot = buildDashboardSnapshot( baseState({ diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts index d6d5e6545ff..e0091066972 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts @@ -210,10 +210,16 @@ export function buildDashboardSnapshot( ? layoutPtyId : null const dotState = row.state as DashboardCardDotState + const workingMode = + row.state === 'working' && row.entry.workingMode === 'monitoring' + ? row.entry.workingMode + : undefined const unseen = !isTitleDerived && (state.acknowledgedAgentsByPaneKey?.[row.paneKey] ?? 0) < row.entry.stateStartedAt - const bucket = dashboardBucketForDotState(dashboardCardDisplayState({ dotState, unseen })) + const bucket = dashboardBucketForDotState( + dashboardCardDisplayState({ dotState, workingMode, unseen }) + ) // Why: only a live pty can open a preview terminal, and only a // card-rendering caller can open one — the sidebar's bucket counts must // not pay host resolution on every agent-status tick. @@ -249,6 +255,7 @@ export function buildDashboardSnapshot( agentType: row.agentType, bucket, dotState, + ...(workingMode ? { workingMode } : {}), task: isTitleDerived ? '' : rowTask(row), repoId: workspace.projectId, worktreeId, diff --git a/src/renderer/src/components/dashboard/dashboard-card-bucket.ts b/src/renderer/src/components/dashboard/dashboard-card-bucket.ts index a81129a227c..b02b6d7d0f7 100644 --- a/src/renderer/src/components/dashboard/dashboard-card-bucket.ts +++ b/src/renderer/src/components/dashboard/dashboard-card-bucket.ts @@ -1,8 +1,12 @@ -import type { DashboardBucket, DashboardCardDotState } from '../../../../shared/dashboard-snapshot' +import type { + DashboardBucket, + DashboardCardDisplayState +} from '../../../../shared/dashboard-snapshot' -export function dashboardBucketForDotState(state: DashboardCardDotState): DashboardBucket { +export function dashboardBucketForDotState(state: DashboardCardDisplayState): DashboardBucket { switch (state) { case 'working': + case 'monitoring': return 'working' case 'done': return 'done' diff --git a/src/renderer/src/components/dashboard/useRetainedAgents.test.ts b/src/renderer/src/components/dashboard/useRetainedAgents.test.ts index 2e3b60f1157..d1440f4e5ef 100644 --- a/src/renderer/src/components/dashboard/useRetainedAgents.test.ts +++ b/src/renderer/src/components/dashboard/useRetainedAgents.test.ts @@ -1,6 +1,6 @@ // @vitest-environment happy-dom import { act, renderHook } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' import { resetAgentPaneAuthorityAliasesForTests } from '@/store/slices/agent-pane-authority' import type { AgentStatusEntry, AgentStatusState } from '../../../../shared/agent-status-types' @@ -32,7 +32,7 @@ function makeRepo(): Repo { } } -function makeWorktree(): Worktree { +function makeWorktree(overrides?: Partial): Worktree { return { id: 'wt-1', repoId: 'repo-1', @@ -50,7 +50,8 @@ function makeWorktree(): Worktree { isUnread: false, isPinned: false, sortOrder: 0, - lastActivityAt: 1 + lastActivityAt: 1, + ...overrides } } @@ -227,6 +228,103 @@ describe('collectRetainedAgentsOnDisappear', () => { }) describe('useRetainedAgentsSync', () => { + it('does not build a fleet snapshot for a title-only publication across 300 worktrees', async () => { + const repo = makeRepo() + const worktrees = Array.from({ length: 300 }, (_, index) => + makeWorktree({ id: `wt-${index}`, path: `/repo/wt-${index}` }) + ) + const tabsByWorktree = Object.fromEntries( + worktrees.map((worktree, index) => [ + worktree.id, + [makeTab({ id: `tab-${index}`, worktreeId: worktree.id, title: `Title ${index}` })] + ]) + ) + const retainAgents = vi.fn() + const pruneRetainedAgents = vi.fn() + useAppStore.setState({ + repos: [repo], + worktreesByRepo: { [repo.id]: worktrees }, + tabsByWorktree, + retainAgents, + pruneRetainedAgents + }) + const hook = renderHook(() => useRetainedAgentsSync()) + await act(async () => { + await Promise.resolve() + }) + retainAgents.mockClear() + pruneRetainedAgents.mockClear() + + const changedWorktreeId = 'wt-173' + act(() => { + useAppStore.setState({ + tabsByWorktree: { + ...tabsByWorktree, + [changedWorktreeId]: [ + { + ...tabsByWorktree[changedWorktreeId][0], + title: 'Updated display title' + } + ] + } + }) + }) + await act(async () => { + await Promise.resolve() + }) + + expect(retainAgents).not.toHaveBeenCalled() + expect(pruneRetainedAgents).not.toHaveBeenCalled() + hook.unmount() + }) + + it('uses the freshest tab title when a real agent transition retains a completed row', async () => { + const repo = makeRepo() + const worktree = makeWorktree() + const paneKey = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') + const row = makeAgentRow({ paneKey, state: 'done' }) + useAppStore.setState({ + repos: [repo], + worktreesByRepo: { [repo.id]: [worktree] }, + tabsByWorktree: { + [worktree.id]: [{ ...row.tab, title: '⠋ Codex is thinking' }] + }, + agentStatusByPaneKey: { [paneKey]: row.entry }, + agentStatusEpoch: initialAppState.agentStatusEpoch + 1 + }) + const hook = renderHook(() => useRetainedAgentsSync()) + await act(async () => { + await Promise.resolve() + }) + + act(() => { + useAppStore.setState((state) => ({ + tabsByWorktree: { + [worktree.id]: [ + { + ...state.tabsByWorktree[worktree.id][0], + title: '⠙ Codex is thinking' + } + ] + } + })) + }) + act(() => { + useAppStore.setState((state) => ({ + agentStatusByPaneKey: {}, + agentStatusEpoch: state.agentStatusEpoch + 1 + })) + }) + await act(async () => { + await Promise.resolve() + }) + + expect(useAppStore.getState().retainedAgentsByPaneKey[paneKey]?.tab.title).toBe( + '⠙ Codex is thinking' + ) + hook.unmount() + }) + it('does not re-retain when status removal and tab closure commit before the next retention effect', async () => { const repo = makeRepo() const worktree = makeWorktree() diff --git a/src/renderer/src/components/dashboard/useRetainedAgents.ts b/src/renderer/src/components/dashboard/useRetainedAgents.ts index 883da974142..2e39205a84c 100644 --- a/src/renderer/src/components/dashboard/useRetainedAgents.ts +++ b/src/renderer/src/components/dashboard/useRetainedAgents.ts @@ -16,6 +16,10 @@ import { } from '../../../../shared/agent-status-types' import { parsePaneKey } from '../../../../shared/stable-pane-id' +import { + createWorktreeTabBucketProjection, + type WorktreeTabBucketProjection +} from '@/lib/worktree-tab-bucket-projection' // Why: when an agent finishes or its terminal closes, the store cleans up the // explicit status entry and the agent vanishes from the live status set. // Retaining the last-known "done" snapshot in the store lets the inline @@ -35,6 +39,16 @@ type RetainedAgentsSyncInputs = { type RetainedAgentsSyncSnapshotInputs = RetainedAgentsSyncInputs & { now: number } +export function createRetainedAgentsTabTopologyProjection( + onInspectBucket?: (worktreeId: string) => void +) { + return createWorktreeTabBucketProjection({ + projectTab: (tab) => tab, + isSameProjectedTab: (previousTab, nextTab) => + previousTab.id === nextTab.id && previousTab.worktreeId === nextTab.worktreeId, + onInspectBucket + }) +} function paneKeyTabId(paneKey: string): string | null { return parsePaneKey(paneKey)?.tabId ?? null @@ -122,17 +136,22 @@ export function buildRetainedAgentsSyncSnapshot(args: RetainedAgentsSyncSnapshot } export function useRetainedAgentsSync(): void { + const tabTopologyProjectionRef = useRef | null>(null) + tabTopologyProjectionRef.current ??= createRetainedAgentsTabTopologyProjection() const retainAgents = useAppStore((s) => s.retainAgents) const pruneRetainedAgents = useAppStore((s) => s.pruneRetainedAgents) const clearRetentionSuppressedPaneKeys = useAppStore((s) => s.clearRetentionSuppressedPaneKeys) - const [repos, worktreesByRepo, folderWorkspaces, tabsByWorktree, agentStatusEpoch] = useAppStore( + const [repos, worktreesByRepo, folderWorkspaces, tabTopology, agentStatusEpoch] = useAppStore( useShallow( (s) => [ s.repos, s.worktreesByRepo, s.folderWorkspaces, - s.tabsByWorktree, + tabTopologyProjectionRef.current!.project(s.tabsByWorktree), s.agentStatusEpoch ] as const ) @@ -150,11 +169,11 @@ export function useRetainedAgentsSync(): void { now: Date.now() }) - // Why: read retention state via getState() after the cheap ref/epoch gate - // fires. Building the full retention snapshot scans all agents, so do it - // only when live identity/state/freshness/final-done data or worktree - // membership changes. Subscribing to retainedAgentsByPaneKey would create - // a feedback loop because this effect calls retainAgents. + // Why: read retention state via getState() after the cheap topology/epoch + // gate fires. Building the full retention snapshot scans all agents, so + // display-title-only publications must not enter this effect. The fresh + // store read still supplies current tab objects whenever a real topology + // or agent transition does run. const { retainedAgentsByPaneKey: retainedNow, retentionSuppressedPaneKeys } = state const { toRetain, consumedSuppressedPaneKeys } = collectRetainedAgentsOnDisappear({ previousAgents: prevAgentsRef.current, @@ -181,7 +200,7 @@ export function useRetainedAgentsSync(): void { repos, worktreesByRepo, folderWorkspaces, - tabsByWorktree, + tabTopology, agentStatusEpoch, retainAgents, pruneRetainedAgents, @@ -273,9 +292,8 @@ export function collectRetainedAgentsOnDisappear(args: { // Why: prefer the real destination tab — reusing the source tab's title and // launchAgent under a different id would mislabel the retained row. const ownerTab = - ownerTabId === prev.row.tab.id - ? prev.row.tab - : (args.tabIndex?.get(ownerTabId)?.tab ?? { ...prev.row.tab, id: ownerTabId }) + args.tabIndex?.get(ownerTabId)?.tab ?? + (ownerTabId === prev.row.tab.id ? prev.row.tab : { ...prev.row.tab, id: ownerTabId }) // Why: PTY exit can remove the live row before closeTab plants a suppressor; // the closed-tab marker prevents re-retention. if (args.recentlyClosedAgentStatusTabIds[ownerTabId]) { diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx index adb0c3d1ed8..adde4669ef4 100644 --- a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx +++ b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx @@ -245,7 +245,7 @@ function AgentTargetMenuItem({ onSend: (target: NotesSendAgentTarget) => void }): React.JSX.Element { const tabTitle = target.tabTitle.trim() - const state = asDotState(agent?.state ?? 'idle') + const state = asDotState(agent?.state ?? 'idle', agent?.entry.workingMode) const timeAgo = agent ? formatAgentRelativeTime(agent, now) : null const secondaryParts = [ agentStateLabel(state), @@ -302,9 +302,13 @@ function orderSendTargetsByWorktreeAgentRows( return ordered } -function asDotState(state: AgentStatusState | 'idle'): AgentDotState { +function asDotState( + state: AgentStatusState | 'idle', + workingMode?: DashboardAgentRowData['entry']['workingMode'] +): AgentDotState { switch (state) { case 'working': + return workingMode === 'monitoring' ? 'monitoring' : 'working' case 'blocked': case 'waiting': case 'done': diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index f37a6968c36..ca6b25f6e07 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -896,8 +896,8 @@ export function FloatingTerminalPanel({ if (item.contentType === 'terminal') { closeTab(item.entityId, { reason: 'cleanup' }) } else if (item.contentType === 'browser') { - destroyWorkspaceWebviews(state.browserPagesByWorkspace, item.entityId) closeBrowserTab(item.entityId) + destroyWorkspaceWebviews(state.browserPagesByWorkspace, item.entityId) } else if (item.contentType === 'simulator') { closeUnifiedTab(item.id) } else { @@ -952,8 +952,8 @@ export function FloatingTerminalPanel({ onClose: () => { const latest = useAppStore.getState() if (item.contentType === 'browser') { - destroyWorkspaceWebviews(latest.browserPagesByWorkspace, item.entityId) closeBrowserTab(item.entityId) + destroyWorkspaceWebviews(latest.browserPagesByWorkspace, item.entityId) } else if (item.contentType === 'simulator') { closeUnifiedTab(item.id) } else { diff --git a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx index 211a73bd2f6..250f4a43468 100644 --- a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx +++ b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx @@ -30,7 +30,7 @@ import { cn } from '@/lib/utils' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { useAppStore } from '@/store' import { useMountedRef } from '@/hooks/useMountedRef' -import { projectViewCacheKey } from '@/store/slices/github' +import { projectViewCacheKey } from '@/store/github/cache-identity' import type { GitHubIssueType, GitHubProjectFieldMutationValue, diff --git a/src/renderer/src/components/native-chat/use-native-chat-hook-status.ts b/src/renderer/src/components/native-chat/use-native-chat-hook-status.ts index 0d462dd067d..687dbc83ad2 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-hook-status.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-hook-status.ts @@ -6,7 +6,12 @@ export function useNativeChatHookStatus( ): readonly [AgentStatusState | null, number | null, boolean] { // Why: primitive selectors keep unrelated pane/status updates from rerendering // native chat while still exposing the three fields used for reconciliation. - const state = useAppStore((store) => store.agentStatusByPaneKey[paneKey]?.state ?? null) + const state = useAppStore((store) => { + const entry = store.agentStatusByPaneKey[paneKey] + return entry?.state === 'working' && entry.workingMode === 'monitoring' + ? null + : (entry?.state ?? null) + }) const stateStartedAt = useAppStore( (store) => store.agentStatusByPaneKey[paneKey]?.stateStartedAt ?? null ) diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts index 3d3a3206cc5..3bb2e2785e4 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts @@ -401,6 +401,32 @@ describe('useNativeChatLiveSession — transport routing', () => { expect(latest?.status).toBe('ready') }) + it('stops foreground working UI when Claude enters monitoring', async () => { + useAppStore.setState({ + agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } as never } + }) + const transport = getMockTransport('env-1') + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + await act(async () => + transport.emit({ + type: 'snapshot', + messages: [user('u-1', 'run it')], + hasMore: false + }) + ) + expect(latest?.status).toBe('working') + + await act(async () => { + useAppStore.setState({ + agentStatusByPaneKey: { + [PANE]: { state: 'working', workingMode: 'monitoring', stateStartedAt: 1 } as never + } + }) + }) + + expect(latest?.status).toBe('ready') + }) + it('applies a lifecycle-only append after the final message frame', async () => { useAppStore.setState({ agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } as never } diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx index 58e771477dd..47040268575 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx @@ -61,6 +61,7 @@ import { getVisibleBranchResults, getVisibleHeldProviderResults, isBlockingJiraUrlIntent, + isBlockingTaskUrlResolution, type SmartNameMode, type SmartWorkspaceSourceRow } from './smart-workspace-source-results' @@ -639,6 +640,20 @@ export default function SmartWorkspaceNameField({ () => (sourceQueryWithinLimit ? parseGitHubIssueOrPRLink(debouncedQuery) : null), [debouncedQuery, sourceQueryWithinLimit] ) + const githubUrlIntent = useMemo( + () => + isSmartWorkspaceSourceQueryWithinLimit(value) && (mode === 'smart' || mode === 'github') + ? parseGitHubIssueOrPRLink(value) + : null, + [mode, value] + ) + const gitlabUrlIntent = useMemo( + () => + isSmartWorkspaceSourceQueryWithinLimit(value) && (mode === 'smart' || mode === 'gitlab') + ? parseGitLabIssueOrMRLink(value) + : null, + [mode, value] + ) const linearUrlIntent = useMemo( () => parseBoundedSmartWorkspaceLinearIssueUrlIntent(value), [value] @@ -1270,12 +1285,14 @@ export default function SmartWorkspaceNameField({ value, debouncedQuery }), + githubUrlIntent, gitlabAvailable: gitlabSourceAvailable, gitlabItems: getVisibleHeldProviderResults({ items: gitlabItems, value, debouncedQuery }), + gitlabUrlIntent, jiraIntent: jiraSource.intent, jiraIssue: jiraSource.issue, jiraIssues: getVisibleHeldProviderResults({ @@ -1302,8 +1319,10 @@ export default function SmartWorkspaceNameField({ branchResultsSource, debouncedQuery, githubItems, + githubUrlIntent, gitlabSourceAvailable, gitlabItems, + gitlabUrlIntent, jiraSource.accountChoices, jiraSource.intent, jiraSource.issue, @@ -1369,6 +1388,12 @@ export default function SmartWorkspaceNameField({ linearAvailable && sourceIntent !== 'linear' && (linearLoading || settledLinearUrlQuery !== linearQuery.trim()) + const blockingTaskUrlResolution = isBlockingTaskUrlResolution({ + sourceIntent: sourceIntent === 'github' || sourceIntent === 'gitlab' ? sourceIntent : null, + isQueryStale, + githubLoading, + gitlabLoading + }) const resolvedCommandValue = resolveSmartWorkspaceCommandValue({ currentValue: commandValue, @@ -1975,7 +2000,7 @@ export default function SmartWorkspaceNameField({ handleEmojiSelect(selectedEmojiSuggestion) return } - if (unresolvedLinearUrlIntent) { + if (unresolvedLinearUrlIntent || blockingTaskUrlResolution) { event.preventDefault() return } diff --git a/src/renderer/src/components/new-workspace/smart-workspace-source-results.test.ts b/src/renderer/src/components/new-workspace/smart-workspace-source-results.test.ts index 780800bb9a4..e78b6d5d76c 100644 --- a/src/renderer/src/components/new-workspace/smart-workspace-source-results.test.ts +++ b/src/renderer/src/components/new-workspace/smart-workspace-source-results.test.ts @@ -7,6 +7,7 @@ import { getVisibleBranchResults, getVisibleHeldProviderResults, isBlockingJiraUrlIntent, + isBlockingTaskUrlResolution, shouldHoldSourceResultsForQuery } from './smart-workspace-source-results' import { @@ -684,6 +685,38 @@ describe('Linear issue source input', () => { }) }) +describe('Task URL Enter protection', () => { + it('blocks Enter while a pasted GitHub or GitLab URL is resolving', () => { + expect( + isBlockingTaskUrlResolution({ + sourceIntent: 'github', + isQueryStale: true, + githubLoading: false, + gitlabLoading: false + }) + ).toBe(true) + expect( + isBlockingTaskUrlResolution({ + sourceIntent: 'gitlab', + isQueryStale: false, + githubLoading: false, + gitlabLoading: true + }) + ).toBe(true) + }) + + it('keeps the workspace-name fallback after lookup settles without a match', () => { + expect( + isBlockingTaskUrlResolution({ + sourceIntent: 'github', + isQueryStale: false, + githubLoading: false, + gitlabLoading: false + }) + ).toBe(false) + }) +}) + describe('Jira issue search', () => { it('keeps valid Jira URL intent blocking in both Smart and Jira modes', () => { const firstUrl = 'https://company.atlassian.net/browse/ORCA-1' diff --git a/src/renderer/src/components/pet/pet-agent-state.test.ts b/src/renderer/src/components/pet/pet-agent-state.test.ts index 09dc0813b5b..98514eb66ca 100644 --- a/src/renderer/src/components/pet/pet-agent-state.test.ts +++ b/src/renderer/src/components/pet/pet-agent-state.test.ts @@ -46,6 +46,14 @@ describe('selectPetAnimationName', () => { expect(select([entry('working')])).toBe('running') }) + it('keeps passive monitoring out of the running animation', () => { + const monitoring = entry('working', { workingMode: 'monitoring' }) + + expect(select([monitoring])).toBe('idle') + expect(select([monitoring, entry('working')])).toBe('running') + expect(select([monitoring, entry('done')])).toBe('review') + }) + it('maps blocked and waiting states to waiting', () => { expect(select([entry('blocked')])).toBe('waiting') expect(select([entry('waiting')])).toBe('waiting') diff --git a/src/renderer/src/components/pet/pet-agent-state.ts b/src/renderer/src/components/pet/pet-agent-state.ts index 72a0f520f66..bc5a711ce62 100644 --- a/src/renderer/src/components/pet/pet-agent-state.ts +++ b/src/renderer/src/components/pet/pet-agent-state.ts @@ -53,7 +53,7 @@ function agentStateAnimation( if (entry.state === 'blocked' || entry.state === 'waiting') { return 'waiting' } - if (entry.state === 'working') { + if (entry.state === 'working' && entry.workingMode !== 'monitoring') { hasWorking = true } else if (entry.state === 'done') { hasDone = true diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 7c81834319c..6201871ab3a 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -15,11 +15,10 @@ import { import { useAppStore, type AppState } from '@/store' import { buildGitHubPRRefreshStateClearToken, - getGitHubPRRefreshStateExpiryAt, - mergePRCommentIntoList, - prChecksCacheSuffix, - prCommentsCacheSuffix -} from '@/store/slices/github' + getGitHubPRRefreshStateExpiryAt +} from '@/store/github/pr-refresh-state' +import { mergePRCommentIntoList } from '@/store/github/pr-comment-cache' +import { prChecksCacheSuffix, prCommentsCacheSuffix } from '@/store/github/cache-identity' import { getGitHubPRCacheKey, getGitHubRepoCacheKey } from '@/store/slices/github-cache-key' import { useActiveWorktree, useRepoById } from '@/store/selectors' import { useChecksPanelTerminalWorktree } from './use-checks-panel-terminal-worktree' diff --git a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx index 4ee957c5001..90c17779112 100644 --- a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx +++ b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx @@ -12,7 +12,7 @@ import type { HostedReviewInfo } from '../../../../shared/hosted-review' import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope' import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' import { getGitHubRepoCacheKey } from '@/store/slices/github-cache-key' -import { prChecksCacheSuffix } from '@/store/slices/github' +import { prChecksCacheSuffix } from '@/store/github/cache-identity' type MockStoreState = { activeWorktreeId: string | null diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts index 0c64607853e..b1f72178b24 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts @@ -4,6 +4,7 @@ import { type AiVaultListResult, type AiVaultSession } from '../../../../shared/ai-vault-types' +import { describeAiVaultScanError } from '../../../../shared/ai-vault-scan-error-message' import { ALL_EXECUTION_HOSTS_SCOPE, requestedExecutionHostScope, @@ -193,7 +194,7 @@ export function useAiVaultSessionRefresh( refreshIdRef.current === refreshId && scanKey === currentScanScopeKey() ) { - setError(err instanceof Error ? err.message : String(err)) + setError(describeAiVaultScanError(err instanceof Error ? err.message : String(err))) } } finally { refreshInFlightRef.current = false diff --git a/src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-breadcrumb.test.ts b/src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-breadcrumb.test.ts index 325c7409246..ae20fa2aba0 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-breadcrumb.test.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-breadcrumb.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import type { PRRefreshState } from '@/store/slices/github' +import type { PRRefreshState } from '@/store/github/pr-refresh-state' import { buildChecksPanelPRRefreshBreadcrumbData, recordChecksPanelPRRefreshBreadcrumb diff --git a/src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-breadcrumb.ts b/src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-breadcrumb.ts index d2925dc64c3..c3d4f2b35a8 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-breadcrumb.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-breadcrumb.ts @@ -1,5 +1,8 @@ import { recordRendererCrashBreadcrumb } from '@/lib/crash-diagnostics' -import { getGitHubPRRefreshStateExpiryAt, type PRRefreshState } from '@/store/slices/github' +import { + getGitHubPRRefreshStateExpiryAt, + type PRRefreshState +} from '@/store/github/pr-refresh-state' import type { CrashReportBreadcrumbData, CrashReportDetailValue diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts index 4a071f6638e..f44e7dca663 100644 --- a/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts +++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts @@ -10,7 +10,7 @@ import { getGitHubRepoCacheKey, getLegacyGitHubPRCacheKey } from '@/store/slices/github-cache-key' -import { prChecksCacheSuffix } from '@/store/slices/github' +import { prChecksCacheSuffix } from '@/store/github/cache-identity' import { buildParentPrChecksProjection, getParentPrChecksRefreshIdentity, diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.ts index 10d0367f620..d3c2480514e 100644 --- a/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.ts +++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.ts @@ -8,7 +8,7 @@ import { getWorktreeCardPrDisplay } from '@/components/sidebar/worktree-card-pr- import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' import { getGitHubRepoCacheKey } from '@/store/slices/github-cache-key' import { getHostedReviewCacheKey, linkedReviewHintKey } from '@/store/slices/hosted-review' -import { prChecksCacheSuffix } from '@/store/slices/github' +import { prChecksCacheSuffix } from '@/store/github/cache-identity' import { PARENT_PR_CHECKS_GROUP_LABELS, PARENT_PR_CHECKS_GROUP_ORDER, diff --git a/src/renderer/src/components/settings/codex-account-auth-warning.test.ts b/src/renderer/src/components/settings/codex-account-auth-warning.test.ts index 4d1631fbaad..d0062884c4c 100644 --- a/src/renderer/src/components/settings/codex-account-auth-warning.test.ts +++ b/src/renderer/src/components/settings/codex-account-auth-warning.test.ts @@ -38,6 +38,32 @@ describe('codex account auth warning', () => { ).toBe(false) }) + it('warns re-auth when the app-server quits over a dead refresh token', () => { + expect( + getCodexAccountAuthWarning({ + limits: codexLimits('Your ChatGPT session could not be refreshed. Please sign in again.'), + target: { runtime: 'host', wslDistro: null }, + runtime: { runtime: 'host' }, + activeAccountId: 'account-1', + accountId: 'account-1' + }) + ).toBe('stale-sign-in') + }) + + it('does not warn re-auth when the app-server quits over bad argv', () => { + expect( + getCodexAccountAuthWarning({ + limits: codexLimits( + "Codex RPC process exited (exit code 2): error: invalid value 'untrusted' for '--ask-for-approval '" + ), + target: { runtime: 'host', wslDistro: null }, + runtime: { runtime: 'host' }, + activeAccountId: 'account-1', + accountId: 'account-1' + }) + ).toBeNull() + }) + it('matches the active host account on the current rate-limit target', () => { expect( getCodexAccountAuthWarning({ diff --git a/src/renderer/src/components/sidebar/StatusIndicator.test.ts b/src/renderer/src/components/sidebar/StatusIndicator.test.ts index d447f05cad4..c7ebd3a59ec 100644 --- a/src/renderer/src/components/sidebar/StatusIndicator.test.ts +++ b/src/renderer/src/components/sidebar/StatusIndicator.test.ts @@ -31,6 +31,15 @@ describe('StatusIndicator', () => { expect(markup).toContain('motion-reduce:border-t-yellow-500') }) + it('renders monitoring as a static radio glyph', () => { + const markup = renderMarkup('monitoring') + + expect(markup).toContain('title="Monitoring background tasks"') + expect(markup).toContain('lucide-radio') + expect(markup).toContain('text-yellow-500') + expect(markup).not.toContain('data-agent-spinner') + }) + it('renders permission as the shared question glyph', () => { const markup = renderMarkup('permission') @@ -51,4 +60,11 @@ describe('StatusIndicator', () => { expect(classNames).toContain('bg-emerald-500') }) + + it('renders interrupted distinctly from done', () => { + const classNames = renderDotClassNames('interrupted') + + expect(classNames).toContain('bg-red-500') + expect(classNames).not.toContain('bg-emerald-500') + }) }) diff --git a/src/renderer/src/components/sidebar/StatusIndicator.tsx b/src/renderer/src/components/sidebar/StatusIndicator.tsx index 2b3da37a092..26493bb7d40 100644 --- a/src/renderer/src/components/sidebar/StatusIndicator.tsx +++ b/src/renderer/src/components/sidebar/StatusIndicator.tsx @@ -1,4 +1,5 @@ import React from 'react' +import { Radio } from 'lucide-react' import { cn } from '@/lib/utils' import { AgentQuestionIcon } from '@/components/AgentQuestionIcon' import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner' @@ -40,6 +41,30 @@ const StatusIndicator = React.memo(function StatusIndicator({ ) } + if (status === 'monitoring') { + return ( + + + ) + } + + if (status === 'interrupted') { + return ( + + + + ) + } + if (status === 'permission') { return ( vi.fn()) + +vi.mock('@/hooks/useShortcutLabel', () => ({ + useOptionalShortcutLabel: shortcutLabelMock, + formatShortcutLabel: () => '', + formatOptionalShortcutLabel: () => null +})) + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: function DropdownMenu(props: { children?: React.ReactNode; open?: boolean }) { + return
{props.children}
+ }, + DropdownMenuContent: function DropdownMenuContent(props: { children?: React.ReactNode }) { + return
{props.children}
+ }, + DropdownMenuItem: function DropdownMenuItem(props: { + children?: React.ReactNode + disabled?: boolean + variant?: string + }) { + return ( +
+ {props.children} +
+ ) + }, + DropdownMenuSeparator: function DropdownMenuSeparator() { + return
+ }, + DropdownMenuShortcut: function DropdownMenuShortcut(props: { children?: React.ReactNode }) { + return {props.children} + }, + DropdownMenuLabel: function DropdownMenuLabel(props: { children?: React.ReactNode }) { + return
{props.children}
+ }, + DropdownMenuSub: function DropdownMenuSub(props: { children?: React.ReactNode }) { + return
{props.children}
+ }, + DropdownMenuSubContent: function DropdownMenuSubContent(props: { children?: React.ReactNode }) { + return
{props.children}
+ }, + DropdownMenuSubTrigger: function DropdownMenuSubTrigger(props: { children?: React.ReactNode }) { + return
{props.children}
+ }, + DropdownMenuTrigger: function DropdownMenuTrigger(props: { children?: React.ReactNode }) { + return
{props.children}
+ }, + DropdownMenuRadioGroup: function DropdownMenuRadioGroup(props: { children?: React.ReactNode }) { + return
{props.children}
+ }, + DropdownMenuRadioItem: function DropdownMenuRadioItem(props: { children?: React.ReactNode }) { + return
{props.children}
+ } +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children} +})) + +vi.mock('lucide-react', async () => + (await import('../tab-bar/lucide-icon-stub-fixture')).stubEveryIcon() +) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +const defaultStoreState = { + updateWorktreeMeta: vi.fn(), + setWorktreesPinnedAndReveal: vi.fn(), + workspaceStatuses: [], + openModal: vi.fn(), + projectGroups: [], + createProjectGroup: vi.fn(), + moveProjectToGroup: vi.fn(), + deleteStateByWorktreeId: {}, + worktreeLineageById: {}, + workspaceLineageByChildKey: {}, + updateWorktreeLineage: vi.fn(), + tabsByWorktree: {}, + ptyIdsByTabId: {}, + browserTabsByWorktree: {}, + worktreesByRepo: {}, + repos: [] +} + +vi.mock('@/store', () => ({ + useAppStore: Object.assign( + (selector: (state: typeof defaultStoreState) => unknown) => selector(defaultStoreState), + { + getState: () => defaultStoreState + } + ) +})) + +vi.mock('@/store/selectors', () => ({ + useAllWorktrees: () => [], + useRepoById: (repoId?: string) => + repoId ? { id: repoId, name: repoId, displayName: repoId } : undefined, + useRepoMap: () => new Map(), + useWorktreeMap: () => new Map() +})) + +vi.mock('./ProjectGroupNameDialog', () => ({ + ProjectGroupNameDialog: () => null +})) + +vi.mock('./WorktreeParentPickerPopover', () => ({ + WorktreeParentPickerPopover: () => null +})) + +vi.mock('./WorktreeDeveloperMenu', () => ({ + WorktreeDeveloperMenu: () => null +})) + +vi.mock('./WorktreeOpenInMenu', () => ({ + WorktreeOpenInSubMenu: () => null +})) + +vi.mock('./WorkspaceSleepMenuItems', () => ({ + WorkspaceSleepMenuItems: () => null +})) + +const mounted: { container: HTMLDivElement; root: Root }[] = [] + +function renderContextMenu(worktree: Worktree) { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + act(() => { + root.render( + +
Card Content
+
+ ) + }) + mounted.push({ container, root }) + return container +} + +describe('WorktreeContextMenu delete shortcut display', () => { + beforeEach(() => { + shortcutLabelMock.mockImplementation((action: string) => { + if (action === 'workspace.delete') { + return '⌘⇧⌫' + } + return null + }) + }) + + afterEach(() => { + for (const { root, container } of mounted) { + act(() => { + root.unmount() + }) + container.remove() + } + mounted.length = 0 + }) + + it('renders the delete shortcut badge for standard worktree delete', () => { + const worktree = { + id: 'repo::wt-1', + repoId: 'repo', + name: 'wt-1', + path: '/path/to/wt-1', + isMainWorktree: false + } as unknown as Worktree + + const container = renderContextMenu(worktree) + const target = container.querySelector('[data-worktree-context-menu-scope]') as HTMLElement + expect(target).toBeTruthy() + act(() => { + target.dispatchEvent( + new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }) + ) + }) + + const shortcuts = container.querySelectorAll('[data-testid="dropdown-menu-shortcut"]') + const deleteShortcuts = Array.from(shortcuts).filter((el) => el.textContent === '⌘⇧⌫') + expect(deleteShortcuts.length).toBe(1) + }) + + it('omits the delete shortcut on disabled Delete Worktree for primary checkout', () => { + const worktree = { + id: 'repo-1::main', + repoId: 'repo-1', + name: 'main', + path: '/path/to/main', + isMainWorktree: true + } as unknown as Worktree + + const container = renderContextMenu(worktree) + const target = container.querySelector('[data-worktree-context-menu-scope]') as HTMLElement + act(() => { + target.dispatchEvent( + new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }) + ) + }) + + const shortcuts = container.querySelectorAll('[data-testid="dropdown-menu-shortcut"]') + const deleteShortcuts = Array.from(shortcuts).filter((el) => el.textContent === '⌘⇧⌫') + expect(deleteShortcuts.length).toBe(0) + }) + + it('omits the shortcut badge when the action is unassigned', () => { + shortcutLabelMock.mockReturnValue(null) + const worktree = { + id: 'repo::wt-1', + repoId: 'repo', + name: 'wt-1', + path: '/path/to/wt-1', + isMainWorktree: false + } as unknown as Worktree + + const container = renderContextMenu(worktree) + const target = container.querySelector('[data-worktree-context-menu-scope]') as HTMLElement + act(() => { + target.dispatchEvent( + new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }) + ) + }) + + const shortcuts = container.querySelectorAll('[data-testid="dropdown-menu-shortcut"]') + expect(shortcuts.length).toBe(0) + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx index 787090a5cd3..8e3d87fd658 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx @@ -8,6 +8,7 @@ import { DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, + DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, @@ -68,6 +69,7 @@ import { translate } from '@/i18n/i18n' import { unnestWorktrees } from './worktree-unnest' import { parseWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope' import { getDeleteStateForWorktreeHost } from './worktree-delete-state-host-match' +import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' type Props = { worktree: Worktree @@ -337,6 +339,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ const deleteState = useAppStore((s) => getDeleteStateForWorktreeHost(worktree, s.deleteStateByWorktreeId) ) + const deleteShortcut = useOptionalShortcutLabel('workspace.delete') const [menuOpen, setMenuOpen] = useState(false) // Why: the Developer submenu is a power-user affordance, so it is revealed by // holding Option/Alt at right-click — captured at open time (like the Help @@ -1086,6 +1089,9 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ 'auto.components.sidebar.WorktreeContextMenu.f4475537d8', 'Delete' )} + {!isMultiContext && !removesProject && deleteShortcut ? ( + {deleteShortcut} + ) : null} diff --git a/src/renderer/src/components/sidebar/WorktreeDeveloperMenuReveal.test.tsx b/src/renderer/src/components/sidebar/WorktreeDeveloperMenuReveal.test.tsx index 6955e14ac60..4eed6feea33 100644 --- a/src/renderer/src/components/sidebar/WorktreeDeveloperMenuReveal.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeDeveloperMenuReveal.test.tsx @@ -66,7 +66,8 @@ vi.mock('@/components/ui/dropdown-menu', () => { DropdownMenuSub: passthrough, DropdownMenuSubContent: passthrough, DropdownMenuSubTrigger: passthrough, - DropdownMenuTrigger: passthrough + DropdownMenuTrigger: passthrough, + DropdownMenuShortcut: passthrough } }) diff --git a/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.test.ts b/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.test.ts index eb075805183..9c0b3a67449 100644 --- a/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.test.ts +++ b/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.test.ts @@ -97,7 +97,7 @@ describe('prepareActiveWorktreeFocusAfterDelete', () => { simulateDelete('wt-del', true) commit() - expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-b') + expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-b', { revealInSidebar: false }) }) it('falls back to the base/primary worktree when no other workspace remains', () => { @@ -108,7 +108,7 @@ describe('prepareActiveWorktreeFocusAfterDelete', () => { simulateDelete('wt-del', true) commit() - expect(activateAndRevealWorktree).toHaveBeenCalledWith('main') + expect(activateAndRevealWorktree).toHaveBeenCalledWith('main', { revealInSidebar: false }) }) it('does not re-focus a sibling hosted on a torn-down runtime-owned SSH target', () => { @@ -140,7 +140,9 @@ describe('prepareActiveWorktreeFocusAfterDelete', () => { simulateDelete('wt-del', true) commit() - expect(activateAndRevealWorktree).toHaveBeenCalledWith('main-1') + expect(activateAndRevealWorktree).toHaveBeenCalledWith('main-1', { + revealInSidebar: false + }) }) it('does not steal focus when the deleted worktree was not the active one', () => { @@ -229,7 +231,7 @@ describe('prepareActiveWorktreeFocusAfterDelete', () => { mocks.state.deleteStateByWorktreeId = { 'wt-a': { isDeleting: true } } commit() - expect(activateAndRevealWorktree).toHaveBeenCalledWith('main') + expect(activateAndRevealWorktree).toHaveBeenCalledWith('main', { revealInSidebar: false }) }) it('does not steal focus when a non-worktree workspace is active', () => { diff --git a/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.ts b/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.ts index 1993e044f4b..8b499005a17 100644 --- a/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.ts +++ b/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.ts @@ -4,6 +4,7 @@ import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { isRuntimeOwnedSshTargetId, parseExecutionHostId } from '../../../../shared/execution-host' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' +import { getWorktreeVisitTimestamp } from '@/lib/worktree-visit-recency' import { getDeleteStateForWorktreeHost } from './worktree-delete-state-host-match' type AppStoreState = ReturnType @@ -56,7 +57,9 @@ function pickNextWorktreeIdAfterDelete( if (others.length > 0) { const lastVisited = state.lastVisitedAtByWorktreeId const [mostRecent] = [...others].sort( - (a, b) => (lastVisited[b.id] ?? 0) - (lastVisited[a.id] ?? 0) + (a, b) => + (getWorktreeVisitTimestamp(lastVisited, b) ?? 0) - + (getWorktreeVisitTimestamp(lastVisited, a) ?? 0) ) return mostRecent.id } @@ -83,7 +86,8 @@ function focusNextWorktreeAfterActiveDelete( } const nextWorktreeId = pickNextWorktreeIdAfterDelete(state, repoId, deletedWorktreeId) if (nextWorktreeId) { - activateAndRevealWorktree(nextWorktreeId) + // Keep successor focus from replacing the deleted row's spatial context. + activateAndRevealWorktree(nextWorktreeId, { revealInSidebar: false }) } } diff --git a/src/renderer/src/components/sidebar/hovered-workspace-delete.test.ts b/src/renderer/src/components/sidebar/hovered-workspace-delete.test.ts new file mode 100644 index 00000000000..d0a749c0d14 --- /dev/null +++ b/src/renderer/src/components/sidebar/hovered-workspace-delete.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store/types' +import type { Worktree } from '../../../../shared/worktree/types' +import { + deleteHoveredWorkspaceImmediately, + getHoveredWorkspaceIdentity, + resolveHoveredWorkspaceDeleteTarget +} from './hovered-workspace-delete' + +function worktree(overrides: Partial = {}): Worktree { + return { + id: 'repo::/feature', + repoId: 'repo', + path: '/feature', + branch: 'feature', + isMainWorktree: false, + ...overrides + } as Worktree +} + +function hoveredDocument(...rows: { workspaceId: string; hostIdentity: string }[]) { + return { + activeElement: null, + querySelectorAll: () => ({ + length: rows.length, + item: (index: number) => { + const row = rows[index] + return row + ? ({ + dataset: { + worktreeId: row.workspaceId, + worktreeHostIdentity: row.hostIdentity + } + } as unknown as HTMLElement) + : null + } + }) + } as unknown as Pick +} + +function state(worktrees: Worktree[] = []): AppState { + return { + activeModal: 'none', + activeWorkspaceExecutionHostId: worktrees[0]?.hostId ?? null, + activeWorktreeId: worktrees[0]?.id ?? null, + deleteFolderWorkspace: vi.fn(), + deleteStateByWorktreeId: {}, + setActiveWorktree: vi.fn(), + worktreesByRepo: { repo: worktrees } + } as unknown as AppState +} + +describe('hovered workspace delete', () => { + it('uses the deepest hovered worktree row', () => { + expect( + getHoveredWorkspaceIdentity( + hoveredDocument( + { workspaceId: 'parent', hostIdentity: 'local|parent' }, + { workspaceId: 'child', hostIdentity: 'local|child' } + ) + ) + ).toEqual({ workspaceId: 'child', hostIdentity: 'local|child' }) + }) + + it('resolves the exact hovered host instead of the active workspace', () => { + const active = worktree({ id: 'repo::/active', path: '/active', hostId: 'local' }) + const hovered = worktree({ hostId: 'ssh:build', instanceId: 'instance-2' }) + + expect( + resolveHoveredWorkspaceDeleteTarget( + state([active, hovered]), + hoveredDocument({ workspaceId: hovered.id, hostIdentity: 'ssh:build|repo::/feature' }) + ) + ).toEqual({ kind: 'worktree', worktree: hovered }) + }) + + it('retains the hovered host when resolving a folder workspace', () => { + expect( + resolveHoveredWorkspaceDeleteTarget( + state(), + hoveredDocument({ + workspaceId: 'folder:folder-1', + hostIdentity: 'runtime:remote-1|folder:folder-1' + }) + ) + ).toEqual({ + kind: 'folder', + executionHostId: 'runtime:remote-1', + folderWorkspaceId: 'folder-1', + workspaceKey: 'folder:folder-1' + }) + }) + + it('rejects primary worktrees, stale rows, and missing hover', () => { + const primary = worktree({ hostId: 'local', isMainWorktree: true }) + const current = state([primary]) + + expect( + resolveHoveredWorkspaceDeleteTarget( + current, + hoveredDocument({ workspaceId: primary.id, hostIdentity: 'local|repo::/feature' }) + ) + ).toBeNull() + expect( + resolveHoveredWorkspaceDeleteTarget( + current, + hoveredDocument({ workspaceId: 'stale', hostIdentity: 'local|stale' }) + ) + ).toBeNull() + expect(resolveHoveredWorkspaceDeleteTarget(current, hoveredDocument())).toBeNull() + }) + + it('rejects worktrees that are already deleting', () => { + const target = worktree({ hostId: 'ssh:build' }) + const current = state([target]) + current.deleteStateByWorktreeId = { + 'ssh:build|repo::/feature': { + isDeleting: true, + error: null, + canForceDelete: false, + forceDeleteReason: null + } + } + + expect( + resolveHoveredWorkspaceDeleteTarget( + current, + hoveredDocument({ workspaceId: target.id, hostIdentity: 'ssh:build|repo::/feature' }) + ) + ).toBeNull() + }) + + it('rejects hovered rows while an editable control has focus', () => { + class EditableElement { + classList = { contains: () => false } + isContentEditable = false + closest = () => this + } + vi.stubGlobal('HTMLElement', EditableElement) + const target = worktree({ hostId: 'local' }) + const doc = hoveredDocument({ + workspaceId: target.id, + hostIdentity: 'local|repo::/feature' + }) as Pick + Object.defineProperty(doc, 'activeElement', { value: new EditableElement() }) + + try { + expect(resolveHoveredWorkspaceDeleteTarget(state([target]), doc)).toBeNull() + } finally { + vi.unstubAllGlobals() + } + }) + + it('routes the hovered worktree through the host-qualified safety flow', () => { + const target = worktree({ hostId: 'ssh:build', instanceId: 'instance-2' }) + const deleteWorktree = vi.fn() + const current = state([target]) + + expect( + deleteHoveredWorkspaceImmediately( + current, + { kind: 'worktree', worktree: target }, + { + deleteWorktree, + getCurrentState: () => current + } + ) + ).toBe(true) + expect(deleteWorktree).toHaveBeenCalledWith(target.id, { + expectedHostId: 'ssh:build', + expectedInstanceId: 'instance-2' + }) + }) + + it('removes a hovered folder workspace from Orca without deleting its directory', async () => { + const current = state() + current.activeWorktreeId = 'folder:folder-1' + current.activeWorkspaceExecutionHostId = 'runtime:remote-1' + current.deleteFolderWorkspace = vi.fn().mockResolvedValue(true) + + expect( + deleteHoveredWorkspaceImmediately( + current, + { + kind: 'folder', + executionHostId: 'runtime:remote-1', + folderWorkspaceId: 'folder-1', + workspaceKey: 'folder:folder-1' + }, + { deleteWorktree: vi.fn(), getCurrentState: () => current } + ) + ).toBe(true) + await vi.waitFor(() => expect(current.setActiveWorktree).toHaveBeenCalledWith(null)) + expect(current.deleteFolderWorkspace).toHaveBeenCalledWith('folder-1', { + executionHostId: 'runtime:remote-1' + }) + }) + + it('rejects a duplicate folder delete while the first request is pending', async () => { + let finishDelete!: (deleted: boolean) => void + const current = state() + current.deleteFolderWorkspace = vi.fn( + () => new Promise((resolve) => (finishDelete = resolve)) + ) + const target = { + kind: 'folder' as const, + executionHostId: 'runtime:remote-2' as const, + folderWorkspaceId: 'folder-2', + workspaceKey: 'folder:folder-2' + } + const dependencies = { deleteWorktree: vi.fn(), getCurrentState: () => current } + + expect(deleteHoveredWorkspaceImmediately(current, target, dependencies)).toBe(true) + expect(deleteHoveredWorkspaceImmediately(current, target, dependencies)).toBe(false) + expect(current.deleteFolderWorkspace).toHaveBeenCalledOnce() + + finishDelete(false) + await vi.waitFor(() => + expect(deleteHoveredWorkspaceImmediately(current, target, dependencies)).toBe(true) + ) + finishDelete(false) + }) +}) diff --git a/src/renderer/src/components/sidebar/hovered-workspace-delete.ts b/src/renderer/src/components/sidebar/hovered-workspace-delete.ts new file mode 100644 index 00000000000..f1ecdc30d8b --- /dev/null +++ b/src/renderer/src/components/sidebar/hovered-workspace-delete.ts @@ -0,0 +1,133 @@ +import { useAppStore } from '@/store' +import { getAllWorktreesFromState } from '@/store/selectors' +import type { AppState } from '@/store/types' +import { isEditableTarget } from '@/lib/editable-target' +import { + composeWorktreeHostIdentity, + getExecutionHostIdFromWorktreeHostIdentity, + getWorktreeHostIdentity +} from '../../../../shared/worktree/host-qualified-identity' +import type { Worktree } from '../../../../shared/worktree/types' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { runWorktreeDelete } from './delete-worktree-flow' +import { getDeleteStateForWorktreeHost } from './worktree-delete-state-host-match' + +const pendingFolderDeletes = new Set() + +type DeleteWorktree = typeof runWorktreeDelete +type HoveredWorkspaceDeleteState = Pick< + AppState, + | 'activeModal' + | 'activeWorktreeId' + | 'deleteFolderWorkspace' + | 'deleteStateByWorktreeId' + | 'worktreesByRepo' +> +type CurrentWorkspaceState = Pick< + AppState, + 'activeWorkspaceExecutionHostId' | 'activeWorktreeId' | 'setActiveWorktree' +> +type HoveredWorkspaceDeleteDependencies = { + deleteWorktree: DeleteWorktree + getCurrentState: () => CurrentWorkspaceState +} +type HoveredWorkspaceDocument = Pick + +export type HoveredWorkspaceDeleteTarget = + | { + kind: 'folder' + executionHostId: ExecutionHostId + folderWorkspaceId: string + workspaceKey: string + } + | { kind: 'worktree'; worktree: Worktree } + +export function getHoveredWorkspaceIdentity( + doc: HoveredWorkspaceDocument = document +): { hostIdentity: string; workspaceId: string } | null { + const hoveredRows = doc.querySelectorAll( + '[data-worktree-sidebar] [role="option"][data-worktree-id]:hover' + ) + const row = hoveredRows.item(hoveredRows.length - 1) + const workspaceId = row?.dataset.worktreeId + const hostIdentity = row?.dataset.worktreeHostIdentity + return workspaceId && hostIdentity ? { workspaceId, hostIdentity } : null +} + +export function resolveHoveredWorkspaceDeleteTarget( + state: HoveredWorkspaceDeleteState, + doc: HoveredWorkspaceDocument = document +): HoveredWorkspaceDeleteTarget | null { + if (state.activeModal !== 'none' || (doc.activeElement && isEditableTarget(doc.activeElement))) { + return null + } + const hovered = getHoveredWorkspaceIdentity(doc) + if (!hovered) { + return null + } + const workspaceScope = parseWorkspaceKey(hovered.workspaceId) + if (workspaceScope?.type === 'folder') { + const executionHostId = getExecutionHostIdFromWorktreeHostIdentity(hovered.hostIdentity) + if (!executionHostId) { + return null + } + return { + kind: 'folder', + executionHostId, + folderWorkspaceId: workspaceScope.folderWorkspaceId, + workspaceKey: hovered.workspaceId + } + } + const worktree = getAllWorktreesFromState(state).find( + (candidate) => + candidate.id === hovered.workspaceId && + getWorktreeHostIdentity(candidate) === hovered.hostIdentity + ) + return worktree && + !worktree.isMainWorktree && + !getDeleteStateForWorktreeHost(worktree, state.deleteStateByWorktreeId)?.isDeleting + ? { kind: 'worktree', worktree } + : null +} + +export function deleteHoveredWorkspaceImmediately( + state: HoveredWorkspaceDeleteState, + target: HoveredWorkspaceDeleteTarget | null = resolveHoveredWorkspaceDeleteTarget(state), + dependencies: HoveredWorkspaceDeleteDependencies = { + deleteWorktree: runWorktreeDelete, + getCurrentState: useAppStore.getState + } +): boolean { + if (!target) { + return false + } + if (target.kind === 'folder') { + const pendingIdentity = composeWorktreeHostIdentity(target.executionHostId, target.workspaceKey) + if (pendingFolderDeletes.has(pendingIdentity)) { + return false + } + pendingFolderDeletes.add(pendingIdentity) + void state + .deleteFolderWorkspace(target.folderWorkspaceId, { + executionHostId: target.executionHostId + }) + .then((deleted) => { + const current = dependencies.getCurrentState() + if ( + deleted && + current.activeWorktreeId === target.workspaceKey && + current.activeWorkspaceExecutionHostId === target.executionHostId + ) { + current.setActiveWorktree(null) + } + }) + .finally(() => pendingFolderDeletes.delete(pendingIdentity)) + return true + } + dependencies.deleteWorktree(target.worktree.id, { + expectedInstanceId: target.worktree.instanceId, + ...(target.worktree.hostId ? { expectedHostId: target.worktree.hostId } : {}) + }) + return true +} diff --git a/src/renderer/src/components/sidebar/use-worktree-activity-status.ts b/src/renderer/src/components/sidebar/use-worktree-activity-status.ts index 177e3c194bc..d0ca4bdae53 100644 --- a/src/renderer/src/components/sidebar/use-worktree-activity-status.ts +++ b/src/renderer/src/components/sidebar/use-worktree-activity-status.ts @@ -22,8 +22,15 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus { const terminalLayoutRootsByTabId = useAppStore( useShallow((s) => selectTerminalLayoutRootsForWorktree(s, worktreeId)) ) - const { hasPermission, hasLiveWorking, hasLiveDone, hasRetainedDone, agentStatusPaneIdsByTabId } = - useAppStore(useShallow((s) => selectWorktreeAgentActivitySummary(s, worktreeId))) + const { + hasPermission, + hasLiveWorking, + hasLiveMonitoring, + hasInterrupted, + hasLiveDone, + hasRetainedDone, + agentStatusPaneIdsByTabId + } = useAppStore(useShallow((s) => selectWorktreeAgentActivitySummary(s, worktreeId))) // Why: compact and detailed cards need the same status-dot semantics: // runtime liveness gates title-derived states, then explicit agent rows can @@ -39,6 +46,8 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus { terminalLayoutRootsByTabId, hasPermission, hasLiveWorking, + hasLiveMonitoring, + hasInterrupted, hasLiveDone, hasRetainedDone }), @@ -51,6 +60,8 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus { terminalLayoutRootsByTabId, hasPermission, hasLiveWorking, + hasLiveMonitoring, + hasInterrupted, hasLiveDone, hasRetainedDone ] diff --git a/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts b/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts index 4b1216d7151..b7902660a19 100644 --- a/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts +++ b/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts @@ -33,6 +33,8 @@ export function selectWorktreeActivityStatuses( const { hasPermission, hasLiveWorking, + hasLiveMonitoring, + hasInterrupted, hasLiveDone, hasRetainedDone, agentStatusPaneIdsByTabId @@ -48,6 +50,8 @@ export function selectWorktreeActivityStatuses( terminalLayoutRootsByTabId: selectTerminalLayoutRootsForWorktree(statusInputs, worktreeId), hasPermission, hasLiveWorking, + hasLiveMonitoring, + hasInterrupted, hasLiveDone, hasRetainedDone }) diff --git a/src/renderer/src/components/sidebar/use-worktree-card-review-details.ts b/src/renderer/src/components/sidebar/use-worktree-card-review-details.ts index 23129542be2..05bee14ee80 100644 --- a/src/renderer/src/components/sidebar/use-worktree-card-review-details.ts +++ b/src/renderer/src/components/sidebar/use-worktree-card-review-details.ts @@ -1,7 +1,7 @@ import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' import { useAppStore } from '@/store' import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key' -import { issueCacheKey as getIssueCacheKey } from '@/store/slices/github' +import { issueCacheKey as getIssueCacheKey } from '@/store/github/cache-identity' import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' import { hostedReviewInfoFromGitHubPRInfo } from '../../../../shared/hosted-review-github' import type { HostedReviewInfo } from '../../../../shared/hosted-review' diff --git a/src/renderer/src/components/sidebar/use-worktree-issue-link.ts b/src/renderer/src/components/sidebar/use-worktree-issue-link.ts index bf1e0a1bb31..4a3aba42542 100644 --- a/src/renderer/src/components/sidebar/use-worktree-issue-link.ts +++ b/src/renderer/src/components/sidebar/use-worktree-issue-link.ts @@ -1,7 +1,7 @@ import { useCallback, useMemo, useRef, useState } from 'react' import { useAppStore } from '@/store' import { parseGitHubIssueOrPRNumber } from '@/lib/github-links' -import { issueCacheKey as getIssueCacheKey } from '@/store/slices/github' +import { issueCacheKey as getIssueCacheKey } from '@/store/github/cache-identity' import { useMountedRef } from '@/hooks/useMountedRef' import { findIndexedWorktreeOwner } from '@/lib/worktree-runtime-owner-index' import { buildLinearIssueUrl, parseLinearIssueInput } from '../../../../shared/linear/links' diff --git a/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.test.ts b/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.test.ts index c18c3cbceed..50dbfc34644 100644 --- a/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.test.ts +++ b/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import type { BrowserWorkspace } from '../../../../shared/browser-workspace-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { + createVisibleWorktreeTerminalActivityProjection, getVisibleWorktreeBrowserActivityTabs, getVisibleWorktreeTerminalActivityTabs } from './visible-worktree-activity-inputs' @@ -49,6 +50,28 @@ describe('visible worktree activity inputs', () => { expect(second).toBe(first) expect(second['wt-1']).toBe(first['wt-1']) }) + it('inspects only the changed bucket in a 300-worktree title publication', () => { + const inspected: string[] = [] + const projector = createVisibleWorktreeTerminalActivityProjection((worktreeId) => + inspected.push(worktreeId) + ) + const tabs = Object.fromEntries( + Array.from({ length: 300 }, (_, index) => { + const worktreeId = `wt-${index}` + return [worktreeId, [terminalTab(`tab-${index}`, `Title ${index}`)]] + }) + ) + const first = projector.project(tabs) + inspected.length = 0 + const changedWorktreeId = 'wt-173' + const second = projector.project({ + ...tabs, + [changedWorktreeId]: [{ ...tabs[changedWorktreeId][0], title: 'Changed display title' }] + }) + + expect(second).toBe(first) + expect(inspected).toEqual([changedWorktreeId]) + }) it('updates terminal activity projection when tab ids change', () => { const first = getVisibleWorktreeTerminalActivityTabs({ diff --git a/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.ts b/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.ts index 683b984cca8..521632ce4b7 100644 --- a/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.ts +++ b/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.ts @@ -1,94 +1,35 @@ import type { BrowserWorkspace } from '../../../../shared/browser-workspace-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { createWorktreeTabBucketProjection } from '@/lib/worktree-tab-bucket-projection' export type TerminalActivityTab = Pick export type BrowserActivityTab = Pick -function haveSameProjection( - previous: readonly U[] | undefined, - next: readonly T[], - isSame: (previousTab: U, nextTab: T) => boolean -): boolean { - if (!previous || previous.length !== next.length) { - return false - } - for (let index = 0; index < next.length; index++) { - const previousTab = previous[index] - if (!previousTab || !isSame(previousTab, next[index])) { - return false - } - } - return true +export function createVisibleWorktreeTerminalActivityProjection( + onInspectBucket?: (worktreeId: string) => void +) { + return createWorktreeTabBucketProjection({ + projectTab: (tab) => ({ id: tab.id }), + isSameProjectedTab: (previousTab, nextTab) => previousTab.id === nextTab.id, + onInspectBucket + }) } -function projectTabs( - tabsByWorktree: Record, - previousProjection: Record | null, - projectTab: (tab: T) => U, - isSame: (previousTab: U, nextTab: T) => boolean -): { projection: Record; unchanged: boolean } { - const nextProjection: Record = {} - let unchanged = - previousProjection !== null && - Object.keys(previousProjection).length === Object.keys(tabsByWorktree).length - - for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { - const previousTabs = previousProjection?.[worktreeId] - if (haveSameProjection(previousTabs, tabs, isSame)) { - nextProjection[worktreeId] = previousTabs as U[] - continue - } - unchanged = false - nextProjection[worktreeId] = tabs.map(projectTab) - } - - return { projection: nextProjection, unchanged } -} - -function projectIdTabs( - tabsByWorktree: Record, - previousProjection: Record | null -): { projection: Record; unchanged: boolean } { - return projectTabs( - tabsByWorktree, - previousProjection, - (tab) => ({ id: tab.id }) as U, - (previousTab, nextTab) => previousTab.id === nextTab.id - ) -} - -let cachedTerminalSource: Record | null = null -let cachedTerminalProjection: Record | null = null +const terminalProjection = createVisibleWorktreeTerminalActivityProjection() export function getVisibleWorktreeTerminalActivityTabs( tabsByWorktree: Record ): Record { - if (cachedTerminalSource === tabsByWorktree && cachedTerminalProjection) { - return cachedTerminalProjection - } - const { projection, unchanged } = projectIdTabs(tabsByWorktree, cachedTerminalProjection) - cachedTerminalSource = tabsByWorktree - if (unchanged && cachedTerminalProjection) { - return cachedTerminalProjection - } - cachedTerminalProjection = projection - return projection + return terminalProjection.project(tabsByWorktree) } -let cachedBrowserSource: Record | null = null -let cachedBrowserProjection: Record | null = null +const browserProjection = createWorktreeTabBucketProjection({ + projectTab: (tab) => ({ id: tab.id }), + isSameProjectedTab: (previousTab, nextTab) => previousTab.id === nextTab.id +}) export function getVisibleWorktreeBrowserActivityTabs( browserTabsByWorktree: Record ): Record { - if (cachedBrowserSource === browserTabsByWorktree && cachedBrowserProjection) { - return cachedBrowserProjection - } - const { projection, unchanged } = projectIdTabs(browserTabsByWorktree, cachedBrowserProjection) - cachedBrowserSource = browserTabsByWorktree - if (unchanged && cachedBrowserProjection) { - return cachedBrowserProjection - } - cachedBrowserProjection = projection - return projection + return browserProjection.project(browserTabsByWorktree) } diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts index ed086d7c94e..69ec75aa41a 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts @@ -16,6 +16,8 @@ function makeAgentStatusEntry(args: { worktreeId?: string parentPaneKey?: string restoredUnconfirmed?: true + workingMode?: AgentStatusEntry['workingMode'] + interrupted?: true }): AgentStatusEntry { return { paneKey: args.paneKey, @@ -26,6 +28,8 @@ function makeAgentStatusEntry(args: { stateHistory: [], worktreeId: args.worktreeId, restoredUnconfirmed: args.restoredUnconfirmed, + workingMode: args.workingMode, + interrupted: args.interrupted, orchestration: args.parentPaneKey ? { taskId: 'task-1', @@ -167,6 +171,54 @@ describe('selectWorktreeAgentActivitySummary', () => { expect(nowSpy).toHaveBeenCalledTimes(2) }) + it('separates passive monitoring from active working', () => { + vi.spyOn(Date, 'now').mockReturnValue(2_000) + const paneKey = makePaneKey('tab-1', LEAF_ID) + const summary = selectWorktreeAgentActivitySummary( + { + tabsByWorktree: { 'repo::/wt-1': [makeTab('tab-1', 'repo::/wt-1')] }, + agentStatusEpoch: 1, + agentStatusByPaneKey: { + [paneKey]: makeAgentStatusEntry({ + paneKey, + state: 'working', + workingMode: 'monitoring' + }) + }, + migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + retainedAgentsByPaneKey: {} + }, + 'repo::/wt-1' + ) + + expect(summary).toMatchObject({ hasLiveWorking: false, hasLiveMonitoring: true }) + }) + + it('separates interrupted outcomes from clean completion', () => { + vi.spyOn(Date, 'now').mockReturnValue(2_000) + const paneKey = makePaneKey('tab-1', LEAF_ID) + const summary = selectWorktreeAgentActivitySummary( + { + tabsByWorktree: { 'repo::/wt-1': [makeTab('tab-1', 'repo::/wt-1')] }, + agentStatusEpoch: 2, + agentStatusByPaneKey: { + [paneKey]: makeAgentStatusEntry({ + paneKey, + state: 'done', + interrupted: true + }) + }, + migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + retainedAgentsByPaneKey: {} + }, + 'repo::/wt-1' + ) + + expect(summary).toMatchObject({ hasInterrupted: true, hasLiveDone: false }) + }) + it('lets an unconfirmed restored row suppress only its pane title', () => { vi.spyOn(Date, 'now').mockReturnValue(2_000) const paneKey = makePaneKey('tab-1', LEAF_ID) diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts index 38e585d39ce..fbb2d93869a 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts @@ -15,6 +15,9 @@ import { export type WorktreeAgentActivitySummary = { hasPermission: boolean hasLiveWorking: boolean + hasLiveMonitoring: boolean + /** Fresh interrupted completion, kept separate from clean done outcomes. */ + hasInterrupted: boolean hasLiveDone: boolean hasRetainedDone: boolean agentStatusPaneIdsByTabId: Record> @@ -25,6 +28,8 @@ const EMPTY_AGENT_STATUS_PANE_IDS_BY_TAB_ID: Record> const EMPTY_SUMMARY: WorktreeAgentActivitySummary = { hasPermission: false, hasLiveWorking: false, + hasLiveMonitoring: false, + hasInterrupted: false, hasLiveDone: false, hasRetainedDone: false, agentStatusPaneIdsByTabId: EMPTY_AGENT_STATUS_PANE_IDS_BY_TAB_ID @@ -177,6 +182,8 @@ function summariesEqual( return ( previous.hasPermission === next.hasPermission && previous.hasLiveWorking === next.hasLiveWorking && + previous.hasLiveMonitoring === next.hasLiveMonitoring && + previous.hasInterrupted === next.hasInterrupted && previous.hasLiveDone === next.hasLiveDone && previous.hasRetainedDone === next.hasRetainedDone && agentStatusPaneIdsByTabIdEqual( @@ -214,12 +221,19 @@ function agentStatusPaneIdsByTabIdEqual( function applyLiveAgentState( summary: WorktreeAgentActivitySummary, - entry: Pick + entry: Pick ): void { if (entry.state === 'blocked' || entry.state === 'waiting') { summary.hasPermission = true + } else if (entry.interrupted === true) { + // Interrupted is encoded as done, so it must be checked first. + summary.hasInterrupted = true } else if (entry.state === 'working') { - summary.hasLiveWorking = true + if (entry.workingMode === 'monitoring') { + summary.hasLiveMonitoring = true + } else { + summary.hasLiveWorking = true + } } else if (entry.state === 'done') { summary.hasLiveDone = true } diff --git a/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts b/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts new file mode 100644 index 00000000000..87096cf9532 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts @@ -0,0 +1,72 @@ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' +import { CompactAgentRow, getCompactAgentSecondary } from './worktree-card-compact-agent-row' +import { getAgentDotState, summarizeAgents } from './worktree-card-agent-summary' + +function monitoringAgent(): DashboardAgentRowData { + return { + paneKey: 'tab-1:leaf-1', + state: 'working', + agentType: 'claude', + startedAt: 1, + tab: { + id: 'tab-1', + ptyId: null, + worktreeId: 'wt-1', + title: 'Claude', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + }, + entry: { + state: 'working', + workingMode: 'monitoring', + prompt: '', + updatedAt: 1, + stateStartedAt: 1, + stateHistory: [], + paneKey: 'tab-1:leaf-1' + } + } +} + +describe('worktree card agent summary', () => { + it('presents passive working as monitoring', () => { + const agent = monitoringAgent() + + expect(getAgentDotState(agent)).toBe('monitoring') + expect(getCompactAgentSecondary(agent)).toBe('Monitoring background tasks') + expect(summarizeAgents([agent], 'Agent')).toBe('Agent monitoring') + }) + + it('keeps monitoring visible before a compact row prompt', () => { + const agent = monitoringAgent() + agent.entry.prompt = 'Run background checks' + + const markup = renderToStaticMarkup( + createElement(CompactAgentRow, { agent, now: 2000, onActivate: vi.fn() }) + ) + + expect(markup).toContain('title="Monitoring background tasks - Run background checks"') + expect(markup).toMatch( + /Monitoring background tasks<\/span>]*> - Run background checks<\/span>/ + ) + }) + + it('lists interrupted outcomes before clean completions', () => { + const done = monitoringAgent() + done.state = 'done' + done.entry.state = 'done' + done.entry.workingMode = undefined + const interrupted = { + ...done, + paneKey: 'tab-1:leaf-2', + entry: { ...done.entry, paneKey: 'tab-1:leaf-2', interrupted: true } + } + + expect(summarizeAgents([done, interrupted], 'Agents')).toBe('Agents: 1 interrupted, 1 done') + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts b/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts index dc08ea6f271..8a23b708b6d 100644 --- a/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts +++ b/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts @@ -11,8 +11,9 @@ export type SummaryAgentGroup = { const SUMMARY_STATE_ORDER: AgentDotState[] = [ 'waiting', 'blocked', - 'interrupted', 'working', + 'monitoring', + 'interrupted', 'done', 'idle' ] @@ -30,7 +31,12 @@ function asDotState(state: AgentStatusState | 'idle'): AgentDotState { } export function getAgentDotState(agent: DashboardAgentRowData): AgentDotState { - return agent.entry.interrupted === true ? 'interrupted' : asDotState(agent.state) + if (agent.entry.interrupted === true) { + return 'interrupted' + } + return agent.state === 'working' && agent.entry.workingMode === 'monitoring' + ? 'monitoring' + : asDotState(agent.state) } export function formatSummaryStateLabel(state: AgentDotState): string { @@ -45,6 +51,8 @@ export function formatSummaryStateLabel(state: AgentDotState): string { return 'failed' case 'working': return 'working' + case 'monitoring': + return 'monitoring' case 'done': return 'done' case 'idle': diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx index c285d4769af..63163bfe207 100644 --- a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx @@ -37,10 +37,14 @@ function getCompactAgentPrimary( return prompt || agentStateLabel(getAgentDotState(agent)) } -function getCompactAgentSecondary(agent: DashboardAgentRowData): string { +export function getCompactAgentSecondary(agent: DashboardAgentRowData): string { if (agent.entry.interrupted === true) { return 'Interrupted by user' } + // Why: the lead turn is over in monitoring, so its last tool line is stale; name the state instead. + if (agent.state === 'working' && agent.entry.workingMode === 'monitoring') { + return agentStateLabel('monitoring') + } const toolPreview = formatAgentToolPreview(agent.entry, agent.state) if (toolPreview) { return toolPreview @@ -119,6 +123,11 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({ const primary = getCompactAgentPrimary(agent, conversationName) const isLineageChild = agent.lineage?.depth === 1 const secondary = getCompactAgentSecondary(agent) + // Why: sidebar truncation must preserve the passive-vs-active distinction. + const leadingText = dotState === 'monitoring' ? secondary : primary + const trailingText = + dotState === 'monitoring' ? (primary === secondary ? '' : primary) : secondary + const rowTitle = `${leadingText}${trailingText ? ` - ${trailingText}` : ''}` const model = agent.entry.model?.trim() ?? '' const shortTime = getCompactAgentTime(agent, now) const cacheTimer = usePromptCacheCountdownForPane(agent.paneKey, cacheTimerActive) @@ -201,12 +210,12 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({ {/* Why: the selected-row fill is strong enough to wash out the dimmed prompt/secondary text, so lift both toward full foreground when focused. */} - {primary} + {leadingText} - {secondary && ( + {trailingText && ( {' '} - - {secondary} + - {trailingText} )}
@@ -269,7 +278,7 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({ role={agent.lineage ? 'treeitem' : undefined} aria-level={agent.lineage ? agent.lineage.depth + 1 : undefined} aria-expanded={hasChildDisclosure ? childAgentsExpanded : undefined} - title={sendTargetDisabledReason ?? `${primary}${secondary ? ` - ${secondary}` : ''}`} + title={sendTargetDisabledReason ?? rowTitle} > {rowBody}
diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-measurement.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-measurement.ts index 5526c2d39c9..ff812a950be 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-measurement.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-measurement.ts @@ -14,6 +14,7 @@ import { import { getRenderRowKey } from '../listing/render-row' import type { RenderRow } from '../listing/render-row' import type { WorktreeListVirtualizer } from './use-virtualizer' +import { useVirtualRowRemovalAnimation } from './use-row-removal-animation' const recordKeyCountCache = new WeakMap, number>() @@ -123,5 +124,12 @@ export function useVirtualRowMeasurementSync(args: { virtualizer }) + useVirtualRowRemovalAnimation({ + renderRows, + rekeyedRowKeys: lineageRowRekeys, + scrollRef, + virtualItems + }) + return { virtualItems, measureVirtualRowElement } } diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-removal-animation.test.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-removal-animation.test.ts new file mode 100644 index 00000000000..23980e38d39 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-removal-animation.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { + buildVirtualRowRemovalMotions, + type VirtualRowLayoutSnapshot +} from './use-row-removal-animation' + +function snapshot(args: { + identities: string[] + scrollTop: number + starts: [string, number][] +}): VirtualRowLayoutSnapshot { + return { + rowIdentityKeys: new Set(args.identities), + scrollTop: args.scrollTop, + startsByKey: new Map(args.starts) + } +} + +describe('buildVirtualRowRemovalMotions', () => { + it('moves surviving rows from their pre-delete viewport positions', () => { + const motions = buildVirtualRowRemovalMotions({ + previous: snapshot({ + identities: ['wt:a', 'wt:b', 'wt:c'], + scrollTop: 100, + starts: [ + ['wt:a', 100], + ['wt:b', 220], + ['wt:c', 340] + ] + }), + current: snapshot({ + identities: ['wt:a', 'wt:c'], + scrollTop: 100, + starts: [ + ['wt:a', 100], + ['wt:c', 220] + ] + }), + rekeyedRowKeys: new Map() + }) + + expect(motions).toEqual([{ key: 'wt:c', deltaY: 120 }]) + }) + + it('does not double-move rows when anchor restoration offsets a deletion above the viewport', () => { + const motions = buildVirtualRowRemovalMotions({ + previous: snapshot({ + identities: ['wt:deleted', 'wt:a'], + scrollTop: 240, + starts: [['wt:a', 240]] + }), + current: snapshot({ + identities: ['wt:a'], + scrollTop: 120, + starts: [['wt:a', 120]] + }), + rekeyedRowKeys: new Map() + }) + + expect(motions).toEqual([]) + }) + + it('follows a surviving row through a lineage rekey', () => { + const motions = buildVirtualRowRemovalMotions({ + previous: snapshot({ + identities: ['wt:parent', 'wt:child'], + scrollTop: 0, + starts: [['lineage-group:parent', 100]] + }), + current: snapshot({ + identities: ['wt:parent'], + scrollTop: 0, + starts: [['wt:parent', 100]] + }), + rekeyedRowKeys: new Map([['lineage-group:parent', 'wt:parent']]) + }) + + expect(motions).toEqual([]) + }) + + it('ignores additions and measurement-only movement', () => { + const previous = snapshot({ + identities: ['wt:a'], + scrollTop: 0, + starts: [['wt:a', 100]] + }) + expect( + buildVirtualRowRemovalMotions({ + previous, + current: snapshot({ + identities: ['wt:a', 'wt:b'], + scrollTop: 0, + starts: [['wt:a', 140]] + }), + rekeyedRowKeys: new Map() + }) + ).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-removal-animation.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-removal-animation.ts new file mode 100644 index 00000000000..1f94b590d6d --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-removal-animation.ts @@ -0,0 +1,121 @@ +import { useLayoutEffect, useMemo, useRef } from 'react' +import type React from 'react' +import type { VirtualItem } from '@tanstack/react-virtual' +import type { RenderRow } from '../listing/render-row' +import { getRenderRowKey } from '../listing/render-row' + +export const WORKTREE_ROW_REMOVAL_ANIMATION_MS = 180 + +export type VirtualRowLayoutSnapshot = { + rowIdentityKeys: ReadonlySet + scrollTop: number + startsByKey: ReadonlyMap +} + +export type VirtualRowRemovalMotion = { + deltaY: number + key: string +} + +export function getSidebarRowIdentityKeys(rows: readonly RenderRow[]): ReadonlySet { + const keys = new Set() + for (const row of rows) { + if (row.type === 'lineage-group') { + row.rows.forEach((member) => keys.add(`wt:${member.rowKey}`)) + } else { + keys.add(getRenderRowKey(row)) + } + } + return keys +} + +export function buildVirtualRowRemovalMotions(args: { + previous: VirtualRowLayoutSnapshot | null + current: VirtualRowLayoutSnapshot + rekeyedRowKeys: ReadonlyMap +}): VirtualRowRemovalMotion[] { + const { previous, current, rekeyedRowKeys } = args + if (previous === null || previous.rowIdentityKeys === current.rowIdentityKeys) { + return [] + } + let removedRow = false + for (const key of previous.rowIdentityKeys) { + if (!current.rowIdentityKeys.has(key)) { + removedRow = true + break + } + } + if (!removedRow) { + return [] + } + + const previousKeyByCurrentKey = new Map() + rekeyedRowKeys.forEach((currentKey, previousKey) => { + previousKeyByCurrentKey.set(currentKey, previousKey) + }) + const motions: VirtualRowRemovalMotion[] = [] + current.startsByKey.forEach((currentStart, key) => { + const previousKey = previous.startsByKey.has(key) ? key : previousKeyByCurrentKey.get(key) + const previousStart = previousKey ? previous.startsByKey.get(previousKey) : undefined + if (previousStart === undefined) { + return + } + const deltaY = previousStart - previous.scrollTop - (currentStart - current.scrollTop) + if (Math.abs(deltaY) > 0.5) { + motions.push({ deltaY, key }) + } + }) + return motions +} + +export function useVirtualRowRemovalAnimation(args: { + renderRows: readonly RenderRow[] + rekeyedRowKeys: ReadonlyMap + scrollRef: React.RefObject + virtualItems: readonly VirtualItem[] +}): void { + const { renderRows, rekeyedRowKeys, scrollRef, virtualItems } = args + const previousSnapshotRef = useRef(null) + const rowIdentityKeys = useMemo(() => getSidebarRowIdentityKeys(renderRows), [renderRows]) + + useLayoutEffect(() => { + const scrollElement = scrollRef.current + if (!scrollElement) { + return + } + const current: VirtualRowLayoutSnapshot = { + rowIdentityKeys, + scrollTop: scrollElement.scrollTop, + startsByKey: new Map(virtualItems.map((item) => [String(item.key), item.start])) + } + const motions = buildVirtualRowRemovalMotions({ + previous: previousSnapshotRef.current, + current, + rekeyedRowKeys + }) + previousSnapshotRef.current = current + if (motions.length === 0 || window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + return + } + + const elementsByKey = new Map( + Array.from( + scrollElement.querySelectorAll('[data-worktree-virtual-row-key]') + ).map((element) => [element.dataset.worktreeVirtualRowKey ?? '', element]) + ) + for (const motion of motions) { + const element = elementsByKey.get(motion.key) + if (!element || element.hasAttribute('data-worktree-sticky-header-active')) { + continue + } + const content = element.firstElementChild + if (!(content instanceof HTMLElement)) { + continue + } + content.animate([{ translate: `0 ${motion.deltaY}px` }, { translate: '0 0' }], { + duration: WORKTREE_ROW_REMOVAL_ANIMATION_MS, + easing: 'cubic-bezier(0.16, 1, 0.3, 1)' + }) + } + }, [rekeyedRowKeys, rowIdentityKeys, scrollRef, virtualItems]) +} diff --git a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx index 890e7c81d7d..c4d8b080843 100644 --- a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx +++ b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx @@ -55,7 +55,7 @@ import { getWorktreeOnHostFromState } from '../../store/selectors' import { getHostedReviewCacheKey } from '../../store/slices/hosted-review' -import { issueCacheKey as getIssueCacheKey } from '../../store/slices/github' +import { issueCacheKey as getIssueCacheKey } from '../../store/github/cache-identity' import { findRepoForHost } from '@/store/slices/repo-host-identity' import { getSelectedDeletableWorkspaceRows, diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts index e475ae49a4d..d2bed923686 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts @@ -62,6 +62,28 @@ describe('resolveTerminalTabActivityStatus', () => { ).toBe('working') }) + it('reports monitoring without hiding active or actionable siblings', () => { + const monitoring = entry(FIRST_LEAF_ID, 'working', { workingMode: 'monitoring' }) + const working = entry(SECOND_LEAF_ID, 'working') + expect( + resolveTerminalTabActivityStatus({ + tab: TAB, + agentStatusByPaneKey: { [monitoring.paneKey]: monitoring }, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('monitoring') + expect( + resolveTerminalTabActivityStatus({ + tab: TAB, + agentStatusByPaneKey: { + [monitoring.paneKey]: monitoring, + [working.paneKey]: working + }, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('working') + }) + it('lets a needs-input pane outrank a working sibling', () => { const working = entry(FIRST_LEAF_ID, 'working') const waiting = entry(SECOND_LEAF_ID, 'waiting') @@ -88,7 +110,7 @@ describe('resolveTerminalTabActivityStatus', () => { ).toBe('done') }) - it('treats an interrupted done as done, matching the worktree card', () => { + it('reports an interrupted done as interrupted, matching the worktree card', () => { const interrupted = entry(FIRST_LEAF_ID, 'done', { interrupted: true }) expect( resolveTerminalTabActivityStatus({ @@ -96,7 +118,22 @@ describe('resolveTerminalTabActivityStatus', () => { agentStatusByPaneKey: { [interrupted.paneKey]: interrupted }, ptyIdsByTabId: LIVE_PTY }) - ).toBe('done') + ).toBe('interrupted') + }) + + it('does not let a finished sibling mask an interrupted outcome', () => { + const interrupted = entry(FIRST_LEAF_ID, 'done', { interrupted: true }) + const finished = entry(SECOND_LEAF_ID, 'done') + expect( + resolveTerminalTabActivityStatus({ + tab: TAB, + agentStatusByPaneKey: { + [interrupted.paneKey]: interrupted, + [finished.paneKey]: finished + }, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('interrupted') }) it('falls back to a live working title when hook status is stale', () => { @@ -246,8 +283,14 @@ describe('resolveTerminalTabAttentionBadge', () => { expect(resolveTerminalTabAttentionBadge({ status: 'permission', hasUnread: true })).toBe( 'permission' ) + expect(resolveTerminalTabAttentionBadge({ status: 'monitoring', hasUnread: true })).toBe( + 'monitoring' + ) expect(resolveTerminalTabAttentionBadge({ status: 'done', hasUnread: true })).toBe('unread') expect(resolveTerminalTabAttentionBadge({ status: 'done', hasUnread: false })).toBe('done') + expect(resolveTerminalTabAttentionBadge({ status: 'interrupted', hasUnread: false })).toBe( + 'interrupted' + ) expect(resolveTerminalTabAttentionBadge({ status: 'active', hasUnread: false })).toBeNull() }) }) @@ -274,8 +317,10 @@ describe('terminalTabHasUnreadActivity', () => { describe('terminalTabActivityToAgentDotState', () => { it('maps glyph statuses and drops quiet ones', () => { expect(terminalTabActivityToAgentDotState('working')).toBe('working') + expect(terminalTabActivityToAgentDotState('monitoring')).toBe('monitoring') expect(terminalTabActivityToAgentDotState('permission')).toBe('permission') expect(terminalTabActivityToAgentDotState('done')).toBe('done') + expect(terminalTabActivityToAgentDotState('interrupted')).toBe('interrupted') expect(terminalTabActivityToAgentDotState('active')).toBeNull() expect(terminalTabActivityToAgentDotState('inactive')).toBeNull() }) diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts index afba5dc4c90..97f511a1d41 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts @@ -19,6 +19,8 @@ export type TerminalTabActivityStatus = WorktreeStatus type TerminalTabActivityFlags = { hasPermission: boolean hasLiveWorking: boolean + hasLiveMonitoring: boolean + hasInterrupted: boolean hasLiveDone: boolean paneIds: Set } @@ -75,11 +77,15 @@ function getTerminalTabActivityFlags( if (entry.state === 'blocked' || entry.state === 'waiting') { flags.hasPermission = true } else if (entry.state === 'working') { - flags.hasLiveWorking = true + if (entry.workingMode === 'monitoring') { + flags.hasLiveMonitoring = true + } else { + flags.hasLiveWorking = true + } + } else if (entry.interrupted === true) { + // Interrupted is encoded as done, so it must be checked first. + flags.hasInterrupted = true } else if (entry.state === 'done') { - // Why: an interrupted `done` still reads as completed here, matching the - // WorktreeCard dot (resolveWorktreeStatus has no interrupted state); only - // the smart-sort ordering treats interrupts as idle. flags.hasLiveDone = true } } @@ -97,6 +103,8 @@ function getOrCreateTerminalTabActivityFlags( flags = { hasPermission: false, hasLiveWorking: false, + hasLiveMonitoring: false, + hasInterrupted: false, hasLiveDone: false, paneIds: new Set() } @@ -157,6 +165,8 @@ export function resolveTerminalTabActivityStatus({ terminalLayoutsByTabId: terminalLayout ? { [tab.id]: terminalLayout } : undefined, hasPermission: flags?.hasPermission ?? false, hasLiveWorking: flags?.hasLiveWorking ?? false, + hasLiveMonitoring: flags?.hasLiveMonitoring ?? false, + hasInterrupted: flags?.hasInterrupted ?? false, hasLiveDone: flags?.hasLiveDone ?? false, // Why: retained/orchestration promotions are worktree-aggregate concerns; // a tab reflects its own live panes and title only. @@ -166,14 +176,20 @@ export function resolveTerminalTabActivityStatus({ /** True while the tab shows a live in-turn signal (spinner or needs-input). */ export function isTerminalTabActivityLive(status: TerminalTabActivityStatus): boolean { - return status === 'working' || status === 'permission' + return status === 'working' || status === 'monitoring' || status === 'permission' } /** * Glyph-bearing attention states for a terminal tab (tab bar + Cmd+J recent chats). * Quiet active/inactive map to null so identity icons stay clean. */ -export type TerminalTabAttentionBadge = 'working' | 'permission' | 'unread' | 'done' +export type TerminalTabAttentionBadge = + | 'working' + | 'monitoring' + | 'permission' + | 'interrupted' + | 'unread' + | 'done' /** * Single priority ladder shared by the tab strip and Cmd+J recent rows: @@ -192,22 +208,30 @@ export function resolveTerminalTabAttentionBadge({ if (status === 'permission') { return 'permission' } + if (status === 'monitoring') { + return 'monitoring' + } if (hasUnread) { return 'unread' } if (status === 'done') { return 'done' } + if (status === 'interrupted') { + return 'interrupted' + } return null } /** Map a container activity status onto AgentStateDot's vocabulary (no unread — that's a bell). */ export function terminalTabActivityToAgentDotState( status: TerminalTabActivityStatus -): 'working' | 'permission' | 'done' | null { +): 'working' | 'monitoring' | 'permission' | 'interrupted' | 'done' | null { switch (status) { case 'working': + case 'monitoring': case 'permission': + case 'interrupted': case 'done': return status case 'active': diff --git a/src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.ts b/src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.ts index 9b6e85c2fcc..e2d90290043 100644 --- a/src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.ts +++ b/src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.ts @@ -99,8 +99,8 @@ export function useTabGroupTabCloseCommands({ reason: 'user' }) } - destroyWorkspaceWebviews(browserState.browserPagesByWorkspace, item.entityId) closeBrowserTab(item.entityId) + destroyWorkspaceWebviews(browserState.browserPagesByWorkspace, item.entityId) closeUnifiedTab(item.id) } else if (item.contentType === 'simulator') { closeUnifiedTab(item.id) @@ -159,8 +159,8 @@ export function useTabGroupTabCloseCommands({ reason: 'user' }) } - destroyWorkspaceWebviews(browserState.browserPagesByWorkspace, item.entityId) closeBrowserTab(item.entityId) + destroyWorkspaceWebviews(browserState.browserPagesByWorkspace, item.entityId) closeUnifiedTab(item.id) } else if (item.contentType === 'terminal') { closeTab(item.entityId) diff --git a/src/renderer/src/components/task-page-cache-selectors.test.ts b/src/renderer/src/components/task-page-cache-selectors.test.ts index 968189a5417..44777236862 100644 --- a/src/renderer/src/components/task-page-cache-selectors.test.ts +++ b/src/renderer/src/components/task-page-cache-selectors.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' import { shallow } from 'zustand/shallow' -import { workItemsCacheKey, type CacheEntry } from '@/store/slices/github' +import { workItemsCacheKey } from '@/store/github/cache-identity' +import type { CacheEntry } from '@/store/github/cache-model' import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import type { LinearIssue } from '../../../shared/linear/issue-types' import type { LinearCollectionResult } from '../../../shared/linear/workspace-types' diff --git a/src/renderer/src/components/task-page-cache-selectors.ts b/src/renderer/src/components/task-page-cache-selectors.ts index 403a8a97dd1..a563dcae7b1 100644 --- a/src/renderer/src/components/task-page-cache-selectors.ts +++ b/src/renderer/src/components/task-page-cache-selectors.ts @@ -1,9 +1,9 @@ -import { - workItemsCacheKey, - type CacheEntry, - type WorkItemsCacheError, - type WorkItemsCacheSources -} from '@/store/slices/github' +import { workItemsCacheKey } from '@/store/github/cache-identity' +import type { + CacheEntry, + WorkItemsCacheError, + WorkItemsCacheSources +} from '@/store/github/cache-model' import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import { taskPageWorkItemKey, diff --git a/src/renderer/src/components/task-page-jira-cache-selectors.ts b/src/renderer/src/components/task-page-jira-cache-selectors.ts index 969160f13f8..7dfc8075ade 100644 --- a/src/renderer/src/components/task-page-jira-cache-selectors.ts +++ b/src/renderer/src/components/task-page-jira-cache-selectors.ts @@ -1,4 +1,4 @@ -import type { CacheEntry } from '@/store/slices/github' +import type { CacheEntry } from '@/store/github/cache-model' import { getTaskSourceCacheScope, type TaskSourceContext diff --git a/src/renderer/src/components/task-page-linear-cache-selectors.ts b/src/renderer/src/components/task-page-linear-cache-selectors.ts index 31ac2730b27..4114e52c4c8 100644 --- a/src/renderer/src/components/task-page-linear-cache-selectors.ts +++ b/src/renderer/src/components/task-page-linear-cache-selectors.ts @@ -1,4 +1,4 @@ -import type { CacheEntry } from '@/store/slices/github' +import type { CacheEntry } from '@/store/github/cache-model' import type { LinearIssue } from '../../../shared/linear/issue-types' import type { LinearCollectionResult } from '../../../shared/linear/workspace-types' import { sortedStrings } from './task-page-work-item-signatures' diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-monitoring-turn-end.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-monitoring-turn-end.test.ts new file mode 100644 index 00000000000..f0b2aab1520 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-monitoring-turn-end.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, vi } from 'vitest' +import { createAgentCompletionCoordinator } from './agent-completion-coordinator' +import { useAgentCompletionCoordinatorLifecycle } from './agent-completion-coordinator-test-harness' +import { + createHookListenerState, + normalizeHookPayload +} from '../../../../shared/agent-hook-listener' +import { makePaneKey } from '../../../../shared/stable-pane-id' + +// Why: STA-4119's second complaint is the missing completion notification. This drives the REAL +// hook listener and feeds its real output into the REAL coordinator, rather than hand-writing a +// payload — the whole question is whether the two layers actually agree about a monitoring turn. +const PANE = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') +const RUNNING_SHELL = { + id: 'shell-1', + type: 'shell', + status: 'running', + description: 'Run the dev server', + command: 'pnpm dev' +} + +function hookPayload( + state: ReturnType, + payload: Record +) { + const parsed = normalizeHookPayload( + state, + 'claude', + { paneKey: PANE, payload }, + 'production' + )?.payload + if (!parsed) { + throw new Error('listener produced no payload') + } + // The hook server stamps stateStartedAt on the way to the renderer. + return { ...parsed, stateStartedAt: 1_700_000_000_000 } +} + +function createCoordinator() { + const dispatchCompletion = vi.fn() + const dispatchHookLifecycle = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: PANE, + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + dispatchHookLifecycle, + isLive: () => true + }) + return { coordinator, dispatchCompletion, dispatchHookLifecycle } +} + +/** Meta the coordinator hands the notification dispatcher. */ +function completionMeta(dispatchCompletion: ReturnType) { + return dispatchCompletion.mock.calls[0]?.[1] as + | { source?: string; agentStatus?: { state?: string; workingMode?: string } } + | undefined +} + +describe('completion notification when a lead turn ends into monitoring', () => { + useAgentCompletionCoordinatorLifecycle() + + it('announces completion when the turn ends but the pane stays working for a background shell', () => { + const listener = createHookListenerState() + const { coordinator, dispatchCompletion, dispatchHookLifecycle } = createCoordinator() + + coordinator.observeHookStatus( + hookPayload(listener, { hook_event_name: 'UserPromptSubmit', prompt: 'start the dev server' }) + ) + const monitoring = hookPayload(listener, { + hook_event_name: 'Stop', + background_tasks: [RUNNING_SHELL] + }) + + // Precondition: the pane really is in the monitoring state, not done. + expect(monitoring).toMatchObject({ state: 'working', workingMode: 'monitoring' }) + expect(typeof monitoring.turnCompletedAt).toBe('number') + + coordinator.observeHookStatus(monitoring) + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + const meta = completionMeta(dispatchCompletion) + expect(meta?.source).toBe('hook') + // Why: the announcement is a synthesized `done` while the reported row stays `working` — + // the notification follows the TURN boundary, not the rendered state. + expect(meta?.agentStatus?.state).toBe('done') + // Why: announce only. Running pane lifecycle here would settle a pane that is still working. + expect(dispatchHookLifecycle).not.toHaveBeenCalledWith( + expect.objectContaining({ state: 'done' }) + ) + }) + + it('does not announce twice when the background shell later clears to done', () => { + const listener = createHookListenerState() + const { coordinator, dispatchCompletion } = createCoordinator() + + coordinator.observeHookStatus( + hookPayload(listener, { hook_event_name: 'UserPromptSubmit', prompt: 'start the dev server' }) + ) + coordinator.observeHookStatus( + hookPayload(listener, { hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] }) + ) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + + // The shell exits; the same turn's all-clear must not re-announce. + coordinator.observeHookStatus( + hookPayload(listener, { + hook_event_name: 'PostToolUse', + tool_name: 'KillShell', + background_tasks: [] + }) + ) + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + }) + + it('does not announce mid-turn while the agent is still working in the foreground', () => { + const listener = createHookListenerState() + const { coordinator, dispatchCompletion } = createCoordinator() + + coordinator.observeHookStatus( + hookPayload(listener, { hook_event_name: 'UserPromptSubmit', prompt: 'do the thing' }) + ) + coordinator.observeHookStatus( + hookPayload(listener, { hook_event_name: 'PreToolUse', tool_name: 'Bash' }) + ) + + expect(dispatchCompletion).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/paired-runtime-parking-capabilities.ts b/src/renderer/src/components/terminal-pane/paired-runtime-parking-capabilities.ts new file mode 100644 index 00000000000..53e01a03ce8 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/paired-runtime-parking-capabilities.ts @@ -0,0 +1,57 @@ +import { TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' + +type PairedRuntimeParkingCapabilityStatuses = ReadonlyMap< + string, + { status: { capabilities?: readonly string[] } | null | undefined } +> + +type PairedRuntimeParkingEnvironmentIdsCache = { + statuses: PairedRuntimeParkingCapabilityStatuses + environmentIds: ReadonlySet +} + +let pairedRuntimeParkingEnvironmentIdsCache: PairedRuntimeParkingEnvironmentIdsCache | null = null + +function haveSameEnvironmentIds(left: ReadonlySet, right: ReadonlySet): boolean { + if (left.size !== right.size) { + return false + } + for (const environmentId of left) { + if (!right.has(environmentId)) { + return false + } + } + return true +} + +export function selectPairedRuntimeParkingEnvironmentIds( + statuses: PairedRuntimeParkingCapabilityStatuses +): ReadonlySet { + const cached = pairedRuntimeParkingEnvironmentIdsCache + if (cached?.statuses === statuses) { + return cached.environmentIds + } + + const capable = new Set() + for (const [environmentId, entry] of statuses) { + if (entry.status?.capabilities?.includes(TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY)) { + capable.add(environmentId) + } + } + const environmentIds = + cached && haveSameEnvironmentIds(cached.environmentIds, capable) + ? cached.environmentIds + : capable + pairedRuntimeParkingEnvironmentIdsCache = { statuses, environmentIds } + return environmentIds +} + +export function selectPairedRuntimeParkingEnvironmentIdsFromState(state: { + runtimeStatusByEnvironmentId: PairedRuntimeParkingCapabilityStatuses +}): ReadonlySet { + return selectPairedRuntimeParkingEnvironmentIds(state.runtimeStatusByEnvironmentId) +} + +export function resetPairedRuntimeParkingEnvironmentIdsCacheForTest(): void { + pairedRuntimeParkingEnvironmentIdsCache = null +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts index dc3a6c4e2d5..5a6de84e03c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts @@ -328,6 +328,106 @@ describe('connectPanePty', () => { shellForeground: true }) expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + // Why: `agentStatus:drop` deliberately preserves a live pane's per-pane caches, so a + // process-confirmed agent exit must go through the reconcile route instead — otherwise a + // surviving Claude latch resolves the pane's next event straight back to 'working' (STA-4612). + expect(window.api.agentStatus.reconcileEndedProcess).toHaveBeenCalledWith(paneKey) + }) + + it('reconciles a pane that HAS a status row, whose own drop removes it first', async () => { + // Why this shape: the sibling test above covers a pane with no row, where the deferred drop + // early-returns and never touches the pane's accepted-status bookkeeping. That is not the + // production case. A pane worth reconciling always HAS a row — and settling runs the drop + // BEFORE the reconcile, so the reconcile must survive its own drop having already fired. + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + // Both imported after vi.resetModules() so they share pty-connection's module registry; a + // static top-level import here is a DIFFERENT instance and the assertion goes vacuous. + const { createTestStore } = await import('@/store/slices/store-test-helpers') + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('powershell.exe') + vi.mocked(window.api.agentStatus.reconcileEndedProcess).mockClear() + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const ptyId = 'pty-claude-row-confirmed-shell' + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks.onData ?? null + return { id: ptyId } + }) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ isVisibleRef: { current: false } }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + + // A real accepted status write, through the real slice, so the row carries whatever + // bookkeeping the store stamps on an accepted write. `working` is the shape STA-4612 is about: + // a latch is holding the pane open and the agent is about to exit out from under it. + const realStore = createTestStore() + realStore + .getState() + .setAgentStatus(paneKey, { state: 'working', prompt: 'ship it', agentType: 'claude' }) + const row = realStore.getState().agentStatusByPaneKey[paneKey] + expect(row).toBeDefined() + mockStoreState.agentStatusByPaneKey[paneKey] = row + // The real dismissal removes the row and runs the slice's own teardown; mirror both, or the + // ordering this test exists to pin is never exercised. + mockStoreState.dropAgentStatus.mockImplementation((key: string) => { + delete mockStoreState.agentStatusByPaneKey[key] + realStore.getState().dropAgentStatus(key) + }) + + dataCallbackRef.current?.('\x1b]133;D;0\x07') + await vi.advanceTimersByTimeAsync(350) + + expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey) + expect(window.api.agentStatus.reconcileEndedProcess).toHaveBeenCalledWith(paneKey) + }) + + it('does NOT reconcile when the process check could not confirm a shell', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue(null) + vi.mocked(window.api.agentStatus.reconcileEndedProcess).mockClear() + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const ptyId = 'pty-unconfirmed-shell' + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks.onData ?? null + return { id: ptyId } + }) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ isVisibleRef: { current: false } }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { + launchConfig: { agentArgs: '', agentEnv: {} }, + identity: { agentType: 'droid' } + } + + dataCallbackRef.current?.('\x1b]133;D;0\x07') + // Run the whole confirm ladder. At 350ms the unavailable branch has not settled yet, so + // asserting there proves nothing — it would pass just as happily if this path DID tear the + // pane down a moment later. + await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) + await flushAsyncTicks() + + // The unavailable branch really ran: it publishes an unproven foreground before settling. + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: null, + shellForeground: false + }) + expect(window.api.agentStatus.reconcileEndedProcess).not.toHaveBeenCalled() }) it('disarms stale TUI modes in the emulator after a confirmed return to shell', async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts index 9017b94dc98..410f8afac54 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts @@ -6,6 +6,10 @@ import { createMockTransport, createPane, createManager } from './pty-connection import { buildPaneConnectionDeps, buildDirectSshSplitRetryCommit } from './pty-connection-test-deps' import { createInitialStoreState } from './pty-connection-test-store-fixtures' import type { StoreState } from './pty-connection-test-store-state' +import { + pendingSpawnByPaneKey, + pendingSpawnGenerationByPaneKey +} from './pty-connection/pty-connect-limits' import type { MockTransport } from './pty-connection-test-pane-fixtures' import { installTerminalTestGlobals, @@ -139,6 +143,8 @@ describe('connectPanePty', () => { }) afterEach(async () => { + pendingSpawnByPaneKey.clear() + pendingSpawnGenerationByPaneKey.clear() await restoreTerminalTestGlobals() }) @@ -161,6 +167,42 @@ describe('connectPanePty', () => { logSpy.mockRestore() }, 30_000) + it('does not hold a successor behind a canceled spawn serializer declaration', async () => { + const { connectPanePty } = await import('./pty-connection') + const declaration = createDeferred() + const spawn = createDeferred() + const transport = createMockTransport() + transport.connect.mockReturnValueOnce(spawn.promise) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + ptyIdsByTabId: { 'tab-1': [] } + } + vi.mocked(window.api.pty.declarePendingPaneSerializer).mockReturnValueOnce(declaration.promise) + + const binding = connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps() as never + ) + await flushAsyncTicks(12) + binding.dispose() + spawn.resolve(null) + await flushAsyncTicks(12) + + expect(pendingSpawnByPaneKey.size).toBe(0) + expect(window.api.pty.clearPendingPaneSerializer).not.toHaveBeenCalled() + + declaration.resolve(7) + await flushAsyncTicks(12) + + expect(window.api.pty.clearPendingPaneSerializer).toHaveBeenCalledExactlyOnceWith( + expect.any(String), + 7 + ) + }) + it.each(['rejects', 'resolves empty'] as const)( 'settles the exact direct SSH retry when its fresh spawn %s', async (outcome) => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts index c1df93dca25..a54c78f87de 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts @@ -33,14 +33,16 @@ const { scheduleRuntimeGraphSync, shouldSeedCacheTimerOnInitialTitle, toastInfo, - notifyCodexPaneBoundForStaleSweep + notifyCodexPaneBoundForStaleSweep, + presentPaneViewportPreservingSynchronizedOutput } = vi.hoisted(() => ({ resetAndRefreshAllTerminalWebglAtlases: vi.fn(), scheduleTerminalWebglAtlasRecovery: vi.fn(), scheduleRuntimeGraphSync: vi.fn(), shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() + notifyCodexPaneBoundForStaleSweep: vi.fn(), + presentPaneViewportPreservingSynchronizedOutput: vi.fn() })) let mockStoreState: StoreState @@ -57,6 +59,11 @@ vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ resetAndRefreshAllTerminalWebglAtlases })) +vi.mock('@/lib/pane-manager/pane-webgl-renderer', async (importOriginal) => ({ + ...(await importOriginal>()), + presentPaneViewportPreservingSynchronizedOutput +})) + vi.mock('./terminal-webgl-atlas-recovery', () => ({ scheduleTerminalWebglAtlasRecovery })) @@ -201,6 +208,7 @@ describe('connectPanePty', () => { expect.stringContaining('remote snapshot with hidden remote output'), expect.any(Function) ) + expect(presentPaneViewportPreservingSynchronizedOutput).toHaveBeenCalledWith(pane) disposable.dispose() }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-environment.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-environment.ts index 6e9f24f3efe..eb3a40b6803 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-test-environment.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-environment.ts @@ -72,7 +72,8 @@ export function installTerminalTestGlobals(): void { restoreTerminalFit: vi.fn().mockResolvedValue({ restored: true }) }, agentStatus: { - inferInterrupt: vi.fn().mockResolvedValue(false) + inferInterrupt: vi.fn().mockResolvedValue(false), + reconcileEndedProcess: vi.fn() } }, dispatchEvent: vi.fn(), diff --git a/src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts b/src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts index 523b77a32f1..7623bfbafc2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts @@ -151,9 +151,14 @@ export function installAgentIdleWorkingHandlers(session: ConnectPanePtySession): : undefined return attempt })() - session.pendingSpawnKey = session.directSshRetryAttempt - ? JSON.stringify([session.cacheKey, session.directSshRetryAttempt.attemptId]) - : session.cacheKey + // Generation is part of ownership: a recovery remount must not join a spawn + // started by the pane instance it replaced, while StrictMode remounts keep + // the same generation and may still share their in-flight spawn. + session.pendingSpawnKey = JSON.stringify([ + session.cacheKey, + session.tabGeneration, + session.directSshRetryAttempt?.attemptId ?? null + ]) session.capturedDirectSshRetryPtyAccepted = false session.directSshPaneRetrySettlementCancelled = false session.directSshPaneRetrySettlementTimers = new Set>() diff --git a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts index c689b66f3ee..5b25adf6b51 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts @@ -1,4 +1,5 @@ import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager' +import { useAppStore } from '@/store' import { TerminalKittyKeyboardModeTracker } from '../../../../../shared/terminal-kitty-keyboard-mode-tracker' import type { PtyConnectionDeps } from '../pty-connection-types' import { @@ -35,6 +36,7 @@ import { installPtyInputRecovery } from './pty-input-recovery' import { installPtyInputForward } from './pty-input-forward' import { installPtyResizeGeometry } from './pty-resize-geometry' import { installSessionReconcileDispose } from './session-reconcile-dispose' +import { resolveTerminalTabId } from './terminal-tab-id' /** * Establishes a binding between a terminal pane and its corresponding PTY stream, @@ -48,6 +50,43 @@ export function connectPanePty( const session = { pane, manager, deps } as ConnectPanePtySession session.shouldRefreshForegroundSynchronously = (): boolean => !session.manager.hasWebglRenderer(session.pane.id) + const state = useAppStore.getState() + const unifiedTab = state.getTab?.(deps.tabId) + const initialOwnerWorktreeId = + state.getTerminalTabOwnerWorktreeId?.(deps.tabId) ?? + (unifiedTab?.contentType === 'terminal' + ? state.getTerminalTabOwnerWorktreeId?.(unifiedTab.entityId) + : null) + const terminalTabId = resolveTerminalTabId( + { + getTab: state.getTab, + hasTerminalTab: (candidateId) => + Boolean( + state.tabsByWorktree[deps.worktreeId]?.some( + (candidate) => candidate.id === candidateId + ) || + (initialOwnerWorktreeId + ? state.tabsByWorktree[initialOwnerWorktreeId]?.some( + (candidate) => candidate.id === candidateId + ) + : false) + ) + }, + deps.tabId + ) + const ownerWorktreeId = + state.getTerminalTabOwnerWorktreeId?.(terminalTabId) ?? initialOwnerWorktreeId + const terminalTab = + state.tabsByWorktree[deps.worktreeId]?.find((candidate) => candidate.id === terminalTabId) ?? + (ownerWorktreeId + ? state.tabsByWorktree[ownerWorktreeId]?.find((candidate) => candidate.id === terminalTabId) + : undefined) ?? + // Why: folder/worktree migrations can leave the pane's render key stale for one commit. + Object.values(state.tabsByWorktree) + .find((tabs) => tabs.some((candidate) => candidate.id === terminalTabId)) + ?.find((candidate) => candidate.id === terminalTabId) + const tab = terminalTab ?? (unifiedTab && 'generation' in unifiedTab ? unifiedTab : null) + session.tabGeneration = tab?.generation ?? 0 // Why: recovery ownership belongs to this xterm instance. A request that // settles after remount must not remount its already-replaced successor. session.terminalRecoveryGeneration = captureTerminalPaneRecoveryGeneration(session.deps.tabId) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-choice.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-choice.ts index 4d97ecf82c6..ca0da3e9731 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-choice.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-choice.ts @@ -3,7 +3,11 @@ import { useAppStore } from '@/store' import { isWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id' import { getEagerPtyBufferHandle } from '../pty-dispatcher' -import { pendingSpawnByPaneKey, recordPtyConnectDiagnostic } from './pty-connect-limits' +import { + pendingSpawnByPaneKey, + pendingSpawnGenerationByPaneKey, + recordPtyConnectDiagnostic +} from './pty-connect-limits' import { isRemoteRuntimePtyId, canRestorePairedParkedTerminal, @@ -158,6 +162,13 @@ export function runDeferredSessionReattachChoice(session: ConnectPanePtySession) session.allowInitialIdleCacheSeed = false const pendingSpawn = pendingSpawnByPaneKey.get(session.pendingSpawnKey) if (pendingSpawn) { + const pendingGeneration = pendingSpawnGenerationByPaneKey.get(session.pendingSpawnKey) + if (pendingGeneration !== undefined && pendingGeneration !== session.tabGeneration) { + recordPtyConnectDiagnostic(`pane=${session.pane.id} -> STALE PENDING SPAWN`) + session.startFreshSpawn() + scheduleRuntimeGraphSync() + return + } recordPtyConnectDiagnostic(`pane=${session.pane.id} -> PENDING SPAWN`) session.armDirectSshPaneRetryTimeout(pendingSpawn, session.directSshRetryAttempt) void pendingSpawn diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts index 3bbbf76f2d9..d60e94a160f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts @@ -3,7 +3,7 @@ import { hasPtySerializer } from '../pty-buffer-serializer' import { writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' import { STARTUP_CWD_FALLBACK_NOTICE } from './startup-cwd-fallback-notice' -import { pendingSpawnByPaneKey } from './pty-connect-limits' +import { pendingSpawnByPaneKey, pendingSpawnGenerationByPaneKey } from './pty-connect-limits' import { shouldWritePtyOutputForeground } from './foreground-output-scan' import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' import { toProcessExitStartup } from './process-exit-startup' @@ -14,6 +14,7 @@ import type { } from './fresh-spawn-types' import type { ConnectPanePtySession } from './connect-pane-pty-session' +import { resolveTerminalTabId } from './terminal-tab-id' export function bindStartFreshSpawn(session: ConnectPanePtySession): void { session.startFreshSpawn = ( @@ -56,6 +57,15 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { const preSignalPromise = session.runtimeEnvironmentId ? Promise.resolve(null) : window.api.pty.declarePendingPaneSerializer(session.cacheKey).catch(() => null) + const clearPreSignaledSerializer = (): void => { + // A disposed pre-bind connect must not keep a successor behind a slow + // serializer declaration. Cleanup can finish independently of spawn ownership. + void preSignalPromise.then((gen) => { + if (typeof gen === 'number') { + void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {}) + } + }) + } session.transportConnectInFlightSince = Date.now() const effectiveStartup = startupOverride === undefined ? session.paneStartup : startupOverride @@ -86,6 +96,51 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { ...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}), ...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}), ...(session.shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), + shouldContinue: () => { + const state = useAppStore.getState() + const unifiedTab = state.getTab?.(session.deps.tabId) + const initialOwnerWorktreeId = + state.getTerminalTabOwnerWorktreeId?.(session.deps.tabId) ?? + (unifiedTab?.contentType === 'terminal' + ? state.getTerminalTabOwnerWorktreeId?.(unifiedTab.entityId) + : null) + const terminalTabId = resolveTerminalTabId( + { + getTab: state.getTab, + hasTerminalTab: (candidateId) => + Boolean( + state.tabsByWorktree[session.deps.worktreeId]?.some( + (candidate) => candidate.id === candidateId + ) || + (initialOwnerWorktreeId + ? state.tabsByWorktree[initialOwnerWorktreeId]?.some( + (candidate) => candidate.id === candidateId + ) + : false) + ) + }, + session.deps.tabId + ) + const ownerWorktreeId = + state.getTerminalTabOwnerWorktreeId?.(terminalTabId) ?? initialOwnerWorktreeId + const terminalTab = + state.tabsByWorktree[session.deps.worktreeId]?.find( + (candidate) => candidate.id === terminalTabId + ) ?? + (ownerWorktreeId + ? state.tabsByWorktree[ownerWorktreeId]?.find( + (candidate) => candidate.id === terminalTabId + ) + : undefined) + const fallbackTab = Object.values(state.tabsByWorktree) + .find((tabs) => tabs.some((candidate) => candidate.id === terminalTabId)) + ?.find((candidate) => candidate.id === terminalTabId) + const currentTab = + terminalTab ?? + fallbackTab ?? + (unifiedTab && 'generation' in unifiedTab ? unifiedTab : null) + return !session.disposed && (currentTab?.generation ?? 0) === session.tabGeneration + }, callbacks: outputCallbacks.callbacks }) @@ -98,10 +153,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { .then(async (spawnedPtyId) => { if (outputCallbacks.generation !== session.transportStreamGeneration) { session.finishReattachLiveDataDeferral(false, outputCallbacks.generation) - const gen = await preSignalPromise - if (typeof gen === 'number') { - void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {}) - } + clearPreSignaledSerializer() return null } const resolvedPtyId = @@ -114,10 +166,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { session.finishReattachLiveDataDeferral(false, outputCallbacks.generation) // Why: an outstanding declare keeps main's cooperation gate suppressing // this paneKey's daemon-snapshot seed until something releases it. - const gen = await preSignalPromise - if (typeof gen === 'number') { - void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {}) - } + clearPreSignaledSerializer() return null } const connectResult = @@ -196,6 +245,11 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { if (resolvedPtyId) { session.reconcilePtySizeAfterSpawn(resolvedPtyId, session.cols, session.rows) } + if (!resolvedPtyId) { + clearPreSignaledSerializer() + session.finishReattachLiveDataDeferral(false, outputCallbacks.generation) + return null + } const gen = await preSignalPromise // Why: a bound PTY owns the renderer serializer even when the declare was // rejected; the gen token only settles or clears the pending declaration. @@ -206,8 +260,6 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { if (typeof gen === 'number') { void window.api.pty.settlePaneSerializer(session.cacheKey, gen).catch(() => {}) } - } else if (typeof gen === 'number') { - void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {}) } if (resolvedPtyId && session.connectionId) { if ( @@ -229,15 +281,13 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { ) { session.clearRegisteredStartupLaunchConfig() } - const gen = await preSignalPromise - if (typeof gen === 'number') { - void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {}) - } + clearPreSignaledSerializer() return null }) .finally(() => { if (pendingSpawnByPaneKey.get(session.pendingSpawnKey) === trackedPromise) { pendingSpawnByPaneKey.delete(session.pendingSpawnKey) + pendingSpawnGenerationByPaneKey.delete(session.pendingSpawnKey) } }) session.armDirectSshPaneRetryTimeout(trackedPromise, session.directSshRetryAttempt) @@ -259,6 +309,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { // Why: split panes in the same tab can spawn concurrently. Key by pane // as well as tab so a remount cannot attach to a sibling setup pane's PTY. pendingSpawnByPaneKey.set(session.pendingSpawnKey, trackedPromise) + pendingSpawnGenerationByPaneKey.set(session.pendingSpawnKey, session.tabGeneration) return trackedPromise } } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts index 8714adbd84b..076c9194934 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts @@ -11,6 +11,7 @@ import { writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll' +import { presentPaneViewportPreservingSynchronizedOutput } from '@/lib/pane-manager/pane-webgl-renderer' import { buildMainModelSnapshotReplayWrites, hasPositiveTerminalDimensions, @@ -22,6 +23,7 @@ import { HIDDEN_OUTPUT_RESTORE_UNAVAILABLE_WARNING } from './hidden-output-resto import { shouldWritePtyOutputForeground } from './foreground-output-scan' import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' import { restoredSnapshotPaintsPrintableContent } from '../restored-snapshot-coverage' +import { recordTerminalFreezeBreadcrumb } from '../terminal-freeze-breadcrumbs' import type { ConnectPanePtySession } from './connect-pane-pty-session' @@ -152,6 +154,12 @@ export function bindHiddenOutputRestoreSnapshot(session: ConnectPanePtySession): session.recordRendererOrderedSeq(snapshot) recordTerminalOutput(session.pane.terminal) await waitForTerminalReplayWritesParsed(session.pane.terminal) + if (session.deps.isVisibleRef.current) { + presentPaneViewportPreservingSynchronizedOutput(session.pane) + recordTerminalFreezeBreadcrumb('stale-pixel-restore-present', { + paneId: session.pane.id + }) + } }, { shouldRestore: () => diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts index 9f1b2b7ebe7..da59cd06c79 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts @@ -88,12 +88,45 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { } } session.deferredCommandFinishedStatusDrop = null + session.deferredConfirmedShellReconcile = null + /** The pane's foreground was proven to be a shell, so its agent exited. Unlike a user dismissal, + * this must also retire the main-side per-pane caches — a surviving Claude latch resolves the + * next event straight back to `working`. + * + * Ordered by the row's `acceptedStatusSeq`, not by row identity or `updatedAt`: the confirming + * process read can take seconds, an unrelated field moving in that window is not evidence the + * agent is alive, and a genuinely newer row can share the anchor's millisecond. Reading the token + * off the row is what makes the ordering safe — the paired drop runs FIRST and removes the row, + * and a removed row means nothing reported, not "the anchor is gone". + * + * Residual: a row deleted by some OTHER path mid-window and then rebuilt restarts its count, so a + * pane armed at 1 that reports exactly once more compares equal. It needs a foreign drop inside + * the confirm window; the relaunch case that would otherwise hit it is already discarded by + * onCommandStarted, and the cost is retiring a pane the process table just proved is a shell. */ + const reconcileEndedProcessIfPaneQuiet = (armedAcceptedStatusSeq: number | undefined): void => { + const current = useAppStore.getState().agentStatusByPaneKey[session.cacheKey] + if (current && current.acceptedStatusSeq !== armedAcceptedStatusSeq) { + return + } + // Why: main-side only. The renderer row and launch config are already owned by the deferred + // drop above; what that path cannot reach is the hook server's per-pane Claude latches, which + // `agentStatus:drop` deliberately preserves for a still-live pane. Main echoes its own clear + // back through the pane-status-cleared channel, so both sides stay consistent. + window.api?.agentStatus?.reconcileEndedProcess?.(session.cacheKey) + } session.visibleForegroundSamplePending = false session.visibleForegroundSampleSettled = false - session.settleDeferredCommandFinishedStatusDrop = (): void => { + session.settleDeferredCommandFinishedStatusDrop = ( + options: { confirmedShell?: boolean } = {} + ): void => { const dropStatus = session.deferredCommandFinishedStatusDrop + const reconcile = session.deferredConfirmedShellReconcile session.deferredCommandFinishedStatusDrop = null + session.deferredConfirmedShellReconcile = null dropStatus?.() + if (options.confirmedShell) { + reconcile?.() + } } session.isForegroundTrackingAllowed = (id: string): boolean => { if (isRemoteRuntimePtyId(id) || parseAppSshPtyId(id) !== null) { @@ -143,9 +176,11 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { useAppStore.getState().clearAgentLaunchConfig(session.cacheKey) return } - session.settleDeferredCommandFinishedStatusDrop() + session.settleDeferredCommandFinishedStatusDrop({ confirmedShell: true }) }, - onCommandFinishedUnavailable: session.settleDeferredCommandFinishedStatusDrop, + // Why wrapped: passed bare, a caller-supplied argument would be read as `options` and could + // reconcile on the unavailable path, which has no proof the agent exited. + onCommandFinishedUnavailable: () => session.settleDeferredCommandFinishedStatusDrop(), onVisibleForegroundSettled: (outcome) => { session.visibleForegroundSamplePending = false session.visibleForegroundSampleSettled = outcome !== 'inconclusive' @@ -195,10 +230,16 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { if (shouldDeferStatusDrop) { // Why: keep the concrete pane identity routable while the local process // check distinguishes a leaked nested-shell D from a genuine agent exit. + // Anchor the accepted-status ordinal once, here — not per rung of the confirm ladder, which + // can span seconds — so the gate tolerates churn across the whole window. + const armedAcceptedStatusSeq = entry?.acceptedStatusSeq session.deferredCommandFinishedStatusDrop = dropStatus + session.deferredConfirmedShellReconcile = () => + reconcileEndedProcessIfPaneQuiet(armedAcceptedStatusSeq) return } session.deferredCommandFinishedStatusDrop = null + session.deferredConfirmedShellReconcile = null dropStatus() } session.sampleVisiblePaneForegroundAgent = (forceRoutingConfirmation = false): void => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-connect-limits.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-connect-limits.ts index 82e850eef19..e082c5703c9 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pty-connect-limits.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-connect-limits.ts @@ -1,6 +1,7 @@ import { e2eConfig } from '@/lib/e2e-config' export const pendingSpawnByPaneKey = new Map>() +export const pendingSpawnGenerationByPaneKey = new Map() export const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' // Why: relay requests expire at 30s; leave one second for their fallback before re-arming locally. export const DIRECT_SSH_PANE_RETRY_SETTLEMENT_TIMEOUT_MS = 31_000 diff --git a/src/renderer/src/components/terminal-pane/pty-connection/terminal-tab-id.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/terminal-tab-id.test.ts new file mode 100644 index 00000000000..24292e7d85d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/terminal-tab-id.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from 'vitest' +import { resolveTerminalTabId } from './terminal-tab-id' + +describe('resolveTerminalTabId', () => { + it('maps a unified terminal tab id to its legacy owner id', () => { + const getTab = vi.fn(() => ({ contentType: 'terminal', entityId: 'legacy-tab' })) + + expect(resolveTerminalTabId({ getTab }, 'unified-tab')).toBe('legacy-tab') + expect(getTab).toHaveBeenCalledWith('unified-tab') + }) + + it('keeps non-terminal and unknown ids unchanged', () => { + expect( + resolveTerminalTabId( + { getTab: () => ({ contentType: 'editor', entityId: '/tmp/file.ts' }) }, + 'editor-tab' + ) + ).toBe('editor-tab') + expect(resolveTerminalTabId({}, 'legacy-tab')).toBe('legacy-tab') + }) + + it('keeps an exact terminal owner ahead of a stale unified alias', () => { + expect( + resolveTerminalTabId( + { + getTab: () => ({ contentType: 'terminal', entityId: 'old-owner' }), + hasTerminalTab: (tabId) => tabId === 'live-owner' + }, + 'live-owner' + ) + ).toBe('live-owner') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/terminal-tab-id.ts b/src/renderer/src/components/terminal-pane/pty-connection/terminal-tab-id.ts new file mode 100644 index 00000000000..c4e02f82af0 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/terminal-tab-id.ts @@ -0,0 +1,15 @@ +type TerminalTabLookup = { + getTab?: (tabId: string) => { contentType: string; entityId: string } | null + hasTerminalTab?: (tabId: string) => boolean +} + +/** Resolve a renderer tab id to the legacy terminal-tab id used by PTY state. */ +export function resolveTerminalTabId(state: TerminalTabLookup, tabId: string): string { + // The terminal-tab table is authoritative when both id forms are briefly present. + // A stale unified entry must not redirect a live PTY owner to an older entity id. + if (state.hasTerminalTab?.(tabId)) { + return tabId + } + const unifiedTab = state.getTab?.(tabId) + return unifiedTab?.contentType === 'terminal' ? unifiedTab.entityId : tabId +} diff --git a/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts index 4abc51cb28c..66dca98b360 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts @@ -38,6 +38,18 @@ describe('createIpcPtyTransport', () => { transport.disconnect() }) + it('does not create a PTY when the pane generation is stale', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const spawn = window.api.pty.spawn as unknown as ReturnType + const transport = createIpcPtyTransport({}) + + await expect( + transport.connect({ url: '', shouldContinue: () => false, callbacks: {} }) + ).resolves.toBeUndefined() + + expect(spawn).not.toHaveBeenCalled() + }) + it('threads provider command ownership through the spawn IPC', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const transport = createIpcPtyTransport({ diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index d8bdc63f62a..17f390f4ec4 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -154,6 +154,8 @@ export type PtyTransport = { startupCommandDelivery?: StartupCommandDelivery /** Reject a stale restored identity before this transport can publish global PTY handlers. */ admitPtyId?: (ptyId: string) => boolean + /** Reject a stale pane after any pre-spawn test gate but before creating a PTY. */ + shouldContinue?: () => boolean callbacks: PtyCallbacks }) => void | Promise attach: (options: { diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index f713ee6c2f9..5d24ede0b4d 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -33,6 +33,7 @@ import { isPreHandlerPtyStateDiscarded } from './pty-pre-handler-buffer' import { createPtyInputWriteQueue } from './pty-input-write-queue' +import { waitAtTerminalPtyPreSpawnE2EBarrier } from './terminal-pty-pre-spawn-e2e-barrier' import type { PtyDataMeta } from './pty-dispatcher' import type { IpcPtyTransportOptions, PtyConnectResult, PtyTransport } from './pty-transport-types' import { createBellDetector } from '../../../../shared/terminal-bell-detector' @@ -807,6 +808,16 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra } try { + const preSpawnBarrier = waitAtTerminalPtyPreSpawnE2EBarrier() + if (preSpawnBarrier) { + await preSpawnBarrier + if (destroyed) { + return + } + } + if (options.shouldContinue && !options.shouldContinue()) { + return + } // Why: cwd fallback is only for fresh local spawns — reattach keeps the session's cwd and SSH transports resolve cwd on the remote host. const shouldSendLocalCwdFallback = cwdFallback === 'worktree' && !connectionId && !admittedSessionId diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 7d50b9f48de..5e0e42715ca 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -377,6 +377,20 @@ export function createRemoteRuntimePtyTransport( pendingClaimQueryReplyCount += 1 } } + // Why: clearing the claim flag without draining strands the queued bytes. + const flushPendingClaimInput = (stream: RemoteRuntimeMultiplexedTerminal): void => { + const queued = pendingClaimInput + pendingViewportClaim = false + pendingClaimInput = [] + pendingClaimQueryReplyCount = 0 + for (const segment of queued) { + stream.sendInput(segment.text) + } + for (const resolve of viewportClaimReadyWaiters) { + resolve(true) + } + viewportClaimReadyWaiters.clear() + } // Why: tab/leaf ids are shared by paired viewers; the instance suffix keeps one viewer's refresh off peer records. const clientId = `desktop:${tabId ?? 'tab'}:${leafId ?? 'leaf'}:${createBrowserUuid()}` const terminalCreateMutationId = createBrowserUuid() @@ -1338,8 +1352,8 @@ export function createRemoteRuntimePtyTransport( } const stream = getCurrentMultiplexedStream(targetHandle) if (claim ? stream?.claimViewport(cols, rows) : stream?.resize(cols, rows)) { - if (claim) { - pendingViewportClaim = false + if (claim && stream) { + flushPendingClaimInput(stream) } return } @@ -1953,17 +1967,6 @@ export function createRemoteRuntimePtyTransport( // Why: a viewport change during the subscribe round-trip hit the no-op one-shot fallback; replay the latest viewport so the PTY isn't stuck at subscribe-time size. if (pendingViewportClaim && desiredViewport) { nextStream.claimViewport(desiredViewport.cols, desiredViewport.rows) - pendingViewportClaim = false - const queuedInput = pendingClaimInput - pendingClaimInput = [] - pendingClaimQueryReplyCount = 0 - for (const segment of queuedInput) { - nextStream.sendInput(segment.text) - } - for (const resolve of viewportClaimReadyWaiters) { - resolve(true) - } - viewportClaimReadyWaiters.clear() } else if ( desiredViewport && (desiredViewport.cols !== subscribedViewport?.cols || @@ -1971,6 +1974,8 @@ export function createRemoteRuntimePtyTransport( ) { nextStream.resize(desiredViewport.cols, desiredViewport.rows) } + // Why: a live claim may already have cleared the flag, so drain on every install. + flushPendingClaimInput(nextStream) } const transport: PtyTransport = { diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts index a93c28ddc01..3a8c66af137 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it } from 'vitest' import { clearTerminalProviderSnapshotCapabilities, synchronizeTerminalProviderSnapshotCapabilities @@ -11,12 +11,17 @@ import { canParkTerminalWorktreeRenderers, isParkRestorableTerminalPty, isSnapshotBackedTerminalPty, + resetPairedRuntimeParkingEnvironmentIdsCacheForTest, selectPairedRuntimeParkingEnvironmentIds, selectColdParkedTerminalTabs, selectColdParkedTerminalWorktrees } from './terminal-hidden-view-parking' describe('selectPairedRuntimeParkingEnvironmentIds', () => { + beforeEach(() => { + resetPairedRuntimeParkingEnvironmentIdsCacheForTest() + }) + it('selects only reachable hosts advertising the paired parking contract', () => { const statuses = new Map([ [ @@ -29,6 +34,39 @@ describe('selectPairedRuntimeParkingEnvironmentIds', () => { expect(selectPairedRuntimeParkingEnvironmentIds(statuses)).toEqual(new Set(['capable'])) }) + + it('shares the capability Set across status-only changes and replaces it on membership changes', () => { + const first = selectPairedRuntimeParkingEnvironmentIds( + new Map([ + ['runtime-a', { status: { capabilities: ['terminal.paired-parking.v1'] } }], + ['runtime-b', { status: { capabilities: ['terminal.multiplex.v1'] } }] + ]) + ) + const statusOnlyUpdate = selectPairedRuntimeParkingEnvironmentIds( + new Map([ + [ + 'runtime-a', + { + status: { + capabilities: ['terminal.paired-parking.v1'], + runtimeId: 'peer-a-reconnected' + } + } + ], + ['runtime-b', { status: { capabilities: ['terminal.multiplex.v1'], appVersion: '1.5.0' } }] + ]) + ) + expect(statusOnlyUpdate).toBe(first) + + const capabilityUpdate = selectPairedRuntimeParkingEnvironmentIds( + new Map([ + ['runtime-a', { status: { capabilities: ['terminal.paired-parking.v1'] } }], + ['runtime-b', { status: { capabilities: ['terminal.paired-parking.v1'] } }] + ]) + ) + expect(capabilityUpdate).not.toBe(first) + expect(capabilityUpdate).toEqual(new Set(['runtime-a', 'runtime-b'])) + }) }) describe('isSnapshotBackedTerminalPty', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts index a6aa4d676a7..b8d1bf7b072 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts @@ -1,7 +1,6 @@ import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' import { PTY_SESSION_ID_SEPARATOR } from '../../../../shared/pty-session-id-format' -import { TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' import type { TerminalTab } from '../../../../shared/terminal-tab-types' @@ -92,17 +91,11 @@ export type TerminalParkRestorePolicy = { pairedRuntimeParkingEnvironmentIds?: ReadonlySet } -export function selectPairedRuntimeParkingEnvironmentIds( - statuses: ReadonlyMap -): Set { - const capable = new Set() - for (const [environmentId, entry] of statuses) { - if (entry.status?.capabilities?.includes(TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY)) { - capable.add(environmentId) - } - } - return capable -} +export { + resetPairedRuntimeParkingEnvironmentIdsCacheForTest, + selectPairedRuntimeParkingEnvironmentIds, + selectPairedRuntimeParkingEnvironmentIdsFromState +} from './paired-runtime-parking-capabilities' // Why: SSH uses local main's model; paired PTYs are eligible only when their // exact host advertises authoritative bounded restore. diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts index d45be73c63e..d3ed7666d4c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { TERMINAL_WORKTREE_PARK_DELAY_MS } from './terminal-hidden-view-parking' import { clearTerminalProviderSnapshotCapabilities, @@ -6,7 +7,7 @@ import { } from '../terminal/terminal-provider-snapshot-capability' import { TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS, - getTerminalWorktreeParkingInputsKey, + createTerminalWorktreeTopologyProjection, hasPendingRetentionSpawnWork, isEvictionExemptTerminalPty, selectForceParkEvictableTabIds, @@ -14,44 +15,77 @@ import { type TerminalWorktreeRetentionCandidate } from './terminal-hidden-worktree-retention' -describe('getTerminalWorktreeParkingInputsKey', () => { - const tabs = { - 'repo::/worktree': [ - { - id: 'tab-1', - ptyId: 'repo::/worktree@@pty-1', - pendingActivationSpawn: undefined, - title: 'Original title' - } - ] +describe('createTerminalWorktreeTopologyProjection', () => { + function tab(worktreeId: string, id = `tab-${worktreeId}`): TerminalTab { + return { + id, + ptyId: `${worktreeId}@@pty-1`, + worktreeId, + title: 'Original title', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } } - it('ignores display-only tab changes', () => { - const retitledTabs = { - 'repo::/worktree': [{ ...tabs['repo::/worktree'][0], title: 'Updated title' }] - } - expect(getTerminalWorktreeParkingInputsKey(retitledTabs)).toBe( - getTerminalWorktreeParkingInputsKey(tabs) + it('keeps the parking dependency stable for a title-only change across 300 worktrees', () => { + const inspected: string[] = [] + const projector = createTerminalWorktreeTopologyProjection((worktreeId) => + inspected.push(worktreeId) ) + const tabs = Object.fromEntries( + Array.from({ length: 300 }, (_, index) => { + const worktreeId = `wt-${index}` + return [worktreeId, [tab(worktreeId)]] + }) + ) + const first = projector.project(tabs) + inspected.length = 0 + const changedWorktreeId = 'wt-173' + const second = projector.project({ + ...tabs, + [changedWorktreeId]: [{ ...tabs[changedWorktreeId][0], title: 'Updated display title' }] + }) + + expect(second).toBe(first) + expect(inspected).toEqual([changedWorktreeId]) }) - it('changes for inputs consumed by the parking policy', () => { - const original = getTerminalWorktreeParkingInputsKey(tabs) - expect( - getTerminalWorktreeParkingInputsKey({ - 'repo::/worktree': [{ ...tabs['repo::/worktree'][0], ptyId: null }] - }) - ).not.toBe(original) - expect( - getTerminalWorktreeParkingInputsKey({ - 'repo::/worktree': [{ ...tabs['repo::/worktree'][0], pendingActivationSpawn: true }] - }) - ).not.toBe(original) - expect( - getTerminalWorktreeParkingInputsKey({ - 'repo::/worktree': [{ ...tabs['repo::/worktree'][0], id: 'tab-2' }] - }) - ).not.toBe(original) + it.each([ + ['PTY assignment', (original: TerminalTab) => ({ ...original, ptyId: null })], + [ + 'pending spawn', + (original: TerminalTab) => ({ ...original, pendingActivationSpawn: true as const }) + ], + ['tab replacement', (original: TerminalTab) => ({ ...original, id: 'tab-2' })], + ['generation remount', (original: TerminalTab) => ({ ...original, generation: 2 })], + ['startup cwd', (original: TerminalTab) => ({ ...original, startupCwd: '/tmp' })] + ])('changes for %s', (_label, mutate) => { + const projector = createTerminalWorktreeTopologyProjection() + const originalTab = tab('wt-1', 'tab-1') + const first = projector.project({ 'wt-1': [originalTab] }) + const second = projector.project({ 'wt-1': [mutate(originalTab)] }) + + expect(second).not.toBe(first) + }) + + it('changes for add, remove, and move transitions', () => { + const projector = createTerminalWorktreeTopologyProjection() + const first = projector.project({ 'wt-1': [tab('wt-1', 'tab-1')], 'wt-2': [] }) + const added = projector.project({ + 'wt-1': [tab('wt-1', 'tab-1'), tab('wt-1', 'tab-2')], + 'wt-2': [] + }) + const removed = projector.project({ 'wt-1': [tab('wt-1', 'tab-2')], 'wt-2': [] }) + const moved = projector.project({ + 'wt-1': [], + 'wt-2': [tab('wt-2', 'tab-2')] + }) + + expect(added).not.toBe(first) + expect(removed).not.toBe(added) + expect(moved).not.toBe(removed) }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts index 2024002bfa4..51d43e45376 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts @@ -9,6 +9,7 @@ import { type TerminalColdParkPolicyOverrides } from './terminal-hidden-view-parking' import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { createWorktreeTabBucketProjection } from '@/lib/worktree-tab-bucket-projection' // Why these sizes: a retained hidden pane costs a measured ~2.5MB of V8 heap // at the 5k-row default scrollback and ~19MB at 50k (plus per-pane queues), @@ -30,17 +31,20 @@ import type { TerminalTab } from '../../../../shared/terminal-tab-types' export const TERMINAL_HIDDEN_WORKTREE_RETENTION_LIMIT = 4 export const TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS = 15 * 60_000 -type TerminalWorktreeParkingTab = Pick - -export function getTerminalWorktreeParkingInputsKey( - tabsByWorktree: Readonly> -): string { - return JSON.stringify( - Object.entries(tabsByWorktree).map(([worktreeId, tabs]) => [ - worktreeId, - tabs.map((tab) => [tab.id, tab.ptyId, tab.pendingActivationSpawn]) - ]) - ) +export function createTerminalWorktreeTopologyProjection( + onInspectBucket?: (worktreeId: string) => void +) { + return createWorktreeTabBucketProjection({ + projectTab: (tab) => tab, + isSameProjectedTab: (previousTab, nextTab) => + previousTab.id === nextTab.id && + previousTab.ptyId === nextTab.ptyId && + previousTab.worktreeId === nextTab.worktreeId && + previousTab.pendingActivationSpawn === nextTab.pendingActivationSpawn && + previousTab.generation === nextTab.generation && + previousTab.startupCwd === nextTab.startupCwd, + onInspectBucket + }) } export function hasPendingRetentionSpawnWork( diff --git a/src/renderer/src/components/terminal-pane/terminal-pty-pre-spawn-e2e-barrier.ts b/src/renderer/src/components/terminal-pane/terminal-pty-pre-spawn-e2e-barrier.ts new file mode 100644 index 00000000000..cc162e2ec91 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pty-pre-spawn-e2e-barrier.ts @@ -0,0 +1,72 @@ +import { e2eConfig } from '@/lib/e2e-config' + +type BarrierState = { + blocked: boolean + release: () => void + released: Promise + reportBlocked: () => void + blockedReported: Promise +} + +export type TerminalPtyPreSpawnE2EBarrier = { + arm: () => void + waitUntilBlocked: () => Promise + release: () => void + status: () => 'idle' | 'armed' | 'blocked' +} + +let barrier: BarrierState | null = null + +function createBarrier(): BarrierState { + let release = (): void => {} + let reportBlocked = (): void => {} + const released = new Promise((resolve) => { + release = resolve + }) + const blockedReported = new Promise((resolve) => { + reportBlocked = resolve + }) + return { blocked: false, release, released, reportBlocked, blockedReported } +} + +function exposeBarrier(): void { + if (!e2eConfig.exposeStore || typeof window === 'undefined') { + return + } + window.__terminalPtyPreSpawnE2EBarrier ??= { + arm: () => { + if (barrier) { + throw new Error('Terminal PTY pre-spawn E2E barrier is already armed') + } + barrier = createBarrier() + }, + waitUntilBlocked: async () => { + if (!barrier) { + throw new Error('Terminal PTY pre-spawn E2E barrier is not armed') + } + await barrier.blockedReported + }, + release: () => { + barrier?.release() + }, + status: () => (barrier ? (barrier.blocked ? 'blocked' : 'armed') : 'idle') + } +} + +export function waitAtTerminalPtyPreSpawnE2EBarrier(): Promise | null { + if (!e2eConfig.exposeStore || typeof window === 'undefined') { + return null + } + exposeBarrier() + const current = barrier + if (!current || current.blocked) { + return null + } + current.blocked = true + current.reportBlocked() + return current.released.then(() => { + if (barrier === current) { + barrier = null + } + }) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts index 3b198054861..74af99a8e14 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts @@ -8,6 +8,10 @@ import { vi.mock('@/lib/pane-manager/pane-manager-registry', () => ({ resetAndRefreshAllTerminalWebglAtlases: vi.fn() })) +const presentPaneViewport = vi.fn() +vi.mock('@/lib/pane-manager/pane-webgl-renderer', () => ({ + presentPaneViewport: (pane: unknown) => presentPaneViewport(pane) +})) vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({ flushTerminalOutput: vi.fn(), requestTerminalBacklogRecovery: vi.fn() @@ -21,10 +25,6 @@ vi.mock('./pane-helpers', () => ({ fitPanes: vi.fn(), focusActivePane: vi.fn() })) -const scheduleTabRevealWebglAtlasRecovery = vi.fn() -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTabRevealWebglAtlasRecovery: () => scheduleTabRevealWebglAtlasRecovery() -})) const flushDeferredPaneMetricOptionsIfMeasurable = vi.fn((_pane: unknown) => false) vi.mock('@/lib/pane-manager/pane-fit', () => ({ flushDeferredPaneMetricOptionsIfMeasurable: (pane: unknown) => @@ -77,18 +77,19 @@ function resumeArgs(manager: FakeManager, shouldUseLightTabResume: boolean) { describe('resumeTerminalVisibility reveal repaint', () => { beforeEach(() => { vi.clearAllMocks() + repairPaneWebglCanvasDprMismatch.mockReturnValue(false) }) - it('schedules a pane-scoped repaint on a light tab reveal', () => { + it('schedules an atlas-preserving present on a light tab reveal', () => { // The light path is the "click the tab that was not open" gesture: it has // no rendering resume or fit, so without this repaint a hidden-while- // working pane keeps compositing pre-hide pixels. const manager = createManager() resumeTerminalVisibility(resumeArgs(manager, true)) - expect(manager.scheduleRevealRepaint).toHaveBeenCalledTimes(1) + expect(manager.scheduleRevealRepaint).not.toHaveBeenCalled() + expect(manager.scheduleRevealPresent).toHaveBeenCalledTimes(1) expect(manager.resumeRendering).not.toHaveBeenCalled() - expect(scheduleTabRevealWebglAtlasRecovery).toHaveBeenCalledTimes(1) }) it('captures native trim movement before enforcing viewport intent', async () => { @@ -147,6 +148,29 @@ describe('resumeTerminalVisibility reveal repaint', () => { expect(flushDeferredPaneMetricOptionsIfMeasurable).not.toHaveBeenCalled() }) + it('defers a heavy-reveal dpr present until the shared atlas recovery', async () => { + const pane = { terminal: {} } + const manager = createManager() + manager.getPanes.mockReturnValue([pane]) + repairPaneWebglCanvasDprMismatch.mockReturnValueOnce(true) + const { resetAndRefreshAllTerminalWebglAtlases } = vi.mocked( + await import('@/lib/pane-manager/pane-manager-registry') + ) + + resumeTerminalVisibility(resumeArgs(manager, false)) + + expect(repairPaneWebglCanvasDprMismatch).toHaveBeenCalledWith(pane) + expect(presentPaneViewport).not.toHaveBeenCalled() + expect(resetAndRefreshAllTerminalWebglAtlases).toHaveBeenCalledTimes(1) + const atlasResetCallOrder = resetAndRefreshAllTerminalWebglAtlases.mock.invocationCallOrder[0] + if (atlasResetCallOrder === undefined) { + throw new Error('Shared atlas recovery call order missing') + } + expect(repairPaneWebglCanvasDprMismatch.mock.invocationCallOrder[0]).toBeLessThan( + atlasResetCallOrder + ) + }) + it('does not fit on a light tab reveal', () => { const manager = createManager() resumeTerminalVisibility(resumeArgs(manager, true)) @@ -193,6 +217,27 @@ describe('resumeTerminalVisibility reveal repaint', () => { expect(manager.fitAllPanes).not.toHaveBeenCalled() }) + it('repairs WebGL canvas backing-store dpr on window wake', () => { + // Clamshell undock: dpr changes while the pane stayed "visible" with a + // stale backing store; tab-reveal is not in the path. + const first = { terminal: { name: 'pane-a' } } + const second = { terminal: { name: 'pane-b' } } + const manager = createManager() + manager.getPanes.mockReturnValue([first, second]) + repairPaneWebglCanvasDprMismatch.mockReturnValueOnce(true) + + recoverVisibleTerminalWindowWake({ + manager: manager as never as PaneManager, + isActive: true, + clearGlyphAtlases: false + }) + + expect(repairPaneWebglCanvasDprMismatch).toHaveBeenCalledTimes(2) + expect(repairPaneWebglCanvasDprMismatch).toHaveBeenNthCalledWith(1, first) + expect(repairPaneWebglCanvasDprMismatch).toHaveBeenNthCalledWith(2, second) + expect(presentPaneViewport).toHaveBeenCalledWith(first) + }) + it('latches viewport intent before refocus recovery flushes streaming output', async () => { const terminal = { name: 'streaming-terminal' } const manager = createManager() diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts index ee86edeba18..0ea98f1db8f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -14,9 +14,9 @@ import { resetTerminalLinkifierHoverState } from '@/lib/pane-manager/terminal-linkifier-hover-reset' import { focusActivePane } from './pane-helpers' -import { scheduleTabRevealWebglAtlasRecovery } from './terminal-webgl-atlas-recovery' import { flushDeferredPaneMetricOptionsIfMeasurable } from '@/lib/pane-manager/pane-fit' import { repairPaneWebglCanvasDprMismatch } from '@/lib/pane-manager/terminal-canvas-dpr-repair' +import { presentPaneViewport } from '@/lib/pane-manager/pane-webgl-renderer' const VISIBLE_RESUME_FLUSH_CHARS = 256 * 1024 const WINDOW_WAKE_FLUSH_CHARS = 64 * 1024 @@ -88,8 +88,6 @@ export function resumeTerminalVisibility({ // overlay's delayed geometry fit. Still request hidden-output recovery: // agent TUIs can suppress hidden bytes until the pane is foregrounded. requestLightTabBacklogRecovery(manager) - // Why: reveal is the lifecycle boundary that owns hidden renderer repair. - scheduleTabRevealWebglAtlasRecovery() if (flushedDeferredMetrics) { // Why: the light path normally skips fitting, but flushed metrics changed // cell size — refit so cols/rows match before the overlay settles. @@ -109,10 +107,15 @@ export function resumeTerminalVisibility({ // terminals; refresh after reset so rebuilt atlases repaint from xterm. resetAndRefreshAllTerminalWebglAtlases('visibility-resume') } - // Why: the synchronous recovery above can fire before the revealed pane is - // attached and laid out. Follow up after layout with one shared-atlas-safe - // recovery covering every visible terminal manager. - manager.scheduleRevealRepaint() + if (shouldUseLightTabResume) { + // Why: preserve the last coherent frame while a TUI holds DEC 2026. The + // settled refresh arms xterm's watchdog without clearing shared GPU data. + manager.scheduleRevealPresent() + } else { + // Why: rendering was recreated, so the heavy path still needs the proven + // shared-atlas recovery after layout settles. + manager.scheduleRevealRepaint() + } }) } @@ -161,6 +164,13 @@ export function recoverVisibleTerminalWindowWake({ // Why: backlog writes can expose transient viewport geometry while parsing. syncTerminalViewportIntents(manager) for (const pane of manager.getPanes()) { + // Why: clamshell undock / monitor move changes devicePixelRatio while the + // pane can stay "visible" with a stale WebGL backing store. The addon's + // device-pixel observer misses that (no CSS-box change, or no box while + // the lid was closed). Repair here — not only on tab reveal. + if (repairPaneWebglCanvasDprMismatch(pane)) { + presentPaneViewport(pane) + } requestTerminalBacklogRecovery(pane.terminal) flushTerminalOutput(pane.terminal, { maxChars: WINDOW_WAKE_FLUSH_CHARS }) // Why: window blur fires mouseleave, clearing xterm's current link but not @@ -214,6 +224,12 @@ function resumeTerminalVisibilityHeavy(manager: PaneManager, isActive: boolean): // Windows (ANGLE -> D3D11) it can be 100-500 ms but a deferred resume // would paint a stretched DOM-fallback flash, which is worse UX. manager.resumeRendering() + // Why: unchanged grid geometry can skip the reveal fit, but a retained WebGL + // canvas may still carry the previous display's backing-store DPR. The + // caller's atlas recovery presents the final shared-atlas generation. + for (const pane of manager.getPanes()) { + repairPaneWebglCanvasDprMismatch(pane) + } // Why: resumeRendering just re-attached WebGL, whose cell metrics briefly differ // from the DOM renderer's; a raw fit here reflows on a transient one-column-off // grid and garbles diff-painting inline TUIs (grok minimize→restore). diff --git a/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts b/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts index 80013abb860..51be05fc091 100644 --- a/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts @@ -5,8 +5,7 @@ import { } from '@/lib/pane-manager/pane-manager-registry' import * as terminalWebglAtlasRecovery from './terminal-webgl-atlas-recovery' -const { scheduleImagePasteWebglAtlasRecovery, scheduleTabRevealWebglAtlasRecovery } = - terminalWebglAtlasRecovery +const { scheduleImagePasteWebglAtlasRecovery } = terminalWebglAtlasRecovery describe('terminal WebGL atlas recovery', () => { const registeredManagers: { resetWebglTextureAtlases(): void }[] = [] @@ -151,26 +150,4 @@ describe('terminal WebGL atlas recovery', () => { expect(healthyManager.refreshAllPanes).toHaveBeenCalledTimes(3) expect(manager.refreshAllPanes).not.toHaveBeenCalled() }) - - it('recovers immediately on a tab reveal', () => { - vi.useFakeTimers() - const rafCallbacks: FrameRequestCallback[] = [] - vi.stubGlobal( - 'requestAnimationFrame', - vi.fn((callback: FrameRequestCallback) => { - rafCallbacks.push(callback) - return rafCallbacks.length - }) - ) - const manager = registerManager() - - scheduleTabRevealWebglAtlasRecovery() - rafCallbacks[0]?.(0) - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) - expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1) - vi.advanceTimersByTime(120) - vi.advanceTimersByTime(380) - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(3) - expect(manager.refreshAllPanes).toHaveBeenCalledTimes(3) - }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.ts b/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.ts index 72dc08e46c1..9253bdd0ffb 100644 --- a/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.ts +++ b/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.ts @@ -33,9 +33,3 @@ export function scheduleImagePasteWebglAtlasRecovery(): void { // one-shot event, so recover immediately rather than debouncing. scheduleAtlasRecoveryBurst('image-paste') } - -export function scheduleTabRevealWebglAtlasRecovery(): void { - // Why: a tab reveal is an explicit renderer lifecycle boundary where hidden - // GPU state can be stale; ordinary PTY output must not clear the shared atlas. - scheduleAtlasRecoveryBurst('tab-reveal') -} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-visibility-resume.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-visibility-resume.test.ts index c7972f542f5..4fdf805100d 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-visibility-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-visibility-resume.test.ts @@ -260,6 +260,8 @@ describe('useTerminalPaneGlobalEffects', () => { manager.resumeRendering.mockClear() manager.resetWebglTextureAtlases.mockClear() + manager.scheduleRevealRepaint.mockClear() + manager.scheduleRevealPresent.mockClear() manager.refreshAllPanes.mockClear() manager.suspendRendering.mockClear() mocks.fitAndFocusPanes.mockClear() @@ -289,8 +291,10 @@ describe('useTerminalPaneGlobalEffects', () => { expect(manager.resumeRendering).not.toHaveBeenCalled() expect(mocks.fitAndFocusPanes).not.toHaveBeenCalled() expect(mocks.fitPanes).not.toHaveBeenCalled() - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) - expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1) + expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled() + expect(manager.refreshAllPanes).not.toHaveBeenCalled() + expect(manager.scheduleRevealRepaint).not.toHaveBeenCalled() + expect(manager.scheduleRevealPresent).toHaveBeenCalledTimes(1) expect(mocks.focusActivePane).toHaveBeenCalledWith(manager) vi.advanceTimersByTime(500) }) @@ -342,6 +346,8 @@ describe('useTerminalPaneGlobalEffects', () => { manager.resumeRendering.mockClear() manager.resetWebglTextureAtlases.mockClear() + manager.scheduleRevealRepaint.mockClear() + manager.scheduleRevealPresent.mockClear() manager.refreshAllPanes.mockClear() mocks.fitAndFocusPanes.mockClear() mocks.fitPanes.mockClear() @@ -361,8 +367,10 @@ describe('useTerminalPaneGlobalEffects', () => { expect(manager.resumeRendering).not.toHaveBeenCalled() expect(mocks.fitAndFocusPanes).not.toHaveBeenCalled() expect(mocks.fitPanes).not.toHaveBeenCalled() - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) - expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1) + expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled() + expect(manager.refreshAllPanes).not.toHaveBeenCalled() + expect(manager.scheduleRevealRepaint).not.toHaveBeenCalled() + expect(manager.scheduleRevealPresent).toHaveBeenCalledTimes(1) expect(mocks.focusActivePane).toHaveBeenCalledWith(manager) vi.advanceTimersByTime(500) }) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts index 58f4cbd2c46..b0deee4b3c4 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts @@ -2,6 +2,7 @@ import { act, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { selectColdParkedTerminalTabs } from './terminal-hidden-view-parking' const mocks = vi.hoisted(() => ({ storeState: { @@ -18,6 +19,7 @@ const mocks = vi.hoisted(() => ({ }, exemptTabIds: new Set(), exemptSelectCalls: 0, + coldParkSelectCalls: 0, /** Toggled to churn the park verdict the way the crash cluster does. */ watcherCoverage: true })) @@ -26,6 +28,21 @@ vi.mock('../../store', () => ({ useAppStore: (selector: (state: unknown) => unknown) => selector(mocks.storeState) })) +vi.mock('./terminal-hidden-view-parking', async (importOriginal) => { + const actual = await importOriginal<{ + selectColdParkedTerminalTabs: typeof selectColdParkedTerminalTabs + }>() + return { + ...actual, + selectColdParkedTerminalTabs: ( + args: Parameters[0] + ) => { + mocks.coldParkSelectCalls += 1 + return actual.selectColdParkedTerminalTabs(args) + } + } +}) + vi.mock('./terminal-eviction-exempt-tabs', () => ({ selectEvictionExemptTerminalTabIds: (_worktreeId: string, tabs: readonly { id: string }[]) => { mocks.exemptSelectCalls += 1 @@ -86,6 +103,7 @@ describe('useTerminalTabColdParking measure-clock contract', () => { beforeEach(() => { vi.useFakeTimers() vi.setSystemTime(1_000_000) + mocks.coldParkSelectCalls = 0 }) afterEach(() => { @@ -205,6 +223,51 @@ describe('useTerminalTabColdParking measure-clock contract', () => { } }) + it('skips parking scans for capability-equivalent status writes and scans membership changes', () => { + const args = hookArgs(false) + mocks.storeState.runtimeStatusByEnvironmentId = new Map([ + ['runtime-a', { status: { capabilities: ['terminal.multiplex.v1'], runtimeId: 'peer-a' } }] + ]) + const { rerender } = renderHook( + (props: ReturnType) => useTerminalTabColdParking(props), + { initialProps: args } + ) + const initialSelectCalls = mocks.coldParkSelectCalls + + mocks.storeState.runtimeStatusByEnvironmentId = new Map([ + [ + 'runtime-a', + { + status: { + capabilities: ['terminal.multiplex.v1'], + runtimeId: 'peer-a-reconnected', + appVersion: '1.5.0' + } + } + ] + ]) + act(() => { + rerender(args) + }) + expect(mocks.coldParkSelectCalls).toBe(initialSelectCalls) + + mocks.storeState.runtimeStatusByEnvironmentId = new Map([ + [ + 'runtime-a', + { + status: { + capabilities: ['terminal.multiplex.v1', 'terminal.paired-parking.v1'], + runtimeId: 'peer-a-reconnected' + } + } + ] + ]) + act(() => { + rerender(args) + }) + expect(mocks.coldParkSelectCalls).toBe(initialSelectCalls + 1) + }) + // Why: the flip-damping pin removes the tab from the parked set, and every // hysteresis deadline of a long-hidden tab is already past — so the pin // deadline is the only wakeup that can ever re-park it. Without scheduling diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts index c078eed66c9..03b93a382e5 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts @@ -16,7 +16,7 @@ import { import { getTerminalTabColdParkRecheckDelayMs } from './terminal-cold-park-recheck-deadlines' import { TERMINAL_TAB_COLD_PARK_DELAY_MS, - selectPairedRuntimeParkingEnvironmentIds, + selectPairedRuntimeParkingEnvironmentIdsFromState, selectColdParkedTerminalTabs } from './terminal-hidden-view-parking' import { @@ -105,10 +105,8 @@ export function useTerminalTabColdParking(args: { const terminalSshParkingEnabled = useAppStore( (state) => state.settings?.terminalSshViewParking !== false ) - const runtimeStatusByEnvironmentId = useAppStore((state) => state.runtimeStatusByEnvironmentId) - const pairedRuntimeParkingEnvironmentIds = useMemo( - () => selectPairedRuntimeParkingEnvironmentIds(runtimeStatusByEnvironmentId), - [runtimeStatusByEnvironmentId] + const pairedRuntimeParkingEnvironmentIds = useAppStore( + selectPairedRuntimeParkingEnvironmentIdsFromState ) const sleepingAgentSessionsByPaneKey = useAppStore( (state) => state.sleepingAgentSessionsByPaneKey @@ -254,7 +252,7 @@ export function useTerminalTabColdParking(args: { activeTerminalTabId, isWorktreeActive, pendingStartupByTabId, - runtimeStatusByEnvironmentId, + pairedRuntimeParkingEnvironmentIds, shouldMeasureHiddenWorktree, terminalParkingEnabled, terminalSshParkingEnabled, diff --git a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts index b866c90af42..c903ec1a2f9 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts @@ -2,15 +2,30 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { renderHook } from '@testing-library/react' import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import type { PaneWebglCanvasDprRepairState } from '@/lib/pane-manager/terminal-canvas-dpr-repair' -const { recoverVisibleTerminalWindowWakeMock } = vi.hoisted(() => ({ - recoverVisibleTerminalWindowWakeMock: vi.fn() +const { + recoverVisibleTerminalWindowWakeMock, + repairPaneWebglCanvasDprMock, + presentPaneViewportMock +} = vi.hoisted(() => ({ + recoverVisibleTerminalWindowWakeMock: vi.fn(), + repairPaneWebglCanvasDprMock: vi.fn<() => PaneWebglCanvasDprRepairState>(() => 'current'), + presentPaneViewportMock: vi.fn() })) vi.mock('./terminal-visibility-resume', () => ({ recoverVisibleTerminalWindowWake: recoverVisibleTerminalWindowWakeMock })) +vi.mock('@/lib/pane-manager/terminal-canvas-dpr-repair', () => ({ + repairPaneWebglCanvasDpr: repairPaneWebglCanvasDprMock +})) + +vi.mock('@/lib/pane-manager/pane-webgl-renderer', () => ({ + presentPaneViewport: presentPaneViewportMock +})) + import { useTerminalWindowWakeRecovery } from './use-terminal-window-wake-recovery' import { getTerminalFreezeBreadcrumbs, @@ -29,6 +44,9 @@ describe('useTerminalWindowWakeRecovery', () => { beforeEach(() => { systemResumedCallback = null recoverVisibleTerminalWindowWakeMock.mockClear() + repairPaneWebglCanvasDprMock.mockClear() + presentPaneViewportMock.mockClear() + repairPaneWebglCanvasDprMock.mockReturnValue('current') unsubscribeSystemResumed.mockClear() onSystemResumed.mockClear() resetTerminalFreezeBreadcrumbsForTesting() @@ -180,4 +198,118 @@ describe('useTerminalWindowWakeRecovery', () => { expect(onSystemResumed).not.toHaveBeenCalled() }) + + it('repairs WebGL canvas dpr on window resize without wiping the glyph atlas', () => { + // Chromium emits resize when devicePixelRatio changes (undock / monitor + // move) even if the CSS box is unchanged. + const pane = { id: 1, terminal: {} } + const resizeManager = { getPanes: () => [pane] } as unknown as PaneManager + repairPaneWebglCanvasDprMock.mockReturnValue('repaired') + vi.stubGlobal('devicePixelRatio', 1) + const { unmount } = renderHook(() => + useTerminalWindowWakeRecovery({ + isVisible: true, + managerRef: { current: resizeManager }, + isActiveRef: { current: true }, + isVisibleRef: { current: true } + }) + ) + + window.dispatchEvent(new Event('resize')) + expect(repairPaneWebglCanvasDprMock).not.toHaveBeenCalled() + + vi.stubGlobal('devicePixelRatio', 2) + window.dispatchEvent(new Event('resize')) + + expect(repairPaneWebglCanvasDprMock).toHaveBeenCalledWith(pane) + expect(presentPaneViewportMock).toHaveBeenCalledWith(pane) + expect(recoverVisibleTerminalWindowWakeMock).not.toHaveBeenCalled() + + unmount() + vi.stubGlobal('devicePixelRatio', 3) + window.dispatchEvent(new Event('resize')) + expect(repairPaneWebglCanvasDprMock).toHaveBeenCalledTimes(1) + }) + + it('retains a dpr transition until the visible manager is available', () => { + const pane = { id: 1, terminal: {} } + const managerRef: { current: PaneManager | null } = { current: null } + vi.stubGlobal('devicePixelRatio', 1) + renderHook(() => + useTerminalWindowWakeRecovery({ + isVisible: true, + managerRef, + isActiveRef: { current: true }, + isVisibleRef: { current: true } + }) + ) + + vi.stubGlobal('devicePixelRatio', 2) + window.dispatchEvent(new Event('resize')) + expect(repairPaneWebglCanvasDprMock).not.toHaveBeenCalled() + + managerRef.current = { getPanes: () => [pane] } as unknown as PaneManager + window.dispatchEvent(new Event('resize')) + expect(repairPaneWebglCanvasDprMock).toHaveBeenCalledWith(pane) + }) + + it('deduplicates a deferred dpr retry and consumes it after repair succeeds', () => { + const pane = { id: 1, terminal: {} } + const callbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callbacks.push(callback) + return callbacks.length + }) + vi.stubGlobal('devicePixelRatio', 1) + repairPaneWebglCanvasDprMock.mockReturnValueOnce('deferred').mockReturnValue('repaired') + renderHook(() => + useTerminalWindowWakeRecovery({ + isVisible: true, + managerRef: { current: { getPanes: () => [pane] } as unknown as PaneManager }, + isActiveRef: { current: true }, + isVisibleRef: { current: true } + }) + ) + + vi.stubGlobal('devicePixelRatio', 2) + window.dispatchEvent(new Event('resize')) + window.dispatchEvent(new Event('resize')) + expect(repairPaneWebglCanvasDprMock).toHaveBeenCalledTimes(1) + expect(callbacks).toHaveLength(1) + + callbacks.shift()?.(performance.now()) + expect(presentPaneViewportMock).toHaveBeenCalledWith(pane) + window.dispatchEvent(new Event('resize')) + expect(repairPaneWebglCanvasDprMock).toHaveBeenCalledTimes(2) + }) + + it('caps a deferred dpr transition at sixteen animation-frame retries', () => { + const pane = { id: 1, terminal: {} } + const callbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callbacks.push(callback) + return callbacks.length + }) + vi.stubGlobal('devicePixelRatio', 1) + repairPaneWebglCanvasDprMock.mockReturnValue('deferred') + renderHook(() => + useTerminalWindowWakeRecovery({ + isVisible: true, + managerRef: { current: { getPanes: () => [pane] } as unknown as PaneManager }, + isActiveRef: { current: true }, + isVisibleRef: { current: true } + }) + ) + + vi.stubGlobal('devicePixelRatio', 2) + window.dispatchEvent(new Event('resize')) + let callbackCount = 0 + while (callbacks.length > 0) { + callbacks.shift()?.(performance.now()) + callbackCount += 1 + } + + expect(callbackCount).toBe(16) + expect(repairPaneWebglCanvasDprMock).toHaveBeenCalledTimes(17) + }) }) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts index ba8102a5fb1..444d0f1f9dd 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts @@ -1,6 +1,8 @@ import { useEffect } from 'react' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { recoverVisibleTerminalWindowWake } from './terminal-visibility-resume' +import { repairPaneWebglCanvasDpr } from '@/lib/pane-manager/terminal-canvas-dpr-repair' +import { presentPaneViewport } from '@/lib/pane-manager/pane-webgl-renderer' import { recordTerminalFreezeBreadcrumb } from './terminal-freeze-breadcrumbs' import type { IDisposable } from '@xterm/xterm' @@ -16,6 +18,8 @@ type WindowWakePtyBinding = IDisposable & { reassertPtySizeAfterWindowWake?: () => void } +const DPR_RECOVERY_RETRY_FRAMES = 16 + export function useTerminalWindowWakeRecovery({ isVisible, managerRef, @@ -28,7 +32,10 @@ export function useTerminalWindowWakeRecovery({ return } let wakeRecoveryFrameId: number | null = null + let dprRecoveryFrameId: number | null = null + let dprRecoveryFramesRemaining = 0 let settledClearGlyphAtlases = false + let observedDevicePixelRatio = window.devicePixelRatio const cancelScheduledWakeRecovery = (): void => { if (wakeRecoveryFrameId === null || typeof cancelAnimationFrame !== 'function') { wakeRecoveryFrameId = null @@ -37,6 +44,13 @@ export function useTerminalWindowWakeRecovery({ cancelAnimationFrame(wakeRecoveryFrameId) wakeRecoveryFrameId = null } + const cancelScheduledDprRecovery = (): void => { + if (dprRecoveryFrameId !== null && typeof cancelAnimationFrame === 'function') { + cancelAnimationFrame(dprRecoveryFrameId) + } + dprRecoveryFrameId = null + dprRecoveryFramesRemaining = 0 + } const reassertPanePtySizes = (): void => { for (const binding of panePtyBindingsRef?.current.values() ?? []) { // Why: one settled read avoids duplicate SSH RPCs while still detecting a dropped resize. @@ -111,7 +125,59 @@ export function useTerminalWindowWakeRecovery({ recoverVisibleWake(true, 'system-resumed') } } + const repairVisiblePanesForDpr = (devicePixelRatio: number): boolean => { + const manager = managerRef.current + if (!manager || !isVisibleRef.current) { + return false + } + let deferred = false + for (const pane of manager.getPanes?.() ?? []) { + const state = repairPaneWebglCanvasDpr(pane) + deferred ||= state === 'deferred' + if (state === 'repaired') { + presentPaneViewport(pane) + } + } + if (!deferred) { + observedDevicePixelRatio = devicePixelRatio + } + return !deferred + } + const scheduleDprRecovery = (): void => { + if (dprRecoveryFrameId !== null || typeof requestAnimationFrame !== 'function') { + return + } + dprRecoveryFramesRemaining = DPR_RECOVERY_RETRY_FRAMES + const retry = (): void => { + dprRecoveryFrameId = null + if (repairVisiblePanesForDpr(window.devicePixelRatio)) { + dprRecoveryFramesRemaining = 0 + return + } + dprRecoveryFramesRemaining -= 1 + if (dprRecoveryFramesRemaining > 0) { + dprRecoveryFrameId = requestAnimationFrame(retry) + } + } + dprRecoveryFrameId = requestAnimationFrame(retry) + } + const onWindowResize = (): void => { + // Why: Chromium emits window resize on devicePixelRatio changes even when + // the CSS box is unchanged (monitor move / undock). xterm's own observer + // misses that while the canvas had no box (laptop lid closed). + const devicePixelRatio = window.devicePixelRatio + if (devicePixelRatio === observedDevicePixelRatio) { + return + } + if (dprRecoveryFrameId !== null) { + return + } + if (!repairVisiblePanesForDpr(devicePixelRatio)) { + scheduleDprRecovery() + } + } window.addEventListener('focus', onFocus) + window.addEventListener('resize', onWindowResize) if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') { document.addEventListener('visibilitychange', onVisibilityChange) } @@ -125,7 +191,9 @@ export function useTerminalWindowWakeRecovery({ : null return () => { cancelScheduledWakeRecovery() + cancelScheduledDprRecovery() window.removeEventListener('focus', onFocus) + window.removeEventListener('resize', onWindowResize) if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') { document.removeEventListener('visibilitychange', onVisibilityChange) } diff --git a/src/renderer/src/components/ui/popover-content-ref.ts b/src/renderer/src/components/ui/popover-content-ref.ts deleted file mode 100644 index cbdf783471d..00000000000 --- a/src/renderer/src/components/ui/popover-content-ref.ts +++ /dev/null @@ -1,44 +0,0 @@ -import * as React from 'react' -import type { Popover as PopoverPrimitive } from 'radix-ui' - -type PopoverContentRef = React.ComponentProps['ref'] - -export function updatePopoverContentRef( - forwardedRef: PopoverContentRef | undefined, - node: HTMLDivElement | null, - cancelWheelFrames: () => void -): (() => void) | undefined { - if (node === null) { - cancelWheelFrames() - if (typeof forwardedRef === 'function') { - forwardedRef(null) - } else if (forwardedRef) { - forwardedRef.current = null - } - return undefined - } - - if (typeof forwardedRef === 'function') { - const cleanup = forwardedRef(node) - // Why: React callback refs may return cleanup; wrapping the Radix ref must - // preserve that cleanup instead of replacing it with a null callback. - return () => { - cancelWheelFrames() - if (typeof cleanup === 'function') { - cleanup() - } else { - forwardedRef(null) - } - } - } - - if (forwardedRef) { - forwardedRef.current = node - } - return () => { - cancelWheelFrames() - if (forwardedRef) { - forwardedRef.current = null - } - } -} diff --git a/src/renderer/src/components/ui/popover-wheel-scroll.test.tsx b/src/renderer/src/components/ui/popover-wheel-scroll.test.tsx new file mode 100644 index 00000000000..dcf1a804df5 --- /dev/null +++ b/src/renderer/src/components/ui/popover-wheel-scroll.test.tsx @@ -0,0 +1,273 @@ +// @vitest-environment happy-dom +import { + act, + createRef, + type Ref, + type RefCallback, + type WheelEvent as ReactWheelEvent, + type WheelEventHandler +} from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Popover, PopoverContent, PopoverTrigger } from './popover' + +let root: Root | null = null +let container: HTMLDivElement + +/** happy-dom reports 0 for layout, so scroll geometry has to be defined per element. */ +function makeScrollable(el: HTMLElement, scrollHeight: number, clientHeight: number): void { + Object.defineProperty(el, 'scrollHeight', { value: scrollHeight, configurable: true }) + Object.defineProperty(el, 'clientHeight', { value: clientHeight, configurable: true }) +} + +function wheel(el: HTMLElement, deltaY: number): WheelEvent { + const event = new WheelEvent('wheel', { deltaY, bubbles: true, cancelable: true }) + act(() => { + el.dispatchEvent(event) + }) + return event +} + +function renderPopover( + contentClassName: string, + nested: boolean, + { + onWheel, + onWheelCapture, + innerOnWheel, + contentRef, + portalContainer + }: { + onWheel?: WheelEventHandler + onWheelCapture?: WheelEventHandler + innerOnWheel?: WheelEventHandler + contentRef?: Ref + portalContainer?: HTMLElement + } = {} +): void { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => { + root!.render( + + open + + {nested ? ( +
+
+ tall +
+
+ ) : ( +
+ tall +
+ )} +
+
+ ) + }) +} + +describe('PopoverContent wheel shim', () => { + beforeEach(() => { + root = null + }) + + afterEach(() => { + if (root) { + act(() => root!.unmount()) + } + document.body.replaceChildren() + vi.unstubAllGlobals() + }) + + it('scrolls a nested viewport when the content itself cannot scroll', () => { + // The workspace-cleanup Filters panel: a flex column whose PopoverContent is + // overflow-hidden, with a ScrollArea viewport above a pinned footer. + renderPopover('popover-scroll-content', true) + const content = document.querySelector('[data-slot="popover-content"]')! + const viewport = document.querySelector('[data-testid="viewport"]')! + makeScrollable(content, 800, 400) + makeScrollable(viewport, 1000, 400) + + const event = wheel(document.querySelector('[data-testid="inner"]')!, 120) + + expect(viewport.scrollTop).toBe(120) + expect(content.scrollTop).toBe(0) + expect(event.defaultPrevented).toBe(true) + }) + + it('still scrolls the content itself when it is the scroller', () => { + renderPopover('popover-scroll-content', false) + const content = document.querySelector('[data-slot="popover-content"]')! + // Inline because happy-dom does not apply the stylesheet; in the app + // `.popover-scroll-content` already sets `overflow-y: auto` (main.css). + content.style.overflowY = 'auto' + makeScrollable(content, 1000, 400) + + const event = wheel(document.querySelector('[data-testid="inner"]')!, 120) + + expect(content.scrollTop).toBe(120) + expect(event.defaultPrevented).toBe(true) + }) + + it('leaves popovers that did not opt in alone', () => { + renderPopover('', true) + const viewport = document.querySelector('[data-testid="viewport"]')! + makeScrollable(viewport, 1000, 400) + + const event = wheel(document.querySelector('[data-testid="inner"]')!, 120) + + expect(viewport.scrollTop).toBe(0) + expect(event.defaultPrevented).toBe(false) + }) + + it('ignores vertically clipped elements and horizontal-only scrollers', () => { + renderPopover('popover-scroll-content', true) + const content = document.querySelector('[data-slot="popover-content"]')! + const viewport = document.querySelector('[data-testid="viewport"]')! + content.style.overflowY = 'hidden' + viewport.style.overflowY = 'hidden' + viewport.style.overflowX = 'auto' + makeScrollable(content, 1000, 400) + makeScrollable(viewport, 1000, 400) + Object.defineProperty(viewport, 'scrollWidth', { value: 1000, configurable: true }) + Object.defineProperty(viewport, 'clientWidth', { value: 400, configurable: true }) + + const event = wheel(document.querySelector('[data-testid="inner"]')!, 120) + + expect(content.scrollTop).toBe(0) + expect(viewport.scrollTop).toBe(0) + expect(event.defaultPrevented).toBe(false) + }) + + it('does nothing when no element in the target chain can scroll', () => { + renderPopover('popover-scroll-content', true) + const content = document.querySelector('[data-slot="popover-content"]')! + const viewport = document.querySelector('[data-testid="viewport"]')! + content.style.overflowY = 'hidden' + viewport.style.overflowY = 'hidden' + makeScrollable(content, 1000, 400) + makeScrollable(viewport, 1000, 400) + + const event = wheel(document.querySelector('[data-testid="inner"]')!, 120) + + expect(content.scrollTop).toBe(0) + expect(viewport.scrollTop).toBe(0) + expect(event.defaultPrevented).toBe(false) + }) + + it('runs for the shim-only marker, which carries no styling', () => { + // The workspace-cleanup Filters panel needs the wheel shim but must NOT inherit + // `.popover-scroll-content`'s 15rem max-height, which would crush its 471px column. + renderPopover('popover-wheel-scroll', true) + const content = document.querySelector('[data-slot="popover-content"]')! + const viewport = document.querySelector('[data-testid="viewport"]')! + viewport.style.overflowY = 'auto' + makeScrollable(content, 400, 400) + makeScrollable(viewport, 1000, 400) + + const event = wheel(document.querySelector('[data-testid="inner"]')!, 120) + + expect(viewport.scrollTop).toBe(120) + expect(event.defaultPrevented).toBe(true) + }) + + it('lets a consumer prevent the shim scroll', () => { + const onWheel = vi.fn((event: ReactWheelEvent) => { + Object.defineProperty(event.nativeEvent, 'preventDefault', { value: vi.fn() }) + event.preventDefault() + }) + const portalContainer = document.createElement('div') + document.body.appendChild(portalContainer) + renderPopover('popover-wheel-scroll', true, { onWheel, portalContainer }) + const viewport = document.querySelector('[data-testid="viewport"]')! + viewport.style.overflowY = 'auto' + makeScrollable(viewport, 1000, 400) + + const event = wheel(document.querySelector('[data-testid="inner"]')!, 120) + + expect(onWheel).toHaveBeenCalledOnce() + expect(event.defaultPrevented).toBe(false) + expect(viewport.scrollTop).toBe(0) + }) + + it('respects consumer capture cancellation', () => { + const onWheelCapture = vi.fn((event: ReactWheelEvent) => { + Object.defineProperty(event.nativeEvent, 'preventDefault', { value: vi.fn() }) + event.preventDefault() + }) + renderPopover('popover-wheel-scroll', true, { onWheelCapture }) + const viewport = document.querySelector('[data-testid="viewport"]')! + viewport.style.overflowY = 'auto' + makeScrollable(viewport, 1000, 400) + + const event = wheel(document.querySelector('[data-testid="inner"]')!, 120) + + expect(onWheelCapture).toHaveBeenCalledOnce() + expect(event.defaultPrevented).toBe(false) + expect(viewport.scrollTop).toBe(0) + }) + + it('respects descendant cancellation', () => { + const innerOnWheel = vi.fn((event: ReactWheelEvent) => { + // Model React's passive delegated wheel event: synthetic cancellation does not + // update the native event observed by the shim. + Object.defineProperty(event.nativeEvent, 'preventDefault', { value: vi.fn() }) + event.preventDefault() + }) + renderPopover('popover-wheel-scroll', true, { innerOnWheel }) + const viewport = document.querySelector('[data-testid="viewport"]')! + viewport.style.overflowY = 'auto' + makeScrollable(viewport, 1000, 400) + + const event = wheel(document.querySelector('[data-testid="inner"]')!, 120) + + expect(innerOnWheel).toHaveBeenCalledOnce() + expect(event.defaultPrevented).toBe(false) + expect(viewport.scrollTop).toBe(0) + }) + + it('preserves callback ref cleanup', () => { + const cleanup = vi.fn() + const contentRef: RefCallback = vi.fn(() => cleanup) + renderPopover('', false, { contentRef }) + + expect(contentRef).toHaveBeenCalledTimes(1) + expect(contentRef).toHaveBeenCalledWith( + document.querySelector('[data-slot="popover-content"]') + ) + + act(() => root!.unmount()) + root = null + expect(cleanup).toHaveBeenCalledOnce() + expect(contentRef).toHaveBeenCalledTimes(1) + }) + + it('clears callback refs that do not return cleanup', () => { + const callbackRef = vi.fn((_node: HTMLDivElement | null): void => {}) + renderPopover('', false, { contentRef: callbackRef }) + + act(() => root!.unmount()) + root = null + expect(callbackRef).toHaveBeenLastCalledWith(null) + }) + + it('clears object refs on detach', () => { + const objectRef = createRef() + renderPopover('', false, { contentRef: objectRef }) + expect(objectRef.current).toBeInstanceOf(HTMLDivElement) + + act(() => root!.unmount()) + root = null + expect(objectRef.current).toBeNull() + }) +}) diff --git a/src/renderer/src/components/ui/popover.test.tsx b/src/renderer/src/components/ui/popover.test.tsx deleted file mode 100644 index 3d84a1f8e33..00000000000 --- a/src/renderer/src/components/ui/popover.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { updatePopoverContentRef } from './popover-content-ref' - -describe('updatePopoverContentRef', () => { - it('preserves callback ref cleanup when content detaches', () => { - const node = {} as HTMLDivElement - const cancelWheelFrames = vi.fn() - const cleanup = vi.fn() - const ref = vi.fn(() => cleanup) - - const detach = updatePopoverContentRef(ref, node, cancelWheelFrames) - detach?.() - - expect(ref).toHaveBeenCalledTimes(1) - expect(ref).toHaveBeenCalledWith(node) - expect(cancelWheelFrames).toHaveBeenCalledTimes(1) - expect(cleanup).toHaveBeenCalledTimes(1) - }) - - it('clears callback refs that do not return cleanup', () => { - const node = {} as HTMLDivElement - const cancelWheelFrames = vi.fn() - const ref = vi.fn() - - const detach = updatePopoverContentRef(ref, node, cancelWheelFrames) - detach?.() - - expect(ref).toHaveBeenNthCalledWith(1, node) - expect(ref).toHaveBeenNthCalledWith(2, null) - expect(cancelWheelFrames).toHaveBeenCalledTimes(1) - }) - - it('clears object refs when content detaches', () => { - const node = {} as HTMLDivElement - const cancelWheelFrames = vi.fn() - const ref: { current: HTMLDivElement | null } = { current: null } - - const detach = updatePopoverContentRef(ref, node, cancelWheelFrames) - expect(ref.current).toBe(node) - - detach?.() - - expect(ref.current).toBeNull() - expect(cancelWheelFrames).toHaveBeenCalledTimes(1) - }) - - it('cancels pending frames when React supplies a null node', () => { - const cancelWheelFrames = vi.fn() - const ref: { current: HTMLDivElement | null } = { current: {} as HTMLDivElement } - - updatePopoverContentRef(ref, null, cancelWheelFrames) - - expect(ref.current).toBeNull() - expect(cancelWheelFrames).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/renderer/src/components/ui/popover.tsx b/src/renderer/src/components/ui/popover.tsx index 5523f5021ee..01365ba665b 100644 --- a/src/renderer/src/components/ui/popover.tsx +++ b/src/renderer/src/components/ui/popover.tsx @@ -4,7 +4,9 @@ import * as React from 'react' import { Popover as PopoverPrimitive } from 'radix-ui' import { cn } from '@/lib/utils' -import { updatePopoverContentRef } from './popover-content-ref' + +// React delegates wheel passively, so native defaultPrevented may not reflect synthetic cancellation. +const consumerPreventedWheelEvents = new WeakSet() function Popover(props: React.ComponentProps) { return @@ -18,6 +20,96 @@ function PopoverAnchor(props: React.ComponentProps } +/** + * Nearest scrollable element between the wheel target and the popover content, + * inclusive of both. Returns null when nothing in that chain can scroll. + */ +function resolvePopoverScroller( + target: EventTarget | null, + content: HTMLElement +): HTMLElement | null { + let node = target instanceof Node ? target : null + while (node && node !== content.parentNode) { + if (node instanceof HTMLElement && node.scrollHeight > node.clientHeight) { + const overflowY = getComputedStyle(node).overflowY + if (overflowY === 'auto' || overflowY === 'scroll') { + return node + } + } + node = node.parentNode + } + return null +} + +function handlePopoverWheel(event: WheelEvent, content: HTMLDivElement): void { + if ( + event.defaultPrevented || + consumerPreventedWheelEvents.has(event) || + !(event.target instanceof Node) || + !content.contains(event.target) + ) { + return + } + + // Why two markers: `popover-scroll-content` also imposes a 15rem max-height and its + // own overflow, while `popover-wheel-scroll` opts into the shim alone. + if ( + !content.classList.contains('popover-scroll-content') && + !content.classList.contains('popover-wheel-scroll') + ) { + return + } + + const el = resolvePopoverScroller(event.target, content) + if (!el) { + return + } + + const delta = + event.deltaMode === WheelEvent.DOM_DELTA_LINE + ? event.deltaY * 16 + : event.deltaMode === WheelEvent.DOM_DELTA_PAGE + ? event.deltaY * el.clientHeight + : event.deltaY + const maxScrollTop = el.scrollHeight - el.clientHeight + const nextScrollTop = Math.max(0, Math.min(maxScrollTop, el.scrollTop + delta)) + + // Why: Radix dialog scroll-lock swallows native wheel scrolling in portaled popovers. + if (nextScrollTop !== el.scrollTop) { + event.preventDefault() + el.scrollTop = nextScrollTop + } +} + +type PopoverContentRef = React.ComponentProps['ref'] + +function attachPopoverContent( + content: HTMLDivElement, + portalContainer: HTMLElement | null | undefined, + forwardedRef: PopoverContentRef | undefined +): () => void { + // React delegates portal events here first, preserving onWheel-before-shim ordering. + const wheelTarget = portalContainer ?? content.ownerDocument.body + const handleWheel = (event: WheelEvent): void => handlePopoverWheel(event, content) + wheelTarget.addEventListener('wheel', handleWheel, { passive: false }) + + const refCleanup = typeof forwardedRef === 'function' ? forwardedRef(content) : undefined + if (forwardedRef && typeof forwardedRef !== 'function') { + forwardedRef.current = content + } + + return () => { + wheelTarget.removeEventListener('wheel', handleWheel) + if (typeof refCleanup === 'function') { + refCleanup() + } else if (typeof forwardedRef === 'function') { + forwardedRef(null) + } else if (forwardedRef) { + forwardedRef.current = null + } + } +} + function PopoverContent({ className, align = 'center', @@ -25,67 +117,45 @@ function PopoverContent({ portalContainer, style, onWheel, + onWheelCapture, ref: forwardedRef, ...props }: React.ComponentProps & { portalContainer?: HTMLElement | null }) { - const wheelFrameIdsRef = React.useRef>(new Set()) - - const cancelWheelFrames = React.useCallback(() => { - for (const frameId of wheelFrameIdsRef.current) { - cancelAnimationFrame(frameId) - } - wheelFrameIdsRef.current.clear() - }, []) - - const setContentRef = React.useCallback( - (node: HTMLDivElement | null) => { - // Why: the wheel shim schedules frames against the content node; cancel - // them when Radix removes that node instead of from a passive Effect. - return updatePopoverContentRef(forwardedRef, node, cancelWheelFrames) - }, - [cancelWheelFrames, forwardedRef] - ) - - const handleWheel = React.useCallback( - (event: React.WheelEvent) => { + const handleConsumerWheel = React.useCallback( + (event: React.WheelEvent): void => { onWheel?.(event) if (event.defaultPrevented) { - return - } - - const el = event.currentTarget - if (!el.classList.contains('popover-scroll-content') || el.scrollHeight <= el.clientHeight) { - return - } - - const delta = - event.deltaMode === WheelEvent.DOM_DELTA_LINE - ? event.deltaY * 16 - : event.deltaMode === WheelEvent.DOM_DELTA_PAGE - ? event.deltaY * el.clientHeight - : event.deltaY - const maxScrollTop = el.scrollHeight - el.clientHeight - const nextScrollTop = Math.max(0, Math.min(maxScrollTop, el.scrollTop + delta)) - - // Why: issue drawers are Radix dialogs with scroll-lock. These popovers - // are portaled outside the dialog subtree, so native wheel scrolling is - // swallowed even though the scrollbar can be dragged. - if (nextScrollTop !== el.scrollTop) { - const previousScrollTop = el.scrollTop - event.stopPropagation() - const frameId = requestAnimationFrame(() => { - wheelFrameIdsRef.current.delete(frameId) - if (el.scrollTop === previousScrollTop) { - el.scrollTop = nextScrollTop - } - }) - wheelFrameIdsRef.current.add(frameId) + consumerPreventedWheelEvents.add(event.nativeEvent) } }, [onWheel] ) + const handleConsumerWheelCapture = React.useCallback( + (event: React.WheelEvent): void => { + onWheelCapture?.(event) + if (event.defaultPrevented) { + consumerPreventedWheelEvents.add(event.nativeEvent) + } + }, + [onWheelCapture] + ) + + const setContentRef = React.useCallback( + (node: HTMLDivElement | null) => { + if (node) { + return attachPopoverContent(node, portalContainer, forwardedRef) + } + if (typeof forwardedRef === 'function') { + forwardedRef(null) + } else if (forwardedRef) { + forwardedRef.current = null + } + return undefined + }, + [forwardedRef, portalContainer] + ) return ( @@ -111,7 +181,8 @@ function PopoverContent({ WebkitAppRegion: 'no-drag' } as React.CSSProperties } - onWheel={handleWheel} + onWheel={handleConsumerWheel} + onWheelCapture={handleConsumerWheelCapture} {...props} /> diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.stale-while-revalidate.test.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.stale-while-revalidate.test.tsx index bc4a90dd892..262cc541526 100644 --- a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.stale-while-revalidate.test.tsx +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.stale-while-revalidate.test.tsx @@ -7,6 +7,7 @@ import type { WorkspaceCleanupScanProgress, WorkspaceCleanupScanResult } from '../../../../shared/workspace-cleanup' +import type { WorkspaceCleanupBrowseState } from '../../../../shared/workspace-cleanup-browse-state' import { createTestStore, seedStore } from '../../store/slices/store-test-helpers' import { resetWorkspaceCleanupBrowsePersistTimer } from '../../store/slices/workspace-cleanup-browse' import { NOW, makeCandidate } from '../../store/slices/workspace-cleanup-slice-test-harness' @@ -349,6 +350,50 @@ describe('WorkspaceCleanupDialog stale-while-revalidate', () => { expect(rowCheckbox('alpha')?.getAttribute('aria-checked')).toBe('true') }) + it('reports selections removed by facet filters', async () => { + installApi({ + scannedAt: CACHED_AT, + candidates: [ + makeCandidate({ + worktreeId: 'repo1::/tmp/alpha', + displayName: 'alpha', + blockers: ['pinned'] + }), + makeCandidate({ worktreeId: 'repo1::/tmp/beta', displayName: 'beta' }) + ], + errors: [] + }) + await renderDialog() + await openDialog() + + await act(async () => rowCheckbox('alpha')?.click()) + const browse = holders.store.getState().workspaceCleanupBrowse as WorkspaceCleanupBrowseState + await act(async () => { + holders.store.setState({ + workspaceCleanupBrowse: { + ...browse, + filters: { + ...browse.filters, + safety: { + ...browse.filters.safety, + blockers: ['pinned'], + blockerMode: 'none-of' + } + } + } + }) + }) + await flush() + + expect(holders.infoToasts).toContain( + '1 selected workspace is hidden by the current filters and was deselected.' + ) + + await act(async () => holders.store.setState({ workspaceCleanupBrowse: browse })) + await flush() + expect(rowCheckbox('alpha')?.getAttribute('aria-checked')).toBe('false') + }) + it('adopts the in-flight scan on reopen instead of starting a duplicate', async () => { const rig = installApi(cachedFleet()) await renderDialog() @@ -399,7 +444,16 @@ describe('WorkspaceCleanupDialog stale-while-revalidate', () => { }) await flush() - // Both rows are default-selected suggestions after the first settle. + // Nothing is pre-selected: the dialog no longer acts on its own verdict. + expect(rowCheckbox('alpha')?.getAttribute('aria-checked')).toBe('false') + expect(rowCheckbox('beta')?.getAttribute('aria-checked')).toBe('false') + + // The user selects both, which is what must survive the refresh. + await act(async () => { + rowCheckbox('alpha')?.click() + rowCheckbox('beta')?.click() + }) + await flush() expect(rowCheckbox('alpha')?.getAttribute('aria-checked')).toBe('true') expect(rowCheckbox('beta')?.getAttribute('aria-checked')).toBe('true') @@ -424,4 +478,36 @@ describe('WorkspaceCleanupDialog stale-while-revalidate', () => { expect(rowNames()).not.toContain('beta') expect(holders.infoToasts).toContain('1 selected workspace no longer exists.') }) + + it('keeps selected active workspaces outside the select-all scope', async () => { + installApi({ + scannedAt: CACHED_AT, + candidates: [ + makeCandidate({ worktreeId: 'repo1::/tmp/ready', displayName: 'ready' }), + makeCandidate({ + worktreeId: 'repo1::/tmp/active', + displayName: 'active', + reasons: [], + blockers: ['active-workspace'] + }) + ], + errors: [] + }) + await renderDialog() + await openDialog() + + await act(async () => rowCheckbox('active')?.click()) + const selectAll = container?.querySelector( + '[aria-label="Select 1 safety-checked workspace"]' + ) + expect(selectAll?.getAttribute('aria-checked')).toBe('false') + + await act(async () => selectAll?.click()) + expect(rowCheckbox('ready')?.getAttribute('aria-checked')).toBe('true') + expect(rowCheckbox('active')?.getAttribute('aria-checked')).toBe('true') + + await act(async () => selectAll?.click()) + expect(rowCheckbox('ready')?.getAttribute('aria-checked')).toBe('false') + expect(rowCheckbox('active')?.getAttribute('aria-checked')).toBe('true') + }) }) diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx index 606d42aca3c..44c71f00b0f 100644 --- a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx @@ -41,13 +41,13 @@ import { import { useWorkspaceCleanupFacetRows } from './use-workspace-cleanup-facet-rows' import { useWorkspaceCleanupGitEvidence } from './use-workspace-cleanup-git-evidence' import { useWorkspaceCleanupRowOrder } from './use-workspace-cleanup-row-order' +import { openWorkspaceCleanupForgetLocally } from './workspace-cleanup-forget-locally-button' import { formatVanishedSelectionNotice, - getDefaultSelectedWorkspaceCleanupIdentities, + formatWithheldSelectionNotice, toggleSetMember } from './workspace-cleanup-selection-model' -/** One filterable list of every workspace Orca knows about. */ export default function WorkspaceCleanupDialog(): React.JSX.Element | null { const lifecycle = useWorkspaceCleanupDialogLifecycle() if (!lifecycle.mountedContent) { @@ -79,17 +79,10 @@ function WorkspaceCleanupDialogContent({ useShallow(selectWorkspaceCleanupDeletionPhases) ) const browse = useWorkspaceCleanupBrowseState() - // Why: facet counts/options are only rendered inside the filter popover; - // tracking its open state lets their O(N) passes skip while it is closed. const [facetPanelOpen, setFacetPanelOpen] = useState(false) const [expandedRowIds, setExpandedRowIds] = useState>(() => new Set()) const [rowsScrollElement, setRowsScrollElement] = useState(null) - const selectedDefaultsScanAtRef = useRef(null) const wasOpenRef = useRef(open) - // Why: a refresh completing mid-open must not clobber a selection the user - // already made (or was given) for this open — defaults apply at most once. - const defaultsAppliedForOpenRef = useRef(false) - const selectionTouchedRef = useRef(false) const mountedRef = useMountedRef() // Why: the facet clock must be stable across renders but never older than @@ -99,8 +92,6 @@ function WorkspaceCleanupDialogContent({ useEffect(() => { if (open && !wasOpenRef.current) { - defaultsAppliedForOpenRef.current = false - selectionTouchedRef.current = false setOpenedAt(Date.now()) } wasOpenRef.current = open @@ -175,7 +166,9 @@ function WorkspaceCleanupDialogContent({ 'components.workspace.cleanup.browse.measureSizesFailed', 'Could not scan workspace sizes' ), - { description: scanError instanceof Error ? scanError.message : String(scanError) } + { + description: scanError instanceof Error ? scanError.message : String(scanError) + } ) }) }, [mountedRef, refreshWorkspaceSpace]) @@ -193,33 +186,20 @@ function WorkspaceCleanupDialogContent({ }, [deletingIdentities, rows, selectedIds]) const selectedCount = selectedCandidates.length - useEffect(() => { - if (loading || !scan || selectedDefaultsScanAtRef.current === scan.scannedAt) { - return - } - selectedDefaultsScanAtRef.current = scan.scannedAt - if (removalInFlightRef.current) { - return - } - if (defaultsAppliedForOpenRef.current || selectionTouchedRef.current) { - return - } - defaultsAppliedForOpenRef.current = true - setSelectedIds( - getDefaultSelectedWorkspaceCleanupIdentities(scan.candidates, deletingIdentities) - ) - }, [deletingIdentities, loading, removalInFlightRef, scan, setSelectedIds]) + // A destructive dialog leaves selection to the user. const pruneSelectionToVisibleRows = useEffectEvent(() => { - setSelectedIds((current) => { - const next = new Set( - [...current].filter( - (identity) => - facetRows.facetMatchedIdentities.has(identity) && !deletingIdentities.has(identity) - ) + const next = new Set( + [...selectedIds].filter( + (identity) => + facetRows.facetMatchedIdentities.has(identity) && !deletingIdentities.has(identity) ) - return next.size === current.size ? current : next - }) + ) + if (next.size === selectedIds.size) { + return + } + setSelectedIds(next) + toast.info(formatWithheldSelectionNotice(selectedIds.size - next.size)) }) useEffect(() => { @@ -254,8 +234,6 @@ function WorkspaceCleanupDialogContent({ if (loading) { return } - // Why: a settled refresh reconciles by host-qualified identity — selections survive it, - // and only rows that truly no longer exist drop (with a visible reason). pruneVanishedSelections() }, [candidateIdentities, deletingIdentities, loading]) @@ -292,7 +270,6 @@ function WorkspaceCleanupDialogContent({ const toggleSelectedRow = useCallback( (identity: string) => { - selectionTouchedRef.current = true setSelectedIds((current) => toggleSetMember(current, identity)) }, [setSelectedIds] @@ -305,31 +282,45 @@ function WorkspaceCleanupDialogContent({ : facetRows.selectableIdentities.filter((identity) => !deletingIdentities.has(identity)), [deletingIdentities, facetRows.selectableIdentities, removal.removalInFlight] ) + // Header state is scoped to the same rows the header action controls. + const selectedSelectableCount = useMemo(() => { + let count = 0 + for (const identity of selectableIdentities) { + if (selectedIds.has(identity)) { + count += 1 + } + } + return count + }, [selectableIdentities, selectedIds]) + const toggleSelectAll = useCallback( (selectAll: boolean) => { - selectionTouchedRef.current = true - setSelectedIds(selectAll ? new Set(selectableIdentities) : new Set()) + setSelectedIds((current) => { + const next = new Set(current) + for (const identity of selectableIdentities) { + if (selectAll) { + next.add(identity) + } else { + next.delete(identity) + } + } + return next + }) }, [selectableIdentities, setSelectedIds] ) - // Why: stable per-row handlers so React.memo keeps unchanged CandidateRow - // instances from re-rendering on scan stream-in and selection changes. const openConfirmRemove = removal.openConfirmRemove const handleRemoveRow = useCallback( (candidate: WorkspaceCleanupCandidate) => { - // Why: rows stay actionable while a rescan streams; the confirm-time - // preflight is the safety gate, not the scan state. if (removalInFlightRef.current) { return } - selectionTouchedRef.current = true setSelectedIds(new Set([getWorkspaceCleanupCandidateIdentity(candidate)])) openConfirmRemove([candidate]) }, [openConfirmRemove, removalInFlightRef, setSelectedIds] ) - const handleViewCandidate = useCallback( (candidate: WorkspaceCleanupCandidate) => { markCandidateViewed(candidate) @@ -364,9 +355,6 @@ function WorkspaceCleanupDialogContent({ loading={loading} scannedAt={scan?.scannedAt ?? null} scanProgress={scanProgress} - // Why: leaving the progress view no longer closes the dialog, so the - // list is reachable mid-batch; a second batch would silently no-op. - // A streaming rescan does NOT gate deletion — preflight does. deleteDisabled={ selectedCount === 0 || removal.removalProgress !== null || removal.removalInFlight } @@ -390,7 +378,7 @@ function WorkspaceCleanupDialogContent({ facetPanelOpen={facetPanelOpen} onFacetPanelOpenChange={setFacetPanelOpen} selectableCount={selectableIdentities.length} - selectedCount={selectedCount} + selectedCount={selectedSelectableCount} spaceScanning={workspaceSpaceScanning} spaceProgress={workspaceSpaceProgress} gitPendingCount={gitEvidence.pendingWorktreeIds.size} @@ -421,6 +409,8 @@ function WorkspaceCleanupDialogContent({ onView={handleViewCandidate} onIgnore={ignoreCandidate} onRemove={handleRemoveRow} + onForgetLocally={openWorkspaceCleanupForgetLocally} + onDeleteAnyway={removal.confirmUnverifiedRemoval} /> diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-browse-state.test.tsx b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-browse-state.test.tsx new file mode 100644 index 00000000000..bd2f33b02fe --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-browse-state.test.tsx @@ -0,0 +1,131 @@ +// @vitest-environment happy-dom +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { create } from 'zustand' +import type { AppState } from '@/store/types' +import { + createWorkspaceCleanupBrowseSlice, + resetWorkspaceCleanupBrowsePersistTimer +} from '@/store/slices/workspace-cleanup-browse' +import { createDefaultWorkspaceCleanupBrowseState } from '../../../../shared/workspace-cleanup-browse-state' +import { listAppliedWorkspaceCleanupFilters } from '../../../../shared/workspace-cleanup-applied-filters' +import { useWorkspaceCleanupBrowseState } from './use-workspace-cleanup-browse-state' +import type { WorkspaceCleanupBrowseController } from './use-workspace-cleanup-browse-state' + +const store = create()( + (...a) => + ({ + workspaceCleanupDismissals: {}, + ...createWorkspaceCleanupBrowseSlice(...a) + }) as unknown as AppState +) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: AppState) => T): T => store(selector) +})) + +let root: Root | null = null +let container: HTMLDivElement | null = null + +function mountController(): { current: WorkspaceCleanupBrowseController | null } { + const ref: { current: WorkspaceCleanupBrowseController | null } = { current: null } + function Probe(): null { + ref.current = useWorkspaceCleanupBrowseState() + return null + } + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => root!.render()) + return ref +} + +describe('useWorkspaceCleanupBrowseState', () => { + beforeEach(() => { + vi.useFakeTimers() + ;(globalThis as { window: unknown }).window = { + ...globalThis.window, + api: { ui: { set: vi.fn().mockResolvedValue(undefined) } } + } + store.setState({ + workspaceCleanupBrowse: createDefaultWorkspaceCleanupBrowseState() + } as Partial) + }) + + afterEach(() => { + if (root) { + act(() => root!.unmount()) + } + root = null + container = null + document.body.replaceChildren() + resetWorkspaceCleanupBrowsePersistTimer() + vi.useRealTimers() + }) + + it('keeps both patches when two groups are written in one tick', () => { + const controller = mountController() + + // Why one act(): a checkbox toggle writes several groups at once. Reading the + // rendered `browse` snapshot per call would make the second patch overwrite + // the first, which is what this asserts against. + act(() => { + controller.current!.patchFilters('activity', { idleMinDays: 20 }) + controller.current!.patchFilters('size', { maxBytes: 500 }) + }) + + const filters = store.getState().workspaceCleanupBrowse.filters + expect(filters.activity.idleMinDays).toBe(20) + expect(filters.size.maxBytes).toBe(500) + }) + + it('keeps both patches when the same group is written twice in one tick', () => { + const controller = mountController() + + act(() => { + controller.current!.patchFilters('git', { minAhead: 2 }) + controller.current!.patchFilters('git', { branchQuery: 'release' }) + }) + + const git = store.getState().workspaceCleanupBrowse.filters.git + expect(git.minAhead).toBe(2) + expect(git.branchQuery).toBe('release') + }) + + it('flips sort direction against the latest state, not the rendered snapshot', () => { + const controller = mountController() + + act(() => { + controller.current!.toggleSortField('size') + controller.current!.toggleSortField('size') + }) + + expect(store.getState().workspaceCleanupBrowse.sort).toEqual({ + field: 'size', + direction: 'desc' + }) + }) + + it('keeps a cleared chip cleared when a facet patch lands in the same tick', () => { + const controller = mountController() + act(() => controller.current!.patchFilters('activity', { idleMinDays: 20 })) + + const format = new Proxy({}, { get: () => () => 'chip' }) as Parameters< + typeof listAppliedWorkspaceCleanupFilters + >[1] + const chip = listAppliedWorkspaceCleanupFilters( + store.getState().workspaceCleanupBrowse.filters, + format + ).find((a) => a.id === 'activity.idleMinDays')! + + act(() => { + controller.current!.replaceFilters(chip.clear) + controller.current!.patchFilters('git', { states: ['dirty'] }) + }) + + const filters = store.getState().workspaceCleanupBrowse.filters + expect(filters.activity.idleMinDays).toBeNull() + expect(filters.git.states).toEqual(['dirty']) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-browse-state.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-browse-state.ts index 948928b9a93..3651f4e01a0 100644 --- a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-browse-state.ts +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-browse-state.ts @@ -16,6 +16,10 @@ export type WorkspaceCleanupBrowseController = { ) => void toggleSortField: (field: WorkspaceCleanupSortField) => void clearFilters: () => void + /** Applies a whole-state transform, for callers that clear one named constraint. */ + replaceFilters: ( + transform: (filters: WorkspaceCleanupFilterState) => WorkspaceCleanupFilterState + ) => void } /** @@ -26,43 +30,58 @@ export function useWorkspaceCleanupBrowseState(): WorkspaceCleanupBrowseControll const browse = useAppStore((s) => s.workspaceCleanupBrowse) const updateBrowse = useAppStore((s) => s.updateWorkspaceCleanupBrowseState) + // Why the updater form: a checkbox toggle writes several fields at once, so two + // patches can land in one tick. Reading `browse` from the render would make the + // second overwrite the first. const patchFilters = useCallback( (key, value) => { - const current = browse.filters[key] - const next = - typeof current === 'object' && current !== null - ? { ...current, ...(value as object) } - : value - // Cast: a computed key over a union widens the spread result past - // WorkspaceCleanupFilterState even though `key` is constrained to it. - const filters = { ...browse.filters, [key]: next } as WorkspaceCleanupFilterState - updateBrowse({ ...browse, filters }) + updateBrowse((current) => { + const group = current.filters[key] + const next = + typeof group === 'object' && group !== null ? { ...group, ...(value as object) } : value + // Cast: a computed key over a union widens the spread result past + // WorkspaceCleanupFilterState even though `key` is constrained to it. + const filters = { ...current.filters, [key]: next } as WorkspaceCleanupFilterState + return { ...current, filters } + }) }, - [browse, updateBrowse] + [updateBrowse] ) // Why: re-picking the active sort flips its direction. const toggleSortField = useCallback( (field: WorkspaceCleanupSortField) => { - const direction = - browse.sort.field === field && browse.sort.direction === 'asc' ? 'desc' : 'asc' - updateBrowse({ ...browse, sort: { field, direction } }) + updateBrowse((current) => { + const direction = + current.sort.field === field && current.sort.direction === 'asc' ? 'desc' : 'asc' + return { ...current, sort: { field, direction } } + }) }, - [browse, updateBrowse] + [updateBrowse] + ) + + // Same updater form as patchFilters: a chip clear and a facet patch can land in one + // tick, and whichever read the render snapshot would undo the other. + const replaceFilters = useCallback( + (transform) => { + updateBrowse((current) => ({ ...current, filters: transform(current.filters) })) + }, + [updateBrowse] ) const clearFilters = useCallback(() => { - updateBrowse({ - ...browse, + updateBrowse((current) => ({ + ...current, filters: createDefaultWorkspaceCleanupFilterState() - }) - }, [browse, updateBrowse]) + })) + }, [updateBrowse]) return { filters: browse.filters, sort: browse.sort, patchFilters, toggleSortField, - clearFilters + clearFilters, + replaceFilters } } diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-removal.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-removal.ts index 43093a10c8c..0bf71f3aee8 100644 --- a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-removal.ts +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-removal.ts @@ -14,6 +14,8 @@ import { type WorkspaceCleanupRemovalProgress } from './workspace-cleanup-background-removal' import { filterWorkspaceCleanupRemovalCandidates } from './workspace-cleanup-removal-candidates' +import { createWorkspaceCleanupSnapshotPruneBatch } from './workspace-cleanup-snapshot-prune-batch' +import { useWorkspaceCleanupUnverifiedRemoval } from './use-workspace-cleanup-unverified-removal' export type WorkspaceCleanupRemovalController = { confirming: boolean @@ -24,11 +26,12 @@ export type WorkspaceCleanupRemovalController = { /** Synchronous guard; state alone lags a second confirm click. */ removalInFlightRef: { current: boolean } /** Keyed by host-qualified identity so a failure marks only the confirmed host's row. */ - rowFailures: Record + rowFailures: Record resetRowFailures: () => void resetForReopen: () => void openConfirmRemove: (candidates: readonly WorkspaceCleanupCandidate[]) => void confirmRemove: () => void + confirmUnverifiedRemoval: (candidate: WorkspaceCleanupCandidate) => void cancelConfirmRemove: () => void backToList: () => void } @@ -60,7 +63,7 @@ export function useWorkspaceCleanupRemoval({ const [deletionPhaseByIdentity, setDeletionPhaseByIdentity] = useState< Record >({}) - const [rowFailures, setRowFailures] = useState>({}) + const [rowFailures, setRowFailures] = useState>({}) const removalInFlightRef = useRef(false) // Why: the dialog stays mounted across cleanup runs, so late settlements from // an earlier batch must not mutate a newer batch's row state. @@ -93,6 +96,13 @@ export function useWorkspaceCleanupRemoval({ [clearWorktreeDeleteState] ) + const confirmUnverifiedRemoval = useWorkspaceCleanupUnverifiedRemoval({ + setRowFailures, + setDeletionPhaseByIdentity, + clearQueuedDeleteState, + onDeselect + }) + const openConfirmRemove = useCallback((candidates: readonly WorkspaceCleanupCandidate[]) => { const nextCandidates = filterWorkspaceCleanupRemovalCandidates( candidates, @@ -174,22 +184,7 @@ export function useWorkspaceCleanupRemoval({ removalInFlightRef.current = false } try { - const beginSnapshotPruneBatch = window.api.workspaceCleanup.beginRemovalSnapshotPruneBatch - const recordSnapshotPrune = window.api.workspaceCleanup.recordRemovalSnapshotPrune - const finishSnapshotPruneBatch = window.api.workspaceCleanup.finishRemovalSnapshotPruneBatch - const snapshotPruneBatch = - typeof beginSnapshotPruneBatch === 'function' && - typeof recordSnapshotPrune === 'function' && - typeof finishSnapshotPruneBatch === 'function' - ? (() => { - const batchId = crypto.randomUUID() - return { - batchId, - begin: () => beginSnapshotPruneBatch({ batchId }), - finish: () => finishSnapshotPruneBatch({ batchId }) - } - })() - : undefined + const snapshotPruneBatch = createWorkspaceCleanupSnapshotPruneBatch() startWorkspaceCleanupBackgroundRemoval({ candidates: removableCandidates, removeCandidates, @@ -215,9 +210,9 @@ export function useWorkspaceCleanupRemoval({ }) }, onResult: (result) => { - const nextFailures: Record = {} + const nextFailures: Record = {} for (const failure of result.failures) { - nextFailures[getWorkspaceCleanupFailureIdentity(failure)] = failure.message + nextFailures[getWorkspaceCleanupFailureIdentity(failure)] = failure // Why: defensively covers failures that never reached onRowFailed. clearQueuedDeleteState(failure.worktreeId, failure.executionHostId) } @@ -243,7 +238,7 @@ export function useWorkspaceCleanupRemoval({ delete next[identity] } for (const failure of result.failures) { - next[getWorkspaceCleanupFailureIdentity(failure)] = failure.message + next[getWorkspaceCleanupFailureIdentity(failure)] = failure } return next }) @@ -277,6 +272,7 @@ export function useWorkspaceCleanupRemoval({ resetForReopen, openConfirmRemove, confirmRemove, + confirmUnverifiedRemoval, cancelConfirmRemove, backToList } diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-scan-lifecycle.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-scan-lifecycle.ts index 8537f2136ff..0027183a077 100644 --- a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-scan-lifecycle.ts +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-scan-lifecycle.ts @@ -56,19 +56,13 @@ export function useWorkspaceCleanupScanLifecycle({ return } latestReadyToastScanAtRef.current = result.scannedAt - const suggestedCount = result.candidates.filter( - (candidate) => candidate.selectedByDefault - ).length toast.success( translate( 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0e2d235c63', 'Workspace scan ready' ), { - description: formatWorkspaceCleanupReadyToast( - result.candidates.length, - suggestedCount - ), + description: formatWorkspaceCleanupReadyToast(result.candidates.length), action: { label: translate( 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4a35c08764', diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-unverified-removal.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-unverified-removal.ts new file mode 100644 index 00000000000..47063a0ab11 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-unverified-removal.ts @@ -0,0 +1,153 @@ +import { useCallback, type Dispatch, type SetStateAction } from 'react' +import { useMountedRef } from '@/hooks/useMountedRef' +import { useAppStore } from '@/store' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { + getWorkspaceCleanupCandidateIdentity, + getWorkspaceCleanupHostIdentity, + resolveWorkspaceCleanupRemovalHostId +} from '../../../../shared/workspace-cleanup-host-identity' +import type { + WorkspaceCleanupFailure, + WorkspaceCleanupRemoveResult +} from '@/store/slices/workspace-cleanup' +import { startWorkspaceCleanupBackgroundRemoval } from './workspace-cleanup-background-removal' +import type { WorkspaceCleanupDeletionPhase } from './workspace-cleanup-candidate-row' +import { createWorkspaceCleanupSnapshotPruneBatch } from './workspace-cleanup-snapshot-prune-batch' + +type UnverifiedRemovalArgs = { + setRowFailures: Dispatch>> + setDeletionPhaseByIdentity: Dispatch< + SetStateAction> + > + clearQueuedDeleteState: ( + worktreeId: string, + executionHostId?: WorkspaceCleanupFailure['executionHostId'] + ) => void + onDeselect: (removedIdentities: readonly string[]) => void +} + +export function useWorkspaceCleanupUnverifiedRemoval({ + setRowFailures, + setDeletionPhaseByIdentity, + clearQueuedDeleteState, + onDeselect +}: UnverifiedRemovalArgs): (candidate: WorkspaceCleanupCandidate) => void { + const beginConsent = useAppStore((state) => state.beginUnverifiedRemovalConsent) + const removeCandidates = useAppStore((state) => state.removeWorkspaceCleanupCandidates) + const markQueued = useAppStore((state) => state.markWorktreesQueuedForDeletion) + const mountedRef = useMountedRef() + + return useCallback( + (candidate) => { + const identity = getWorkspaceCleanupCandidateIdentity(candidate) + const attemptId = beginConsent(identity) + if (!attemptId) { + return + } + const hostId = resolveWorkspaceCleanupRemovalHostId(candidate) + setRowFailures((current) => withoutIdentity(current, identity)) + setDeletionPhaseByIdentity((current) => ({ ...current, [identity]: 'queued' })) + markQueued([hostId ? { id: candidate.worktreeId, hostId } : candidate.worktreeId]) + startWorkspaceCleanupBackgroundRemoval({ + candidates: [candidate], + removeCandidates, + snapshotPruneBatch: createWorkspaceCleanupSnapshotPruneBatch(), + getRemoveOptions: () => ({ + unverifiedRemovalConsent: { identity, attemptId } + }), + onProgress: () => { + if (mountedRef.current) { + setDeletionPhaseByIdentity((current) => ({ ...current, [identity]: 'deleting' })) + } + }, + onRowFailed: (failure) => { + clearQueuedDeleteState(failure.worktreeId, failure.executionHostId) + // A preflight/timeout failure can arrive before the background + // batch settles; drop the optimistic deleting overlay immediately. + if (mountedRef.current) { + setDeletionPhaseByIdentity((current) => withoutIdentity(current, identity)) + } + }, + onResult: (result) => { + clearQueuedDeleteState(candidate.worktreeId, hostId ?? undefined) + if (mountedRef.current) { + applyResult(result, identity, setRowFailures, setDeletionPhaseByIdentity, onDeselect) + } + }, + onLateResult: (result) => { + if (mountedRef.current) { + setDeletionPhaseByIdentity((current) => withoutIdentity(current, identity)) + applyLateResult(result, setRowFailures, onDeselect) + } + }, + onError: () => { + clearQueuedDeleteState(candidate.worktreeId, hostId ?? undefined) + if (mountedRef.current) { + setDeletionPhaseByIdentity((current) => withoutIdentity(current, identity)) + } + } + }) + }, + [ + beginConsent, + clearQueuedDeleteState, + markQueued, + mountedRef, + onDeselect, + removeCandidates, + setDeletionPhaseByIdentity, + setRowFailures + ] + ) +} + +function applyResult( + result: WorkspaceCleanupRemoveResult, + identity: string, + setFailures: UnverifiedRemovalArgs['setRowFailures'], + setPhases: UnverifiedRemovalArgs['setDeletionPhaseByIdentity'], + onDeselect: UnverifiedRemovalArgs['onDeselect'] +): void { + setPhases((current) => withoutIdentity(current, identity)) + setFailures((current) => addFailures(withoutIdentity(current, identity), result.failures)) + onDeselect(result.removedIdentities) +} + +function applyLateResult( + result: WorkspaceCleanupRemoveResult, + setFailures: UnverifiedRemovalArgs['setRowFailures'], + onDeselect: UnverifiedRemovalArgs['onDeselect'] +): void { + setFailures((current) => { + let next = current + for (const identity of result.removedIdentities) { + next = withoutIdentity(next, identity) + } + return addFailures(next, result.failures) + }) + onDeselect(result.removedIdentities) +} + +function addFailures( + current: Record, + failures: readonly WorkspaceCleanupFailure[] +): Record { + const next = { ...current } + for (const failure of failures) { + next[getWorkspaceCleanupFailureIdentity(failure)] = failure + } + return next +} + +function withoutIdentity(current: Record, identity: string): Record { + const next = { ...current } + delete next[identity] + return next +} + +function getWorkspaceCleanupFailureIdentity(failure: WorkspaceCleanupFailure): string { + return failure.executionHostId + ? getWorkspaceCleanupHostIdentity(failure.executionHostId, failure.worktreeId) + : getWorkspaceCleanupCandidateIdentity({ worktreeId: failure.worktreeId }) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.test.ts index 1c45ddbc7ad..107981b5ef3 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.test.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { createDefaultWorkspaceCleanupFilterState } from '../../../../shared/workspace-cleanup-filter-model' +import { normalizeWorkspaceCleanupBrowseState } from '../../../../shared/workspace-cleanup-browse-state' import { hasActiveWorkspaceCleanupFilters, listActiveWorkspaceCleanupFacetGroups @@ -35,4 +36,14 @@ describe('listActiveWorkspaceCleanupFacetGroups', () => { expect(listActiveWorkspaceCleanupFacetGroups(filters)).toEqual([]) expect(hasActiveWorkspaceCleanupFilters(filters)).toBe(true) }) + + it('drops retired verdict filters and recovers safety as inactive', () => { + const browse = normalizeWorkspaceCleanupBrowseState({ + filters: { safety: { tiers: ['ready'], selectableOnly: true } }, + sort: { field: 'tier', direction: 'desc' } + }) + + expect(listActiveWorkspaceCleanupFacetGroups(browse.filters)).not.toContain('safety') + expect(browse.sort).toEqual({ field: 'last-activity', direction: 'desc' }) + }) }) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-applied-filter-chips.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-applied-filter-chips.tsx new file mode 100644 index 00000000000..f6d75549b34 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-applied-filter-chips.tsx @@ -0,0 +1,179 @@ +import React from 'react' +import { X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import { toMegabytes } from './workspace-cleanup-facet-panel-model' +import type { WorkspaceCleanupAppliedFilter } from '../../../../shared/workspace-cleanup-applied-filters' +import { listAppliedWorkspaceCleanupFilters } from '../../../../shared/workspace-cleanup-applied-filters' +import type { WorkspaceCleanupFilterState } from '../../../../shared/workspace-cleanup-filter-model' + +/** Why in the bar, not the panel: a filter narrowing the list must be visible without opening anything. */ +// Why not memoized: the labels are translated at build time, so caching them on +// `filters` alone reuses the previous language's strings after a language change. +// Derivation is constant-size (one pass over the filter fields, not per row). +export function useAppliedWorkspaceCleanupFilters( + filters: WorkspaceCleanupFilterState +): WorkspaceCleanupAppliedFilter[] { + return listAppliedWorkspaceCleanupFilters(filters, buildAppliedFilterFormatters()) +} + +export function WorkspaceCleanupAppliedFilterChips({ + applied, + onClear +}: { + applied: readonly WorkspaceCleanupAppliedFilter[] + onClear: (filter: WorkspaceCleanupAppliedFilter) => void +}): React.JSX.Element | null { + if (applied.length === 0) { + return null + } + return ( +
+ {applied.map((filter) => ( + + {filter.label} + + + ))} +
+ ) +} + +function buildAppliedFilterFormatters(): Parameters[1] { + const mb = (bytes: number): number => toMegabytes(bytes) ?? 0 + return { + idleDays: (days) => + translate('components.workspace.cleanup.browse.chip.idleDays', 'Idle {{value0}}d+', { + value0: days + }), + neverVisited: () => + translate('components.workspace.cleanup.browse.chip.neverVisited', 'Never visited'), + minSize: (bytes) => + translate('components.workspace.cleanup.browse.chip.minSize', 'At least {{value0}} MB', { + value0: mb(bytes) + }), + maxSize: (bytes) => + translate('components.workspace.cleanup.browse.chip.maxSize', 'At most {{value0}} MB', { + value0: mb(bytes) + }), + excludesUnsized: () => + translate('components.workspace.cleanup.browse.chip.excludesUnsized', 'Measured only'), + excludesStatusless: () => + translate('components.workspace.cleanup.browse.chip.excludesStatusless', 'Has a status'), + list: (kind, count) => + translate('components.workspace.cleanup.browse.chip.list', '{{value0}}: {{value1}}', { + value0: getChipKindLabel(kind), + value1: count + }), + triState: (kind, mode) => + translate('components.workspace.cleanup.browse.chip.triState', '{{value0}}: {{value1}}', { + value0: getChipKindLabel(kind), + value1: + mode === 'only' + ? translate('components.workspace.cleanup.browse.chip.only', 'Only') + : translate('components.workspace.cleanup.browse.chip.exclude', 'Excluded') + }), + minAhead: (count) => + translate('components.workspace.cleanup.browse.chip.minAhead', 'Ahead {{value0}}+', { + value0: count + }), + minBehind: (count) => + translate('components.workspace.cleanup.browse.chip.minBehind', 'Behind {{value0}}+', { + value0: count + }), + branchQuery: (value) => + translate('components.workspace.cleanup.browse.chip.branchQuery', 'Branch: {{value0}}', { + value0: value + }), + pathPrefix: (value) => + translate('components.workspace.cleanup.browse.chip.pathPrefix', 'Path: {{value0}}', { + value0: value + }), + presence: (kind, mode) => + translate('components.workspace.cleanup.browse.chip.presence', '{{value0}}: {{value1}}', { + value0: getChipKindLabel(kind), + value1: + mode === 'some' + ? translate('components.workspace.cleanup.browse.chip.has', 'Has') + : translate('components.workspace.cleanup.browse.chip.none', 'None') + }), + completelyEmpty: () => + translate('components.workspace.cleanup.browse.chip.completelyEmpty', 'Nothing to lose') + } +} + +function getChipKindLabel(kind: string): string { + switch (kind) { + case 'status': + return translate('components.workspace.cleanup.browse.chip.kind.status', 'Status') + case 'agent': + return translate('components.workspace.cleanup.browse.chip.kind.agent', 'Agent') + case 'git': + return translate('components.workspace.cleanup.browse.chip.kind.git', 'Git') + case 'review': + return translate('components.workspace.cleanup.browse.chip.kind.review', 'Review') + case 'reviewState': + return translate('components.workspace.cleanup.browse.chip.kind.reviewState', 'Review state') + case 'reviewProvider': + return translate('components.workspace.cleanup.browse.chip.kind.reviewProvider', 'Provider') + case 'ticket': + return translate('components.workspace.cleanup.browse.chip.kind.ticket', 'Ticket') + case 'ticketSource': + return translate( + 'components.workspace.cleanup.browse.chip.kind.ticketSource', + 'Ticket source' + ) + case 'context': + return translate('components.workspace.cleanup.browse.chip.kind.context', 'Context') + case 'host': + return translate('components.workspace.cleanup.browse.chip.kind.host', 'Host') + case 'repo': + return translate('components.workspace.cleanup.browse.chip.kind.repo', 'Repo') + case 'blocker': + return translate('components.workspace.cleanup.browse.chip.kind.blocker', 'Blocker') + case 'dismissed': + return translate('components.workspace.cleanup.browse.chip.kind.dismissed', 'Ignored') + case 'archived': + return translate('components.workspace.cleanup.browse.chip.kind.archived', 'Archived') + case 'pinned': + return translate('components.workspace.cleanup.browse.chip.kind.pinned', 'Pinned') + case 'unread': + return translate('components.workspace.cleanup.browse.chip.kind.unread', 'Unread') + case 'comment': + return translate('components.workspace.cleanup.browse.chip.kind.comment', 'Comment') + case 'prunable': + return translate('components.workspace.cleanup.browse.chip.kind.prunable', 'Prunable') + case 'locked': + return translate('components.workspace.cleanup.browse.chip.kind.locked', 'Locked') + case 'retainedAgents': + return translate( + 'components.workspace.cleanup.browse.chip.kind.retainedAgents', + 'Finished agents' + ) + default: + return kind + } +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts index 0702e40a10f..1fd04777c39 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts @@ -56,6 +56,7 @@ export type WorkspaceCleanupBackgroundRemovalArgs = { begin: () => Promise finish: () => Promise } + getRemoveOptions?: (candidate: WorkspaceCleanupCandidate) => WorkspaceCleanupRemoveOptions } export function startWorkspaceCleanupBackgroundRemoval({ @@ -68,7 +69,8 @@ export function startWorkspaceCleanupBackgroundRemoval({ onRowFailed, removalTimeoutMs = DEFAULT_WORKSPACE_CLEANUP_REMOVAL_TIMEOUT_MS, removalSettlementGraceMs = DEFAULT_WORKSPACE_CLEANUP_SETTLEMENT_GRACE_MS, - snapshotPruneBatch + snapshotPruneBatch, + getRemoveOptions }: WorkspaceCleanupBackgroundRemovalArgs): void { if (candidates.length === 0) { try { @@ -79,7 +81,6 @@ export function startWorkspaceCleanupBackgroundRemoval({ return } - const count = candidates.length const removedIds: string[] = [] const removedIdentities: string[] = [] const failures: WorkspaceCleanupFailure[] = [] @@ -95,7 +96,7 @@ export function startWorkspaceCleanupBackgroundRemoval({ const emitProgress = (): void => { onProgress({ - totalCount: count, + totalCount: candidates.length, processedCount, removedCount: removedIds.length, failedCount: failures.length @@ -199,6 +200,7 @@ export function startWorkspaceCleanupBackgroundRemoval({ const outcome = await waitForWorkspaceCleanupRemovalWithTimeout( removeCandidates([candidate.worktreeId], { approvedCandidates: [candidate], + ...getRemoveOptions?.(candidate), ...(snapshotPruneBatchActive && snapshotPruneBatch ? { snapshotPruneBatchId: snapshotPruneBatch.batchId } : {}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-browse-toolbar.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-browse-toolbar.tsx index c70209dd0eb..d53b3215c2c 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-browse-toolbar.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-browse-toolbar.tsx @@ -71,6 +71,7 @@ export function WorkspaceCleanupBrowseToolbar({ gitEvidence={{ pendingCount: gitPendingCount, totalCount: gitCheckedTotal }} onQueryChange={(query) => browse.patchFilters('query', query)} onClearFilters={browse.clearFilters} + onClearAppliedFilter={(filter) => browse.replaceFilters(filter.clear)} /> { it('shows facts instead of cleanup policy tiers', () => { - expect(getCandidateFactStatus(makeCandidate({ tier: 'ready' }))).toBeNull() - expect(getCandidateFactStatus(makeCandidate({ tier: 'review' }))).toBeNull() - expect( - getCandidateFactStatus(makeCandidate({ tier: 'ready', reasons: ['archived'] })) - ).toMatchObject({ label: 'Archived' }) + expect(getCandidateFactStatuses(makeCandidate({ tier: 'ready' }))).toEqual([]) + expect(getCandidateFactStatuses(makeCandidate({ tier: 'review' }))).toEqual([]) + expect(getCandidateFactStatuses(makeCandidate({ reasons: ['archived'] }))).toContainEqual( + expect.objectContaining({ label: 'Archived' }) + ) }) it('does not duplicate git status blockers as a separate git icon label', () => { const gitStatusError = makeCandidate({ blockers: ['git-status-error'], - git: { clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null } + git: { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null + } }) const unknownBase = makeCandidate({ blockers: ['unknown-base'], - git: { clean: true, upstreamAhead: null, upstreamBehind: null, checkedAt: 1 } + git: { + clean: true, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: 1 + } }) - expect(getDirtyGitLabel(gitStatusError)).toBeNull() + expect(getDirtyGitLabel(gitStatusError)).toBe('Git status check failed') expect(shouldShowGitMetadataChip(gitStatusError)).toBe(false) - expect(getDirtyGitLabel(unknownBase)).toBeNull() + expect(getDirtyGitLabel(unknownBase)).toBe('Git status could not be verified') expect(shouldShowGitMetadataChip(unknownBase)).toBe(false) }) @@ -35,7 +45,12 @@ describe('workspace cleanup candidate row data', () => { expect( shouldShowGitMetadataChip( makeCandidate({ - git: { clean: true, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 1 } + git: { + clean: true, + upstreamAhead: 0, + upstreamBehind: 0, + checkedAt: 1 + } }) ) ).toBe(true) @@ -43,14 +58,31 @@ describe('workspace cleanup candidate row data', () => { it('keeps unpushed risk visible for archived rows', () => { expect( - getCandidateFactStatus( + getCandidateFactStatuses( makeCandidate({ tier: 'review', reasons: ['archived'], - git: { clean: true, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 1 } + git: { + clean: true, + upstreamAhead: 2, + upstreamBehind: 0, + checkedAt: 1 + } }) ) - ).toMatchObject({ label: 'Unpushed commits' }) + ).toContainEqual(expect.objectContaining({ label: 'Unpushed commits' })) + }) + + it('renders every blocker fact instead of only the first', () => { + expect( + getCandidateFactStatuses(makeCandidate({ blockers: ['pinned', 'git-status-error'] })) + ).toEqual([ + expect.objectContaining({ label: 'Pinned' }), + expect.objectContaining({ + label: 'Git status unavailable', + tone: 'destructive' + }) + ]) }) it('suppresses the git metadata chip when the status pill already names git risk', () => { @@ -58,7 +90,12 @@ describe('workspace cleanup candidate row data', () => { shouldShowGitMetadataChip( makeCandidate({ blockers: ['unpushed-commits'], - git: { clean: true, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 1 } + git: { + clean: true, + upstreamAhead: 2, + upstreamBehind: 0, + checkedAt: 1 + } }) ) ).toBe(false) @@ -67,7 +104,12 @@ describe('workspace cleanup candidate row data', () => { makeCandidate({ tier: 'review', blockers: [], - git: { clean: true, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 1 } + git: { + clean: true, + upstreamAhead: 2, + upstreamBehind: 0, + checkedAt: 1 + } }) ) ).toBe(false) @@ -75,7 +117,12 @@ describe('workspace cleanup candidate row data', () => { shouldShowGitMetadataChip( makeCandidate({ blockers: ['dirty-files'], - git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 1 } + git: { + clean: false, + upstreamAhead: 0, + upstreamBehind: 0, + checkedAt: 1 + } }) ) ).toBe(false) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts index 42a7d0c58a5..75103a98d0c 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts @@ -2,12 +2,10 @@ import { translate } from '@/i18n/i18n' import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' import { getWorkspaceCleanupGitLabel, - hasWorkspaceCleanupLocalContext, type WorkspaceCleanupReviewInfo } from './workspace-cleanup-presentation' import { formatUnpushedCommitCount, - formatWorkspaceCleanupContextCount, formatWorkspaceCleanupContextDetail, formatWorkspaceCleanupGitStatusLabel, getGitStatusUnknownLabel, @@ -23,50 +21,56 @@ export function getWorkspaceCleanupBlockerLabels(candidate: WorkspaceCleanupCand return candidate.blockers.map((blocker) => getWorkspaceCleanupBlockerLabel(blocker)) } -export function getCandidateFactStatus(candidate: WorkspaceCleanupCandidate): { +export function getCandidateFactStatuses(candidate: WorkspaceCleanupCandidate): { label: string tone: StatusPillTone -} | null { - if (candidate.blockers.includes('dismissed')) { - return { - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e8b3741ff7', - 'Ignored' - ), - tone: 'neutral' - } - } +}[] { if (candidate.blockers.length > 0) { - return { label: getWorkspaceCleanupBlockerLabel(candidate.blockers[0]), tone: 'neutral' } + return candidate.blockers.map((blocker) => ({ + label: + blocker === 'dismissed' + ? translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e8b3741ff7', + 'Ignored' + ) + : getWorkspaceCleanupBlockerLabel(blocker), + tone: blocker === 'git-status-error' || blocker === 'unknown-base' ? 'destructive' : 'neutral' + })) } if (candidate.git.upstreamAhead && candidate.git.upstreamAhead > 0) { - return { - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.9623a5107d', - 'Unpushed commits' - ), - tone: 'review' - } + return [ + { + label: translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.9623a5107d', + 'Unpushed commits' + ), + tone: 'review' + } + ] } if (candidate.git.clean === false) { - return { - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e97e4580c7', - 'Dirty' - ), - tone: 'review' - } + return [ + { + label: translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e97e4580c7', + 'Dirty' + ), + tone: 'review' + } + ] } if (candidate.reasons.includes('archived')) { - return { - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.archivedStatus', - 'Archived' - ), - tone: 'neutral' - } + return [ + { + label: translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.archivedStatus', + 'Archived' + ), + tone: 'neutral' + } + ] } - return null + return [] } export function formatGitStatus(candidate: WorkspaceCleanupCandidate): string { @@ -85,7 +89,7 @@ export function formatBranchSafetyDetails(candidate: WorkspaceCleanupCandidate): return details } -export function formatContextDetails(candidate: WorkspaceCleanupCandidate): string | null { +export function formatContextDetailLabels(candidate: WorkspaceCleanupCandidate): string[] { const parts: string[] = [] if (candidate.localContext.terminalTabCount > 0) { parts.push( @@ -110,15 +114,26 @@ export function formatContextDetails(candidate: WorkspaceCleanupCandidate): stri formatWorkspaceCleanupContextDetail('agent', candidate.localContext.retainedDoneAgentCount) ) } + return parts +} + +export function formatContextDetails(candidate: WorkspaceCleanupCandidate): string | null { + const parts = formatContextDetailLabels(candidate) return parts.length > 0 ? parts.join(', ') : null } export function getDirtyGitLabel(candidate: WorkspaceCleanupCandidate): string | null { - if ( - candidate.blockers.includes('unknown-base') || - candidate.blockers.includes('git-status-error') - ) { - return null + if (candidate.blockers.includes('git-status-error')) { + return translate( + 'components.workspace.cleanup.browse.gitStatusCheckFailed', + 'Git status check failed' + ) + } + if (candidate.blockers.includes('unknown-base')) { + return translate( + 'components.workspace.cleanup.browse.gitStatusUnverified', + 'Git status could not be verified' + ) } if (candidate.blockers.includes('unpushed-commits')) { if (candidate.git.upstreamAhead && candidate.git.upstreamAhead > 0) { @@ -139,21 +154,19 @@ export function getDirtyGitLabel(candidate: WorkspaceCleanupCandidate): string | } export function shouldShowGitMetadataChip(candidate: WorkspaceCleanupCandidate): boolean { - return ( - !candidate.blockers.includes('unknown-base') && - !candidate.blockers.includes('git-status-error') && - !hasGitStatusPill(candidate) - ) + return !hasGitStatusPill(candidate) } function hasGitStatusPill(candidate: WorkspaceCleanupCandidate): boolean { if ( candidate.blockers.includes('dirty-files') || - candidate.blockers.includes('unpushed-commits') + candidate.blockers.includes('unpushed-commits') || + candidate.blockers.includes('unknown-base') || + candidate.blockers.includes('git-status-error') ) { return true } - if (candidate.blockers.length > 0 || candidate.tier === 'ready') { + if (candidate.blockers.length > 0) { return false } return (candidate.git.upstreamAhead ?? 0) > 0 || candidate.git.clean === false @@ -166,13 +179,6 @@ export function getReviewPillTone(reviewInfo: WorkspaceCleanupReviewInfo): Statu return 'neutral' } -export function getContextPillLabel(candidate: WorkspaceCleanupCandidate): string | null { - if (!hasWorkspaceCleanupLocalContext(candidate)) { - return null - } - return formatWorkspaceCleanupContextCount(getContextCount(candidate)) -} - export function getContextCount(candidate: WorkspaceCleanupCandidate): number { return ( candidate.localContext.terminalTabCount + diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx index d5a7416914e..bec347d570d 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx @@ -33,7 +33,7 @@ import { formatBranchSafetyDetails, formatContextDetails, formatGitStatus, - getCandidateFactStatus, + getCandidateFactStatuses, getContextCount, getDirtyGitLabel, getReviewPillTone, @@ -42,10 +42,13 @@ import { } from './workspace-cleanup-candidate-row-data' import { StatusPill } from './workspace-cleanup-status-pill' import { WorkspaceCleanupMetadataChip } from './workspace-cleanup-metadata-chip' +import { WorkspaceCleanupForgetLocallyButton } from './workspace-cleanup-forget-locally-button' import { getWorkspaceCleanupCandidateAccessibleName, getWorkspaceCleanupCandidateHostLabel } from './workspace-cleanup-host-label' +import { formatCompactActivityLabel, getReviewTooltip } from './workspace-cleanup-row-labels' +import type { WorkspaceCleanupFailure } from '@/store/slices/workspace-cleanup' export type WorkspaceCleanupDeletionPhase = 'deleting' | 'queued' @@ -55,7 +58,7 @@ type CandidateRowProps = { identity: string deletionPhase?: WorkspaceCleanupDeletionPhase expanded: boolean - failure?: string + failure?: WorkspaceCleanupFailure /** A focused git re-scan is in flight, so "Not checked" is provisional. */ gitEvidencePending?: boolean last: boolean @@ -69,6 +72,8 @@ type CandidateRowProps = { selected: boolean onIgnore: (candidate: WorkspaceCleanupCandidate) => void onRemove: (candidate: WorkspaceCleanupCandidate) => void + onForgetLocally?: (candidate: WorkspaceCleanupCandidate) => void + onDeleteAnyway?: (candidate: WorkspaceCleanupCandidate) => void onToggleExpanded: (identity: string) => void onToggleSelected: (identity: string) => void onView: (candidate: WorkspaceCleanupCandidate) => void @@ -95,6 +100,8 @@ export const CandidateRow = React.memo(function CandidateRow({ selected, onIgnore, onRemove, + onForgetLocally, + onDeleteAnyway, onToggleExpanded, onToggleSelected, onView @@ -107,7 +114,7 @@ export const CandidateRow = React.memo(function CandidateRow({ const blockers = getWorkspaceCleanupBlockerLabels(candidate) const contextDetails = formatContextDetails(candidate) const branchSafetyDetails = formatBranchSafetyDetails(candidate) - const factStatus = getCandidateFactStatus(candidate) + const factStatuses = getCandidateFactStatuses(candidate) const dirtyLabel = getDirtyGitLabel(candidate) const gitLabel = getWorkspaceCleanupGitLabel(candidate) const showGitMetadataChip = shouldShowGitMetadataChip(candidate) @@ -178,9 +185,13 @@ export const CandidateRow = React.memo(function CandidateRow({ 'Deleting…' )} - ) : factStatus ? ( - {factStatus.label} - ) : null} + ) : ( + factStatuses.map((status) => ( + + {status.label} + + )) + )} {workspaceStatusLabel ? ( - {failure} + {failure.message} + {failure.canDeleteAnyway && onDeleteAnyway ? ( + + ) : null} ) : null} @@ -377,26 +398,11 @@ export const CandidateRow = React.memo(function CandidateRow({ ) : null} + {candidate.blockers.includes('ssh-disconnected') && onForgetLocally ? ( + + ) : null} ) }) - -function formatCompactActivityLabel(label: string): string { - if (label === 'Just now') { - return 'now' - } - return label.replace(/ ago$/, '') -} - -function getReviewTooltip(reviewInfo: WorkspaceCleanupReviewInfo): string { - const parts = [reviewInfo.label] - if (reviewInfo.state) { - parts.push(reviewInfo.state) - } - if (reviewInfo.title) { - parts.push(reviewInfo.title) - } - return parts.filter(Boolean).join(' · ') -} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx index 0a7014cf1b2..dbffc7214e5 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx @@ -84,6 +84,49 @@ describe('WorkspaceCleanupConfirmRemove', () => { expect(container.querySelectorAll('span.truncate.text-sm.font-medium')).toHaveLength(5) }) + it('names each local context type separately instead of totalling them', () => { + const candidate = makeFacetCandidate({ + worktreeId: 'repo-1::/repo/with-context', + displayName: 'Has context', + localContext: { + terminalTabCount: 1, + cleanEditorTabCount: 2, + browserTabCount: 3, + diffCommentCount: 4, + newestDiffCommentAt: null, + retainedDoneAgentCount: 5 + } + }) + + act(() => { + root.render( + + ) + }) + + const labels = [ + 'Terminal tabs: 1', + 'Editor tabs: 2', + 'Browser tabs: 3', + 'Diff notes: 4', + 'Completed agents: 5' + ] + const renderedLabels = Array.from(container.querySelectorAll('span')) + .map((element) => element.textContent) + .filter((label): label is string => labels.includes(label ?? '')) + + expect(renderedLabels).toEqual(labels) + expect(container.textContent).not.toContain('Context: ') + }) + it('visibly and accessibly identifies each host for colliding confirmations', () => { const local = makeFacetCandidate({ executionHostId: 'local' }) const remote = makeFacetCandidate({ @@ -115,4 +158,64 @@ describe('WorkspaceCleanupConfirmRemove', () => { expect(container.querySelector(`[aria-label="Host: ${hostLabel}"]`)).not.toBeNull() } }) + + it('shows every destructive git risk and the fleet-level force projection', () => { + const failed = makeFacetCandidate({ + worktreeId: 'repo-1::/failed', + blockers: ['pinned', 'git-status-error'], + git: { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null + } + }) + const unknownBase = makeFacetCandidate({ + worktreeId: 'repo-1::/unknown-base', + blockers: ['unknown-base'] + }) + + act(() => { + root.render( + + ) + }) + + expect(container.textContent).toContain('Pinned') + expect(container.textContent).toContain('Git status unavailable') + expect(container.textContent).toContain('Could not verify unpushed commits') + expect(container.textContent).toContain( + '2 workspaces currently show risk and may need a force delete' + ) + expect( + [...container.querySelectorAll('.text-destructive')].map((node) => node.textContent) + ).toEqual( + expect.arrayContaining(['Git status unavailable', 'Could not verify unpushed commits']) + ) + + act(() => { + root.render( + + ) + }) + expect(container.textContent).toContain( + '1 workspace currently shows risk and may need a force delete' + ) + }) }) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx index ee068fe3f21..c7ccae4a8fe 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx @@ -6,11 +6,14 @@ import { Progress } from '@/components/ui/progress' import { ScrollArea } from '@/components/ui/scroll-area' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' -import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { + shouldForceWorkspaceCleanupRemoval, + type WorkspaceCleanupCandidate +} from '../../../../shared/workspace-cleanup' import type { WorkspaceCleanupRemovalProgress } from './workspace-cleanup-background-removal' import { - getCandidateFactStatus, - getContextPillLabel, + getCandidateFactStatuses, + formatContextDetailLabels, getDirtyGitLabel, getReviewPillTone, shouldShowGitMetadataChip @@ -60,6 +63,7 @@ export function WorkspaceCleanupConfirmRemove({ }): React.JSX.Element { const [scrollElement, setScrollElement] = React.useState(null) const count = candidates.length + const riskCount = candidates.filter(shouldForceWorkspaceCleanupRemoval).length const deleting = progress !== null const progressValue = progress ? Math.min(100, Math.max(0, (progress.processedCount / progress.totalCount) * 100)) @@ -136,6 +140,21 @@ export function WorkspaceCleanupConfirmRemove({ { value0: count } )} + {riskCount > 0 ? ( +
+ {riskCount === 1 + ? translate( + 'components.workspace.cleanup.browse.forceDeleteProjectionOne', + '{{count}} workspace currently shows risk and may need a force delete', + { count: riskCount } + ) + : translate( + 'components.workspace.cleanup.browse.forceDeleteProjectionMany', + '{{count}} workspaces currently show risk and may need a force delete', + { count: riskCount } + )} +
+ ) : null} - {factStatus ? {factStatus.label} : null} + {factStatuses.map((status) => ( + + {status.label} + + ))} {reviewInfo.label ? ( {reviewInfo.label} ) : null} - {contextPillLabel ? {contextPillLabel} : null} + {contextPillLabels.map((label) => ( + {label} + ))} {dirtyLabel && showGitMetadataChip ? ( {dirtyLabel} ) : null} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.test.tsx index 10049ceeb4e..5c36b15a054 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.test.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.test.tsx @@ -108,11 +108,34 @@ describe('workspace cleanup facet controls', () => { renderFacets(createDefaultWorkspaceCleanupFilterState(), onPatch) const input = control('Idle for at least') as HTMLInputElement | null - expect(input?.type).toBe('number') act(() => typeInto(input, '17')) expect(onPatch).toHaveBeenCalledWith('activity', { idleMinDays: 17 }) }) + it('keeps every numeric threshold off type=number so a wheel tick cannot set it', () => { + // Why assert the type rather than dispatch a wheel event: the spinner is a + // Chromium behaviour happy-dom does not implement, so a dispatched wheel + // changes nothing here either way and the test would pass without the fix. + // The type is the property that makes the whole class impossible. + renderFacets(createDefaultWorkspaceCleanupFilterState(), createPatchMock()) + + const numericFacets = [ + 'Idle for at least', + 'At least', + 'At most', + 'Commits ahead ≥', + 'Commits behind ≥' + ] + const found = numericFacets + .map((label) => control(label) as HTMLInputElement | null) + .filter((input): input is HTMLInputElement => input !== null) + expect(found).toHaveLength(numericFacets.length) + for (const input of found) { + expect(input.type).toBe('text') + expect(input.inputMode).toBe('numeric') + } + }) + it('multi-selects git states', () => { const onPatch = createPatchMock() const filters = createDefaultWorkspaceCleanupFilterState() diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.tsx index 7dc1c0d92e7..481e4ecd6b8 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.tsx @@ -139,10 +139,13 @@ export function FacetNumberField({ return (
+ {/* Why text, not number: Chromium mutates a focused number input on every + wheel tick, which silently walked a threshold up inside the scrollable + facet panel. preventDefault would stop that but also stop the panel + scrolling, re-breaking #14629. */} void): WorkspaceCleanupFilterState { const state = createDefaultWorkspaceCleanupFilterState() @@ -100,7 +101,10 @@ describe('facet building', () => { it('keeps same-id worktrees and sizes isolated by host', () => { const candidates = [ makeFacetCandidate({ executionHostId: 'local' }), - makeFacetCandidate({ executionHostId: 'ssh:builder', connectionId: 'builder' }) + makeFacetCandidate({ + executionHostId: 'ssh:builder', + connectionId: 'builder' + }) ] const sizes = buildWorkspaceCleanupSizeIndex( [ @@ -130,7 +134,10 @@ describe('facet building', () => { it('does not apply a legacy size to duplicate candidate ids', () => { const candidates = [ makeFacetCandidate({ executionHostId: 'local' }), - makeFacetCandidate({ executionHostId: 'ssh:builder', connectionId: 'builder' }) + makeFacetCandidate({ + executionHostId: 'ssh:builder', + connectionId: 'builder' + }) ] const sizes = buildWorkspaceCleanupSizeIndex( [{ worktreeId: candidates[0]!.worktreeId, status: 'ok', sizeBytes: 10 }], @@ -213,7 +220,9 @@ describe('activity filter', () => { }) it('separates background activity from a real visit', () => { - const busyButUnopened = makeFacets({ candidate: { lastActivityAt: FACET_NOW } }) + const busyButUnopened = makeFacets({ + candidate: { lastActivityAt: FACET_NOW } + }) const state = filters((s) => { s.safety.dismissed = 'any' s.activity.neverVisited = true @@ -333,7 +342,9 @@ describe('agent, git, review, ticket, context, and location filters', () => { it('filters by git state, ahead counts, branch text, prunable and locked', () => { const unpushed = makeFacets({ - candidate: { git: { clean: true, upstreamAhead: 3, upstreamBehind: 1, checkedAt: 1 } } + candidate: { + git: { clean: true, upstreamAhead: 3, upstreamBehind: 1, checkedAt: 1 } + } }) expect(unpushed.gitState).toBe('unpushed') const state = filters((s) => { @@ -366,7 +377,9 @@ describe('agent, git, review, ticket, context, and location filters', () => { }) it('keeps review filtering provider-general and treats draft as a state', () => { - const draftMr = makeFacets({ review: { provider: 'gitlab', state: 'draft', label: 'MR #7' } }) + const draftMr = makeFacets({ + review: { provider: 'gitlab', state: 'draft', label: 'MR #7' } + }) const state = filters((s) => { s.safety.dismissed = 'any' s.review.presence = 'some' @@ -402,7 +415,10 @@ describe('agent, git, review, ticket, context, and location filters', () => { it('filters by local context and completely-empty', () => { const busy = makeFacets({ candidate: { - localContext: { ...makeFacetCandidate().localContext, terminalTabCount: 2 } + localContext: { + ...makeFacetCandidate().localContext, + terminalTabCount: 2 + } } }) const hasContext = filters((s) => { @@ -473,23 +489,90 @@ describe('safety filter', () => { expect(matchesWorkspaceCleanupFilters(dismissed, only, FACET_NOW)).toBe(true) expect(matchesWorkspaceCleanupFilters(makeFacets(), only, FACET_NOW)).toBe(false) }) - - it('restricts to rows the policy would actually let the user delete', () => { - const blocked = makeFacets({ candidate: { blockers: ['dirty-files'], tier: 'review' } }) - const state = filters((s) => { - s.safety.dismissed = 'any' - s.safety.selectableOnly = true - }) - expect(matchesWorkspaceCleanupFilters(blocked, state, FACET_NOW)).toBe(false) - expect(matchesWorkspaceCleanupFilters(makeFacets(), state, FACET_NOW)).toBe(true) - }) }) describe('query pipeline', () => { + it('bulk-selects label rows while preserving explicit exclusions', () => { + const candidates = [ + makeFacetCandidate({ + worktreeId: 'repo-1::/dirty', + blockers: ['dirty-files'] + }), + makeFacetCandidate({ + worktreeId: 'repo-1::/pinned', + blockers: ['pinned'] + }), + makeFacetCandidate({ + worktreeId: 'repo-1::/unknown', + git: { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null + } + }), + makeFacetCandidate({ + worktreeId: 'repo-1::/active', + blockers: ['active-workspace'] + }), + makeFacetCandidate({ + worktreeId: 'repo-1::/agent', + blockers: ['live-agent'] + }), + makeFacetCandidate({ + worktreeId: 'repo-1::/ignored', + blockers: ['dismissed'] + }), + makeFacetCandidate({ + worktreeId: 'repo-1::/main', + blockers: ['main-worktree'] + }), + makeFacetCandidate({ + worktreeId: 'repo-1::/folder', + blockers: ['folder-repo'] + }), + makeFacetCandidate({ + worktreeId: 'repo-1::/remote', + blockers: ['ssh-disconnected'] + }) + ] + const result = queryWorkspaceCleanupCandidates( + candidates, + { + filters: createDefaultWorkspaceCleanupFilterState(), + sort: { field: 'name', direction: 'asc' } + }, + {}, + FACET_NOW + ) + const selectableIds = result.rows + .filter((row) => result.selectableIdentities.includes(row.identity)) + .map((row) => row.worktreeId) + + expect(selectableIds).toEqual( + expect.arrayContaining(['repo-1::/dirty', 'repo-1::/pinned', 'repo-1::/unknown']) + ) + expect(selectableIds).not.toEqual( + expect.arrayContaining([ + 'repo-1::/active', + 'repo-1::/agent', + 'repo-1::/ignored', + 'repo-1::/main', + 'repo-1::/folder', + 'repo-1::/remote' + ]) + ) + expect(canQueueWorkspaceCleanupCandidate(candidates[3])).toBe(true) + expect(canQueueWorkspaceCleanupCandidate(candidates[4])).toBe(true) + expect(canQueueWorkspaceCleanupCandidate(candidates[5])).toBe(true) + }) + it('reports counts and selectable ids', () => { const rows = [ makeNamedFacets('alpha'), - makeNamedFacets('beta', { candidate: { blockers: ['dirty-files'], tier: 'review' } }) + makeNamedFacets('beta', { + candidate: { blockers: ['dirty-files'], tier: 'review' } + }) ] expect(filterWorkspaceCleanupFacets(rows, ANY, FACET_NOW)).toHaveLength(2) const result = queryWorkspaceCleanupCandidates( diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-labels.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-labels.ts index 9ea2cd09adf..98f09a577d2 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-labels.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-labels.ts @@ -1,6 +1,5 @@ import { translate } from '@/i18n/i18n' import type { HostedReviewProvider } from '../../../../shared/hosted-review' -import type { WorkspaceCleanupTier } from '../../../../shared/workspace-cleanup' import type { WorkspaceCleanupAgentState, WorkspaceCleanupBlockerMode, @@ -49,8 +48,6 @@ export function getWorkspaceCleanupSortFieldLabel(field: WorkspaceCleanupSortFie return translate('components.workspace.cleanup.browse.sort.ticket', 'Ticket') case 'local-context': return translate('components.workspace.cleanup.browse.sort.localContext', 'Open context') - case 'tier': - return translate('components.workspace.cleanup.browse.sort.tier', 'Safety') case 'blocker-count': return translate('components.workspace.cleanup.browse.sort.blockerCount', 'Blockers') } @@ -124,17 +121,6 @@ export function getWorkspaceCleanupTicketSourceLabel(source: WorkspaceCleanupTic } } -export function getWorkspaceCleanupTierLabel(tier: WorkspaceCleanupTier): string { - switch (tier) { - case 'ready': - return translate('components.workspace.cleanup.browse.tier.ready', 'Ready') - case 'review': - return translate('components.workspace.cleanup.browse.tier.review', 'Needs review') - case 'protected': - return translate('components.workspace.cleanup.browse.tier.protected', 'Protected') - } -} - export function getWorkspaceCleanupIdleSignalLabel(signal: WorkspaceCleanupIdleSignal): string { switch (signal) { case 'last-visited': diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-matchers.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-matchers.ts index 988a31c1489..d617e7c5ecb 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-matchers.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-matchers.ts @@ -170,15 +170,9 @@ export function matchesWorkspaceCleanupSafety( facets: WorkspaceCleanupFacets, filter: WorkspaceCleanupSafetyFilter ): boolean { - if (filter.tiers.length > 0 && !filter.tiers.includes(facets.tier)) { - return false - } if (!matchesWorkspaceCleanupTriState(filter.dismissed, facets.isDismissed)) { return false } - if (filter.selectableOnly && !facets.isSelectable) { - return false - } if (filter.blockers.length === 0) { return true } diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-row-caches.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-row-caches.ts index 07f1c041ab9..2ac23e11ff8 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-row-caches.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-row-caches.ts @@ -17,6 +17,7 @@ import { getWorkspaceCleanupCandidateIdentity, getWorkspaceCleanupHostIdentity } from './workspace-cleanup-host-identity' +import { getWorktreeVisitTimestamp } from '@/lib/worktree-visit-recency' /** * Streaming ticks replace only the candidate objects they touched, so all @@ -105,7 +106,10 @@ export function computeWorkspaceCleanupFacetList(args: { const sizeBytes = sources.sizeBytesByWorktreeId.get(hostIdentity) ?? sources.sizeBytesByWorktreeId.get(candidate.worktreeId) - const lastVisitedAt = sources.lastVisitedAtByWorktreeId[candidate.worktreeId] + const lastVisitedAt = getWorktreeVisitTimestamp(sources.lastVisitedAtByWorktreeId, { + id: candidate.worktreeId, + hostId: getWorkspaceCleanupCandidateHostId(candidate) + }) const agentState = sources.liveAgentStatusByWorktreeId.get(candidate.worktreeId) const review = sources.reviewInfoByWorktreeId.get(hostIdentity) ?? diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.test.ts index 4da525a1352..fda63e1c0de 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.test.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.test.ts @@ -60,27 +60,41 @@ describe('ranked fields', () => { it('orders git states from safest to riskiest', () => { const rows = [ makeNamedFacets('unpushed', { - candidate: { git: { clean: true, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 1 } } + candidate: { + git: { + clean: true, + upstreamAhead: 2, + upstreamBehind: 0, + checkedAt: 1 + } + } }), makeNamedFacets('clean'), makeNamedFacets('dirty', { - candidate: { git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 1 } } + candidate: { + git: { + clean: false, + upstreamAhead: 0, + upstreamBehind: 0, + checkedAt: 1 + } + } }), makeNamedFacets('unknown', { - candidate: { git: { clean: null, upstreamAhead: 0, upstreamBehind: 0, checkedAt: null } } + candidate: { + git: { + clean: null, + upstreamAhead: 0, + upstreamBehind: 0, + checkedAt: null + } + } }) ] expect(names(rows, 'git', 'asc')).toEqual(['clean', 'unknown', 'dirty', 'unpushed']) }) - it('orders tiers ready to protected and agents idle to permission', () => { - const tierRows = [ - makeNamedFacets('protectedRow', { candidate: { tier: 'protected' } }), - makeNamedFacets('readyRow', { candidate: { tier: 'ready' } }), - makeNamedFacets('reviewRow', { candidate: { tier: 'review' } }) - ] - expect(names(tierRows, 'tier', 'asc')).toEqual(['readyRow', 'reviewRow', 'protectedRow']) - + it('orders agents idle to permission', () => { const agentRows = [ makeNamedFacets('permission', { agentStatus: 'permission' }), makeNamedFacets('idle'), @@ -91,8 +105,12 @@ describe('ranked fields', () => { it('breaks equal blocker counts by the worst blocker', () => { const rows = [ - makeNamedFacets('softest', { candidate: { blockers: ['git-status-error'] } }), - makeNamedFacets('hardest', { candidate: { blockers: ['main-worktree'] } }) + makeNamedFacets('softest', { + candidate: { blockers: ['git-status-error'] } + }), + makeNamedFacets('hardest', { + candidate: { blockers: ['main-worktree'] } + }) ] expect(names(rows, 'blocker-count', 'desc')).toEqual(['hardest', 'softest']) }) @@ -101,8 +119,12 @@ describe('ranked fields', () => { describe('tie-breaks', () => { it('falls back to activity, repo, name, then worktree id', () => { const rows = [ - makeNamedFacets('zeta', { candidate: { lastActivityAt: FACET_NOW - DAY } }), - makeNamedFacets('alpha', { candidate: { lastActivityAt: FACET_NOW - 2 * DAY } }) + makeNamedFacets('zeta', { + candidate: { lastActivityAt: FACET_NOW - DAY } + }), + makeNamedFacets('alpha', { + candidate: { lastActivityAt: FACET_NOW - 2 * DAY } + }) ] // Same size on both sides, so only the tie-break chain can order them. expect(names(rows, 'size', 'desc')).toEqual(['alpha', 'zeta']) @@ -111,7 +133,9 @@ describe('tie-breaks', () => { it('keeps tie-broken rows in the same order when direction flips', () => { const rows = [ makeNamedFacets('b', { candidate: { lastActivityAt: FACET_NOW - DAY } }), - makeNamedFacets('a', { candidate: { lastActivityAt: FACET_NOW - 2 * DAY } }) + makeNamedFacets('a', { + candidate: { lastActivityAt: FACET_NOW - 2 * DAY } + }) ] expect(names(rows, 'git', 'asc')).toEqual(names(rows, 'git', 'desc')) }) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.ts index 50787aa6bd9..354e3c063e0 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.ts @@ -3,8 +3,7 @@ import { getWorkspaceCleanupAgentRank, getWorkspaceCleanupBlockerSeverity, getWorkspaceCleanupGitRank, - getWorkspaceCleanupReviewRank, - getWorkspaceCleanupTierRank + getWorkspaceCleanupReviewRank } from '../../../../shared/workspace-cleanup-facet-rankings' import type { WorkspaceCleanupSortDirectionState, @@ -161,8 +160,6 @@ function compareNatural( ) case 'local-context': return left.localContextCount - right.localContextCount - case 'tier': - return getWorkspaceCleanupTierRank(left.tier) - getWorkspaceCleanupTierRank(right.tier) case 'blocker-count': return ( left.blockerCount - right.blockerCount || diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts index c52a9a020e6..7e44fed5344 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts @@ -4,10 +4,10 @@ import type { WorkspaceStatusDefinition } from '../../../../shared/worktree/type import { getWorkspaceCleanupCandidateIdentity } from '../../../../shared/workspace-cleanup-host-identity' import { getWorkspaceStatus } from '../../../../shared/workspace-statuses' import { - canSelectWorkspaceCleanupCandidate, + canQueueWorkspaceCleanupCandidate, + WORKSPACE_CLEANUP_BULK_SELECT_EXCLUSIONS, type WorkspaceCleanupBlocker, - type WorkspaceCleanupCandidate, - type WorkspaceCleanupTier + type WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' import { getWorkspaceCleanupGitState } from './workspace-cleanup-filter-sort' import type { @@ -22,6 +22,7 @@ import { getWorkspaceCleanupHostIdentity, type WorkspaceCleanupWorktreeFacts } from './workspace-cleanup-host-identity' +import { getWorktreeVisitTimestamp } from '@/lib/worktree-visit-recency' export type { WorkspaceCleanupWorktreeFacts } from './workspace-cleanup-host-identity' export type WorkspaceCleanupFacetSources = { @@ -47,7 +48,6 @@ export type WorkspaceCleanupFacets = { path: string branch: string hostId: ExecutionHostId - tier: WorkspaceCleanupTier blockers: readonly WorkspaceCleanupBlocker[] blockerCount: number isDismissed: boolean @@ -118,17 +118,23 @@ export function buildWorkspaceCleanupFacets( path: candidate.path, branch, hostId: worktree?.hostId ?? getWorkspaceCleanupCandidateHostId(candidate), - tier: candidate.tier, blockers: candidate.blockers, blockerCount: candidate.blockers.length, isDismissed: (sources.dismissedIdentities?.has(getWorkspaceCleanupCandidateIdentity(candidate)) ?? false) || candidate.blockers.includes('dismissed'), - isSelectable: canSelectWorkspaceCleanupCandidate(candidate), + isSelectable: + canQueueWorkspaceCleanupCandidate(candidate) && + !candidate.blockers.some((blocker) => WORKSPACE_CLEANUP_BULK_SELECT_EXCLUSIONS.has(blocker)), lastActivityAt: candidate.lastActivityAt, createdAt: toFiniteOrNull(worktree?.createdAt ?? candidate.createdAt), - lastVisitedAt: toFiniteOrNull(sources.lastVisitedAtByWorktreeId?.[candidate.worktreeId]), + lastVisitedAt: toFiniteOrNull( + getWorktreeVisitTimestamp(sources.lastVisitedAtByWorktreeId, { + id: candidate.worktreeId, + hostId: worktree?.hostId ?? getWorkspaceCleanupCandidateHostId(candidate) + }) + ), sizeBytes: toFiniteOrNull( sources.sizeBytesByWorktreeId?.get(hostIdentity) ?? sources.sizeBytesByWorktreeId?.get(candidate.worktreeId) @@ -139,7 +145,9 @@ export function buildWorkspaceCleanupFacets( isPinned: worktree?.isPinned ?? candidate.blockers.includes('pinned'), isUnread: worktree?.isUnread ?? false, hasComment, - agentState: sources.liveAgentStatusByWorktreeId?.get(candidate.worktreeId) ?? 'idle', + agentState: toWorkspaceCleanupAgentState( + sources.liveAgentStatusByWorktreeId?.get(candidate.worktreeId) + ), retainedDoneAgentCount: candidate.localContext.retainedDoneAgentCount, gitState: getWorkspaceCleanupGitState(candidate), upstreamAhead: toFiniteOrNull(candidate.git.upstreamAhead), @@ -168,6 +176,17 @@ export function countWorkspaceCleanupMeasuredRows(rows: readonly WorkspaceCleanu return rows.reduce((count, row) => count + (row.sizeBytes === null ? 0 : 1), 0) } +// Why: monitoring is still registered background work, so it filters as active — offering +// such a workspace as idle would invite cleaning up a running dev server (#10997). +function toWorkspaceCleanupAgentState( + status: LiveAgentWorktreeStatus | undefined +): WorkspaceCleanupAgentState { + if (status === undefined) { + return 'idle' + } + return status === 'monitoring' ? 'working' : status +} + function getLocalContextCount(candidate: WorkspaceCleanupCandidate): number { const context = candidate.localContext return ( @@ -211,7 +230,6 @@ function buildSearchText(facets: Omit): st facets.review.title, facets.review.provider, facets.gitState, - facets.tier, ...facets.ticketSources, ...facets.blockers ] diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.test.tsx index 0927053709e..44b76bbff11 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.test.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.test.tsx @@ -2,15 +2,24 @@ import { cleanup, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { createDefaultWorkspaceCleanupFilterState } from '../../../../shared/workspace-cleanup-filter-model' +import type { WorkspaceCleanupAppliedFilter } from '../../../../shared/workspace-cleanup-applied-filters' import { WorkspaceCleanupFilterBar } from './workspace-cleanup-filter-bar' afterEach(cleanup) -function renderFilterBar(facetPanelOpen = false): void { +function renderFilterBar( + facetPanelOpen = false, + overrides: { + filters?: ReturnType + hasActiveFilters?: boolean + onClearAppliedFilter?: (filter: WorkspaceCleanupAppliedFilter) => void + onClearFilters?: () => void + } = {} +): void { render( ) } @@ -46,6 +56,41 @@ describe('WorkspaceCleanupFilterBar', () => { expect(screen.queryByRole('button', { name: 'Scan' })).toBeNull() }) + it('names an applied filter in the bar without opening the panel', () => { + // The reported defect: a persisted idleMinDays the user never set. The bar already + // read "Showing N of M", so only the cause was hidden. + const filters = createDefaultWorkspaceCleanupFilterState() + filters.activity.idleMinDays = 20 + renderFilterBar(false, { filters, hasActiveFilters: true }) + + expect(screen.getByRole('listitem').textContent).toContain('Idle 20d+') + }) + + it('clears one named filter from its chip', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.activity.idleMinDays = 20 + const onClearAppliedFilter = vi.fn() + renderFilterBar(false, { filters, hasActiveFilters: true, onClearAppliedFilter }) + + screen.getByRole('button', { name: 'Remove filter Idle 20d+' }).click() + + expect(onClearAppliedFilter).toHaveBeenCalledTimes(1) + expect(onClearAppliedFilter.mock.calls[0][0].id).toBe('activity.idleMinDays') + }) + + it('offers Clear filters in the bar, not only inside the panel', () => { + renderFilterBar(false, { hasActiveFilters: true }) + + expect(screen.getAllByRole('button', { name: /Clear filters/ }).length).toBeGreaterThan(0) + }) + + it('shows no chips and no Clear for a default profile', () => { + renderFilterBar() + + expect(screen.queryAllByRole('listitem')).toHaveLength(0) + expect(screen.queryByRole('button', { name: /Clear filters/ })).toBeNull() + }) + it('keeps the footer visible while the facet panel scrolls', () => { renderFilterBar(true) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.tsx index 1fcfa2049dc..8a48dc10c05 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.tsx @@ -5,6 +5,11 @@ import { Input } from '@/components/ui/input' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { ScrollArea } from '@/components/ui/scroll-area' import { translate } from '@/i18n/i18n' +import { + useAppliedWorkspaceCleanupFilters, + WorkspaceCleanupAppliedFilterChips +} from './workspace-cleanup-applied-filter-chips' +import type { WorkspaceCleanupAppliedFilter } from '../../../../shared/workspace-cleanup-applied-filters' import { WorkspaceCleanupGitReviewFacets } from './workspace-cleanup-git-review-facets' import { WorkspaceCleanupLifecycleFacets } from './workspace-cleanup-lifecycle-facets' import type { WorkspaceCleanupFacetGroupProps } from './workspace-cleanup-facet-panel-model' @@ -23,7 +28,8 @@ export function WorkspaceCleanupFilterBar({ hasActiveFilters, gitEvidence, onQueryChange, - onClearFilters + onClearFilters, + onClearAppliedFilter }: { facetProps: WorkspaceCleanupFacetGroupProps facetPanelOpen: boolean @@ -34,8 +40,10 @@ export function WorkspaceCleanupFilterBar({ gitEvidence: WorkspaceCleanupGitEvidenceProgress onQueryChange: (query: string) => void onClearFilters: () => void + onClearAppliedFilter: (filter: WorkspaceCleanupAppliedFilter) => void }): React.JSX.Element { const { filters, totalCount } = facetProps + const applied = useAppliedWorkspaceCleanupFilters(filters) return (
@@ -70,7 +78,12 @@ export function WorkspaceCleanupFilterBar({ {/* 471px preserves the 420px facet viewport plus the fixed footer at full height. */} @@ -101,6 +114,15 @@ export function WorkspaceCleanupFilterBar({ )} + {/* Why out here and not only in the popover: a filter narrowing the list must be + clearable without first discovering which panel it lives in. */} + {hasActiveFilters ? ( + + ) : null} + {gitEvidence.pendingCount > 0 ? ( @@ -111,6 +133,8 @@ export function WorkspaceCleanupFilterBar({ )} ) : null} + +
) } diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx index 6a1610b9763..2f36354a286 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx @@ -26,15 +26,25 @@ let container: HTMLDivElement | null = null // One row per bucket the retired tab partition used to hide behind a tab. const FLEET: WorkspaceCleanupFacets[] = [ - makeNamedFacets('ready-one', { candidate: { tier: 'ready' }, sizeBytes: 4 * 1024 * 1024 }), + makeNamedFacets('ready-one', { + candidate: { tier: 'ready' }, + sizeBytes: 4 * 1024 * 1024 + }), makeNamedFacets('needs-review', { candidate: { tier: 'review' } }), makeNamedFacets('protected-one', { - candidate: { tier: 'protected', blockers: ['pinned'], selectedByDefault: false } + candidate: { + tier: 'protected', + blockers: ['pinned'], + selectedByDefault: false + } }), makeNamedFacets('ignored-one', { dismissed: true }) ] -function renderRows(rows: readonly WorkspaceCleanupFacets[]): void { +function renderRows( + rows: readonly WorkspaceCleanupFacets[], + onForgetLocally?: (candidate: WorkspaceCleanupFacets['candidate']) => void +): void { act(() => root?.render( { it('switches the visible rows when filters are applied', () => { const filters = createDefaultWorkspaceCleanupFilterState() - filters.safety.tiers = ['ready'] filters.safety.dismissed = 'exclude' renderRows(query(filters).rows) - expect(renderedNames()).toEqual(['ready-one']) + expect(renderedNames()).not.toContain('ignored-one') - filters.safety.tiers = [] filters.safety.dismissed = 'only' renderRows(query(filters).rows) expect(renderedNames()).toEqual(['ignored-one']) - - filters.safety.dismissed = 'any' - filters.safety.tiers = ['protected'] - renderRows(query(filters).rows) - expect(renderedNames()).toEqual(['protected-one']) }) - it('reports only the rows the user could actually queue for deletion', () => { - expect(query().selectableIdentities).not.toContain( + it('lets select-all include pinned label rows', () => { + expect(query().selectableIdentities).toContain( getWorkspaceCleanupHostIdentity('local', 'repo-1::/repo/protected-one') ) }) + it('offers local removal for a disconnected SSH row without making it selectable', () => { + const onForgetLocally = vi.fn() + const disconnected = makeNamedFacets('disconnected', { + candidate: { blockers: ['ssh-disconnected'] } + }) + + renderRows([disconnected], onForgetLocally) + + expect(container?.querySelector('[aria-label^="Select disconnected"]')).toBeNull() + const forgetButton = container?.querySelector('[aria-label="Remove from Orca"]') + expect(forgetButton).not.toBeNull() + act(() => forgetButton?.click()) + expect(onForgetLocally).toHaveBeenCalledWith(disconnected.candidate) + }) + it('shows status and disk-size facts without expanding a row', () => { renderRows(query().rows) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-forget-locally-button.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-forget-locally-button.tsx new file mode 100644 index 00000000000..a139df881a6 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-forget-locally-button.tsx @@ -0,0 +1,43 @@ +import { Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { resolveWorkspaceCleanupRemovalHostId } from '../../../../shared/workspace-cleanup-host-identity' +import { runWorktreeDelete } from '@/components/sidebar/delete-worktree-flow' + +export function openWorkspaceCleanupForgetLocally(candidate: WorkspaceCleanupCandidate): void { + runWorktreeDelete(candidate.worktreeId, { + expectedHostId: resolveWorkspaceCleanupRemovalHostId(candidate) ?? undefined + }) +} + +export function WorkspaceCleanupForgetLocallyButton({ + candidate, + onForget +}: { + candidate: WorkspaceCleanupCandidate + onForget: (candidate: WorkspaceCleanupCandidate) => void +}): React.JSX.Element { + const label = translate( + 'auto.components.sidebar.ForgetSshWorkspaceDialog.forget', + 'Remove from Orca' + ) + return ( + + + + + + {label} + + + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-review-facets.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-review-facets.tsx index 3db2300c4c5..337c164a2ba 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-review-facets.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-review-facets.tsx @@ -8,12 +8,10 @@ import { WORKSPACE_CLEANUP_PRESENCE_VALUES, WORKSPACE_CLEANUP_REVIEW_STATE_VALUES, WORKSPACE_CLEANUP_TICKET_SOURCE_VALUES, - WORKSPACE_CLEANUP_TIER_VALUES, WORKSPACE_CLEANUP_TRI_STATE_VALUES } from '../../../../shared/workspace-cleanup-facet-rankings' import { getWorkspaceCleanupBlockerLabel } from './workspace-cleanup-candidate-labels' import { - FacetCheckbox, FacetChoice, FacetNumberField, FacetSection, @@ -27,7 +25,6 @@ import { getWorkspaceCleanupReviewProviderLabel, getWorkspaceCleanupReviewStateLabel, getWorkspaceCleanupTicketSourceLabel, - getWorkspaceCleanupTierLabel, getWorkspaceCleanupTriStateLabel } from './workspace-cleanup-facet-labels' import type { WorkspaceCleanupFacetGroupProps } from './workspace-cleanup-facet-panel-model' @@ -166,13 +163,6 @@ export function WorkspaceCleanupGitReviewFacets({ matchCount={counts.safety} totalCount={totalCount} > - onPatch('safety', { tiers })} - /> onPatch('safety', { dismissed })} /> - onPatch('safety', { selectableOnly })} - /> ) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation-fixtures.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation-fixtures.ts index 17f5f2f7c05..3abf167b57e 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation-fixtures.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation-fixtures.ts @@ -3,7 +3,10 @@ import { translate } from '@/i18n/i18n' import type { HostedReviewInfo } from '../../../../shared/hosted-review' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' -import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { + applyWorkspaceCleanupPolicy, + type WorkspaceCleanupCandidate +} from '../../../../shared/workspace-cleanup' import type { WorkspaceCleanupFilters } from './workspace-cleanup-presentation' export const NOW = 1_700_000_000_000 @@ -27,7 +30,7 @@ export const DEFAULT_FILTERS: WorkspaceCleanupFilters = { export function makeCandidate( overrides: Partial = {} ): WorkspaceCleanupCandidate { - return { + return applyWorkspaceCleanupPolicy({ worktreeId: 'repo-1::/repo/alpha', repoId: 'repo-1', repoName: 'Repo', @@ -35,8 +38,6 @@ export function makeCandidate( displayName: 'alpha', branch: 'alpha', path: '/repo/alpha', - tier: 'ready', - selectedByDefault: true, reasons: ['idle-clean'], blockers: [], lastActivityAt: NOW - 40 * 24 * 60 * 60 * 1000, @@ -56,7 +57,7 @@ export function makeCandidate( }, fingerprint: 'fingerprint', ...overrides - } + }) } export function makeWorktree(overrides: Partial = {}): Worktree { diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-labels.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-labels.ts new file mode 100644 index 00000000000..887cb1c17d0 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-labels.ts @@ -0,0 +1,9 @@ +import type { WorkspaceCleanupReviewInfo } from './workspace-cleanup-presentation' + +export function formatCompactActivityLabel(label: string): string { + return label === 'Just now' ? 'now' : label.replace(/ ago$/, '') +} + +export function getReviewTooltip(reviewInfo: WorkspaceCleanupReviewInfo): string { + return [reviewInfo.label, reviewInfo.state, reviewInfo.title].filter(Boolean).join(' · ') +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-list.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-list.tsx index 6c1da8faed2..fe81bf57e08 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-list.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-list.tsx @@ -7,6 +7,7 @@ import { WorkspaceCleanupCandidateList } from './workspace-cleanup-candidate-lis import { WorkspaceCleanupEmptyState } from './workspace-cleanup-dialog-notices' import type { WorkspaceCleanupFacets } from './workspace-cleanup-facets' import { formatWorkspaceCleanupRelativeTime } from './workspace-cleanup-relative-time' +import type { WorkspaceCleanupFailure } from '@/store/slices/workspace-cleanup' export type WorkspaceCleanupRowListState = { rows: readonly WorkspaceCleanupFacets[] @@ -23,7 +24,7 @@ export type WorkspaceCleanupRowListState = { selectedIds: ReadonlySet gitPendingWorktreeIds: ReadonlySet /** Keyed by host-qualified identity so a failure marks only its own host's row. */ - rowFailures: Record + rowFailures: Record scrollElement: HTMLDivElement | null onClearFilters: () => void onToggleExpanded: (identity: string) => void @@ -31,6 +32,8 @@ export type WorkspaceCleanupRowListState = { onView: (candidate: WorkspaceCleanupCandidate) => void onIgnore: (candidate: WorkspaceCleanupCandidate) => void onRemove: (candidate: WorkspaceCleanupCandidate) => void + onForgetLocally: (candidate: WorkspaceCleanupCandidate) => void + onDeleteAnyway: (candidate: WorkspaceCleanupCandidate) => void } export function WorkspaceCleanupRowList(props: WorkspaceCleanupRowListState): React.JSX.Element { @@ -92,6 +95,8 @@ export function WorkspaceCleanupRowList(props: WorkspaceCleanupRowListState): Re onView={props.onView} onIgnore={props.onIgnore} onRemove={props.onRemove} + onForgetLocally={props.onForgetLocally} + onDeleteAnyway={props.onDeleteAnyway} /> )} /> diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scan-notice.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scan-notice.ts index e05d4b2caa7..5b9de759572 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scan-notice.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scan-notice.ts @@ -64,36 +64,17 @@ export function formatWorkspaceCleanupScanProgress( ) } -export function formatWorkspaceCleanupReadyToast( - workspaceCount: number, - suggestedCount: number -): string { +/** Reports scan size without classifying the user's work. */ +export function formatWorkspaceCleanupReadyToast(workspaceCount: number): string { if (workspaceCount === 0) { return translate('components.workspace.cleanup.scan.noWorkspaces', 'No workspaces found.') } if (workspaceCount === 1) { - return suggestedCount === 1 - ? translate( - 'components.workspace.cleanup.scan.readyOneOne', - '1 workspace found, with 1 cleanup suggestion.' - ) - : translate( - 'components.workspace.cleanup.scan.readyOneMany', - '1 workspace found, with {{value0}} cleanup suggestions.', - { value0: suggestedCount } - ) + return translate('components.workspace.cleanup.scan.readyOne', '1 workspace found.') } - return suggestedCount === 1 - ? translate( - 'components.workspace.cleanup.scan.readyManyOne', - '{{value0}} workspaces found, with 1 cleanup suggestion.', - { value0: workspaceCount } - ) - : translate( - 'components.workspace.cleanup.scan.readyManyMany', - '{{value0}} workspaces found, with {{value1}} cleanup suggestions.', - { value0: workspaceCount, value1: suggestedCount } - ) + return translate('components.workspace.cleanup.scan.readyMany', '{{value0}} workspaces found.', { + value0: workspaceCount + }) } function formatScanErrorRepoName( diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-selection-model.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-selection-model.ts index d9dc94bb8df..5824ebe1846 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-selection-model.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-selection-model.ts @@ -1,6 +1,4 @@ import { translate } from '@/i18n/i18n' -import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' -import { getWorkspaceCleanupCandidateIdentity } from '../../../../shared/workspace-cleanup-host-identity' export function formatVanishedSelectionNotice(count: number): string { return count === 1 @@ -15,20 +13,18 @@ export function formatVanishedSelectionNotice(count: number): string { ) } -/** Host-qualified identities, so a default selection cannot span two hosts' rows. */ -export function getDefaultSelectedWorkspaceCleanupIdentities( - candidates: readonly WorkspaceCleanupCandidate[], - deletingIdentities: ReadonlySet = new Set() -): Set { - return new Set( - candidates - .filter( - (candidate) => - candidate.selectedByDefault && - !deletingIdentities.has(getWorkspaceCleanupCandidateIdentity(candidate)) +/** Explains selection removed when a facet filter hides rows. */ +export function formatWithheldSelectionNotice(count: number): string { + return count === 1 + ? translate( + 'components.workspace.cleanup.browse.selectionWithheldOne', + '1 selected workspace is hidden by the current filters and was deselected.' + ) + : translate( + 'components.workspace.cleanup.browse.selectionWithheld', + '{{value0}} selected workspaces are hidden by the current filters and were deselected.', + { value0: count } ) - .map((candidate) => getWorkspaceCleanupCandidateIdentity(candidate)) - ) } export function toggleSetMember(current: Set, value: string): Set { diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-snapshot-prune-batch.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-snapshot-prune-batch.ts new file mode 100644 index 00000000000..b633f610d93 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-snapshot-prune-batch.ts @@ -0,0 +1,18 @@ +import type { WorkspaceCleanupBackgroundRemovalArgs } from './workspace-cleanup-background-removal' + +export function createWorkspaceCleanupSnapshotPruneBatch(): + | WorkspaceCleanupBackgroundRemovalArgs['snapshotPruneBatch'] + | undefined { + const begin = window.api.workspaceCleanup.beginRemovalSnapshotPruneBatch + const record = window.api.workspaceCleanup.recordRemovalSnapshotPrune + const finish = window.api.workspaceCleanup.finishRemovalSnapshotPruneBatch + if (typeof begin !== 'function' || typeof record !== 'function' || typeof finish !== 'function') { + return undefined + } + const batchId = crypto.randomUUID() + return { + batchId, + begin: () => begin({ batchId }), + finish: () => finish({ batchId }) + } +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.test.tsx index 0300865b531..151df522781 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.test.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.test.tsx @@ -92,4 +92,29 @@ describe('WorkspaceCleanupSortHeader', () => { container?.querySelector('[role="checkbox"]')?.getAttribute('aria-checked') ).toBe('mixed') }) + it('states how many workspaces select-all would take', () => { + render({ field: 'name', direction: 'asc' }, { selectableCount: 12 }) + + expect(container?.textContent).toContain('Select all 12 safety-checked workspaces') + }) + + it('uses singular copy for one safety-checked workspace', () => { + render({ field: 'name', direction: 'asc' }, { selectableCount: 1 }) + + expect(container?.textContent).toContain('Select 1 safety-checked workspace') + }) + + it('does not report all-selected when the selected rows are not the selectable ones', () => { + const onToggleSelectAll = vi.fn() + render( + { field: 'name', direction: 'asc' }, + { selectableCount: 3, selectedCount: 0, onToggleSelectAll } + ) + + const checkbox = container?.querySelector('[role="checkbox"]') + expect(checkbox?.getAttribute('aria-checked')).toBe('false') + + act(() => (checkbox as HTMLElement | null)?.click()) + expect(onToggleSelectAll).toHaveBeenCalledWith(true) + }) }) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.tsx index 2e8de45c1a8..3a3e78429c3 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.tsx @@ -31,6 +31,17 @@ export function WorkspaceCleanupSortHeader({ }): React.JSX.Element { const allSelected = selectableCount > 0 && selectedCount >= selectableCount const someSelected = selectableCount > 0 && selectedCount > 0 && !allSelected + const selectAllLabel = + selectableCount === 1 + ? translate( + 'components.workspace.cleanup.browse.selectAllCountOne', + 'Select 1 safety-checked workspace' + ) + : translate( + 'components.workspace.cleanup.browse.selectAllCount', + 'Select all {{value0}} safety-checked workspaces', + { value0: selectableCount } + ) return (
- - {translate( - 'components.workspace.cleanup.browse.selectAll', - 'Select all matching workspaces' - )} - + {selectAllLabel}