diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 2f5e334ab85..f94e38ad9f3 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,6 +1,7 @@ name: Bug report description: Report a problem with Orca title: '[Bug]: ' +type: Bug labels: - bug body: diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index d4e0a800402..800201387db 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,6 +1,7 @@ name: Feature request description: Suggest an improvement for Orca title: '[Feature]: ' +type: Feature labels: - enhancement body: diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml index 58cdbb36d1c..76595f338a6 100644 --- a/.github/workflows/computer-e2e.yml +++ b/.github/workflows/computer-e2e.yml @@ -6,6 +6,8 @@ on: - '.github/workflows/computer-e2e.yml' - 'config/electron-builder.config.cjs' - 'config/scripts/build-computer-macos.mjs' + - 'config/scripts/build-windows-cli-launcher.mjs' + - 'config/scripts/build-windows-cli-launcher.test.mjs' - 'config/scripts/computer-e2e-workflow.test.mjs' - 'config/scripts/computer-use-skill-guidance.test.mjs' - 'config/scripts/computer-use-smoke.mjs' @@ -22,12 +24,15 @@ on: - 'native/computer-use-macos/**' - 'native/computer-use-linux/**' - 'native/computer-use-windows/**' + - 'native/windows-cli-launcher/**' - 'skills/computer-use/SKILL.md' - 'src/cli/**' - 'src/main/computer/**' - 'src/main/runtime/rpc/dispatcher.ts' - 'src/main/runtime/rpc/errors.ts' - 'src/main/runtime/rpc/methods/computer*.ts' + - 'src/main/ssh/ssh-remote-cli-launcher.ts' + - 'src/main/ssh/ssh-remote-cli-launcher.test.ts' - 'src/shared/computer-use-*.ts' - 'tests/e2e/computer-linux.e2e.ts' - 'tests/e2e/computer-mac.e2e.ts' @@ -72,6 +77,8 @@ jobs: - run: pnpm install --frozen-lockfile - run: >- pnpm vitest run + config/scripts/build-windows-cli-launcher.test.mjs + src/main/ssh/ssh-remote-cli-launcher.test.ts config/scripts/computer-e2e-workflow.test.mjs config/scripts/computer-use-skill-guidance.test.mjs config/scripts/computer-use-smoke.test.mjs diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index ce603bc764d..0e18d3ca5cd 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -173,3 +173,59 @@ jobs: path: test-results/ retention-days: 7 if-no-files-found: ignore + + ssh-docker-watcher-isolation: + name: ssh docker watcher isolation + needs: build + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref || github.ref }} + + - name: Install native build and headless UI tools + run: sudo apt-get update && sudo apt-get install -y build-essential openssh-client python3 xvfb + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: package.json + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + run_install: false + + # Why: same Linux-only node-gyp pin as build/e2e jobs so the workaround + # stays consistent across workflows even while this job is ubuntu-latest. + - name: Use external node-gyp to avoid pnpm's bundled copy (Linux only) + if: runner.os == 'Linux' + run: | + npm install -g node-gyp@11.5.0 + echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Download E2E build output + uses: actions/download-artifact@v8 + with: + name: e2e-build-out + path: out/ + + # Why: this is the release-path proof that the deployed Linux relay keeps + # its PTY and explorer live across a real watcher SIGSEGV. + - name: Run Docker SSH watcher isolation E2E + run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-watcher-isolation + + - name: Upload watcher isolation traces + if: failure() + uses: actions/upload-artifact@v7 + with: + name: playwright-traces-ssh-docker-watcher-isolation + path: test-results/ + retention-days: 7 + if-no-files-found: ignore diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 397772bfb7e..605645186fb 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -863,6 +863,20 @@ jobs: ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }} + - name: Gate runtime file-watcher process isolation + if: runner.os == 'Linux' + run: | + # Why: #8212 is a native-process crash contract. Prove both the Node + # host and the exact Electron runtime survive SIGSEGV before packaging. + node config/scripts/runtime-file-watcher-fault-harness.mjs + ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs + + - name: Gate SSH relay watcher process isolation + run: | + # Why: the remote native watcher shares a daemon with live PTYs. + # Kill only its child and require both PTY and watch recovery before packaging. + node config/scripts/relay-watcher-fault-harness.mjs + - name: Publish release artifacts (Linux) if: matrix.platform == 'linux-x64' || matrix.platform == 'linux-arm64' uses: nick-fields/retry@v4 diff --git a/.github/workflows/release-mac-build.yml b/.github/workflows/release-mac-build.yml index 3638d40f00c..bd5c5dc9c00 100644 --- a/.github/workflows/release-mac-build.yml +++ b/.github/workflows/release-mac-build.yml @@ -120,6 +120,19 @@ jobs: ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }} + - name: Gate runtime file-watcher process isolation + run: | + # Why: #8212 is a native-process crash contract. Prove both the Node + # host and the exact Electron runtime survive SIGSEGV before packaging. + node config/scripts/runtime-file-watcher-fault-harness.mjs + ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs + + - name: Gate SSH relay watcher process isolation + run: | + # Why: the remote native watcher shares a daemon with live PTYs. + # Kill only its child and require both PTY and watch recovery before packaging. + node config/scripts/relay-watcher-fault-harness.mjs + - name: Publish release artifacts (macOS) uses: nick-fields/retry@v4 with: diff --git a/.github/workflows/win-update-survival-e2e.yml b/.github/workflows/win-update-survival-e2e.yml index f5e7cb09309..4f19e2529b9 100644 --- a/.github/workflows/win-update-survival-e2e.yml +++ b/.github/workflows/win-update-survival-e2e.yml @@ -73,7 +73,7 @@ jobs: uses: actions/cache@v4 with: path: dist/orca-windows-setup.exe - key: branch-installer-${{ hashFiles('src/**', 'config/**', 'package.json', 'pnpm-lock.yaml') }} + key: branch-installer-${{ hashFiles('src/**', 'config/**', 'native/**', 'resources/win32/**', 'package.json', 'pnpm-lock.yaml') }} - name: Build Windows installer (unsigned) if: steps.cache-installer.outputs.cache-hit != 'true' diff --git a/.gitignore b/.gitignore index ed81a687a69..1874a068079 100644 --- a/.gitignore +++ b/.gitignore @@ -100,9 +100,9 @@ docs/reference/react-performance-audit.md validation-screenshots/ .stably-browser -# PR verification evidence screenshots are referenced from notes but should not -# be committed. -notes/artifacts/ +# Local scratch notes and PR evidence screenshots (not part of the product). +/notes/ +/pr-evidence/ # Playwright test-results/ @@ -123,5 +123,5 @@ src/renderer/src/i18n/locales/.ko-catalog-cache.json src/renderer/src/i18n/locales/.ja-catalog-cache.json src/renderer/src/i18n/locales/.es-catalog-cache.json -# Bench result JSONs are working artifacts; headline numbers live in notes/terminal-performance-initiative.md +# Bench result JSONs are working artifacts tools/benchmarks/results/terminal-pipeline-*.json diff --git a/.npmrc b/.npmrc index 196b4fee4cf..313e39e8526 100644 --- a/.npmrc +++ b/.npmrc @@ -1,9 +1,2 @@ shamefully-hoist=true minimum-release-age=4320 -minimum-release-age-exclude[]=electron@43.1.0 -minimum-release-age-exclude[]=node-abi@4.33.0 -minimum-release-age-exclude[]=typescript@7.0.2 -minimum-release-age-exclude[]=@typescript/typescript-* -minimum-release-age-exclude[]=mermaid@11.15.0 -minimum-release-age-exclude[]=@mermaid-js/parser@1.1.1 -minimum-release-age-exclude[]=serve-sim@0.1.40 diff --git a/AGENTS.md b/AGENTS.md index 989fa611226..680c3001799 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,9 +12,7 @@ Keep comments short — one or two lines. Capture only the non-obvious reason (s ## Lint Rules: Do Not Disable Max Lines -Never add a `max-lines` disable (`eslint-disable max-lines`, `oxlint-disable max-lines`, or line-specific variants), and never add a per-file `max-lines` bump in `mobile/.oxlintrc.json`. Split the file, extract focused modules, move fixtures/builders into named files, or otherwise reduce the counted lines instead. - -This is enforced in CI by `pnpm check:max-lines-ratchet` (`config/scripts/check-max-lines-ratchet.mjs`). Files already over the limit are grandfathered in `config/max-lines-baseline.txt`; that list may only **shrink**. Adding a new suppression fails the build. If you remove a suppression (great — split it!), run `pnpm check:max-lines-ratchet --prune` to drop its baseline line. +Never add a `max-lines` disable (`eslint-disable max-lines`, `oxlint-disable max-lines`, or line-specific variants), and never add a per-file `max-lines` bump in `mobile/.oxlintrc.json`. ## File and Module Naming @@ -55,6 +53,5 @@ Source-control and review changes must consider GitLab and other supported git p ## GitHub CLI Usage Be mindful of the user's `gh` CLI API rate limit — batch requests where possible and avoid unnecessary calls. All code, commands, and scripts must be compatible with macOS, Linux, and Windows. -Never commit PR evidence images; attach them to the PR conversation instead (but never use gh-attach). ## Type Declarations: Prefer `.ts` Over `.d.ts` diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 61f38c081c4..b15ffc07f5d 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -185,6 +185,10 @@ module.exports = { from: 'resources/win32/bin/orca.cmd', to: 'bin/orca.cmd' }, + { + from: 'native/windows-cli-launcher/.build/orca.exe', + to: 'bin/orca.exe' + }, { from: 'node_modules/agent-browser/bin/agent-browser-win32-x64.exe', to: 'agent-browser-win32-x64.exe' diff --git a/config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch b/config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch index 5276bb1d813..8e24345a340 100644 --- a/config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch +++ b/config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch @@ -30,7 +30,7 @@ index 73e0de80affdf478c39b7ac1d8666e34bd3d6e31..e0f3c25039e12dc0c9094b2d19aa6288 layout (location = 1) in vec2 a_size; layout (location = 2) in vec4 a_color; @@ -71,5 +71,5 @@ out vec4 outColor; - + void main() { outColor = v_color; -}`,l0=8,Me=l0*Float32Array.BYTES_PER_ELEMENT,e1=20*l0,ne=class{constructor(){this.attributes=new Float32Array(e1),this.count=0}},p0=0,Lt=0,ht=0,dt=0,_t=0,ct=0,ut=0,ae=class extends W{constructor(t,r,s,a,o){super();this._terminal=t;this._gl=r;this._dimensions=s;this._themeService=a;this._logService=o;this._vertices=new ne;this._verticesCursor=new ne;let n=this._gl;this._program=g(ie(n,jt,Jt,this._logService)),this._register(M(()=>n.deleteProgram(this._program))),this._projectionLocation=g(n.getUniformLocation(this._program,"u_projection"));let L=this._vertexArrayObject=n.createVertexArray();this._register(M(()=>n.deleteVertexArray(L))),n.bindVertexArray(L);let T=new Float32Array([0,0,1,0,0,1,1,1]),h=n.createBuffer();this._register(M(()=>n.deleteBuffer(h))),n.bindBuffer(n.ARRAY_BUFFER,h),n.bufferData(n.ARRAY_BUFFER,T,n.STATIC_DRAW),n.enableVertexAttribArray(3),n.vertexAttribPointer(3,2,this._gl.FLOAT,!1,0,0);let d=new Uint8Array([0,1,2,3]),_=n.createBuffer();this._register(M(()=>n.deleteBuffer(_))),n.bindBuffer(n.ELEMENT_ARRAY_BUFFER,_),n.bufferData(n.ELEMENT_ARRAY_BUFFER,d,n.STATIC_DRAW),this._attributesBuffer=g(n.createBuffer()),this._register(M(()=>n.deleteBuffer(this._attributesBuffer))),n.bindBuffer(n.ARRAY_BUFFER,this._attributesBuffer),n.enableVertexAttribArray(0),n.vertexAttribPointer(0,2,n.FLOAT,!1,Me,0),n.vertexAttribDivisor(0,1),n.enableVertexAttribArray(1),n.vertexAttribPointer(1,2,n.FLOAT,!1,Me,2*Float32Array.BYTES_PER_ELEMENT),n.vertexAttribDivisor(1,1),n.enableVertexAttribArray(2),n.vertexAttribPointer(2,4,n.FLOAT,!1,Me,4*Float32Array.BYTES_PER_ELEMENT),n.vertexAttribDivisor(2,1),this._updateCachedColors(a.colors),this._register(this._themeService.onChangeColors(p=>{this._updateCachedColors(p),this._updateViewportRectangle()}))}renderBackgrounds(){this._renderVertices(this._vertices)}renderCursor(){this._renderVertices(this._verticesCursor)}_renderVertices(t){let r=this._gl;r.useProgram(this._program),r.bindVertexArray(this._vertexArrayObject),r.uniformMatrix4fv(this._projectionLocation,!1,te),r.bindBuffer(r.ARRAY_BUFFER,this._attributesBuffer),r.bufferData(r.ARRAY_BUFFER,t.attributes,r.DYNAMIC_DRAW),r.drawElementsInstanced(this._gl.TRIANGLE_STRIP,4,r.UNSIGNED_BYTE,0,t.count)}handleResize(){this._updateViewportRectangle()}setDimensions(t){this._dimensions=t}_updateCachedColors(t){this._bgFloat=this._colorToFloat32Array(t.background),this._cursorFloat=this._colorToFloat32Array(t.cursor)}_updateViewportRectangle(){this._addRectangleFloat(this._vertices.attributes,0,0,0,this._terminal.cols*this._dimensions.device.cell.width,this._terminal.rows*this._dimensions.device.cell.height,this._bgFloat)}updateBackgrounds(t){let r=this._terminal,s=this._vertices,a=1,o,n,L,T,h,d,_,p,c,u,C;for(o=0;o>24&255)/255,_t=(p0>>16&255)/255,ct=(p0>>8&255)/255,ut=1,this._addRectangle(t.attributes,r,Lt,ht,(n-o)*this._dimensions.device.cell.width,this._dimensions.device.cell.height,dt,_t,ct,ut)}_addRectangle(t,r,s,a,o,n,L,T,h,d){t[r]=s/this._dimensions.device.canvas.width,t[r+1]=a/this._dimensions.device.canvas.height,t[r+2]=o/this._dimensions.device.canvas.width,t[r+3]=n/this._dimensions.device.canvas.height,t[r+4]=L,t[r+5]=T,t[r+6]=h,t[r+7]=d}_addRectangleFloat(t,r,s,a,o,n,L){t[r]=s/this._dimensions.device.canvas.width,t[r+1]=a/this._dimensions.device.canvas.height,t[r+2]=o/this._dimensions.device.canvas.width,t[r+3]=n/this._dimensions.device.canvas.height,t[r+4]=L[0],t[r+5]=L[1],t[r+6]=L[2],t[r+7]=L[3]}_colorToFloat32Array(t){return new Float32Array([(t.rgba>>24&255)/255,(t.rgba>>16&255)/255,(t.rgba>>8&255)/255,(t.rgba&255)/255])}};var oe=class extends W{constructor(t,r,s,a,o,n,L,T){super();this._container=r;this._alpha=o;this._coreBrowserService=n;this._optionsService=L;this._themeService=T;this._deviceCharWidth=0;this._deviceCharHeight=0;this._deviceCellWidth=0;this._deviceCellHeight=0;this._deviceCharLeft=0;this._deviceCharTop=0;this._canvas=this._coreBrowserService.mainDocument.createElement("canvas"),this._canvas.classList.add(`xterm-${s}-layer`),this._canvas.style.zIndex=a.toString(),this._initCanvas(),this._container.appendChild(this._canvas),this._register(this._themeService.onChangeColors(h=>{this._refreshCharAtlas(t,h),this.reset(t)})),this._register(M(()=>{this._canvas.remove()}))}_initCanvas(){this._ctx=g(this._canvas.getContext("2d",{alpha:this._alpha})),this._alpha||this._clearAll()}handleBlur(t){}handleFocus(t){}handleCursorMove(t){}handleGridChanged(t,r,s){}handleSelectionChanged(t,r,s,a=!1){}_setTransparency(t,r){if(r===this._alpha)return;let s=this._canvas;this._alpha=r,this._canvas=this._canvas.cloneNode(),this._initCanvas(),this._container.replaceChild(this._canvas,s),this._refreshCharAtlas(t,this._themeService.colors),this.handleGridChanged(t,0,t.rows-1)}_refreshCharAtlas(t,r){this._deviceCharWidth<=0&&this._deviceCharHeight<=0||(this._charAtlas=Y0(t,this._optionsService.rawOptions,r,this._deviceCellWidth,this._deviceCellHeight,this._deviceCharWidth,this._deviceCharHeight,this._coreBrowserService.dpr,2048),this._charAtlas.warmUp())}resize(t,r){this._deviceCellWidth=r.device.cell.width,this._deviceCellHeight=r.device.cell.height,this._deviceCharWidth=r.device.char.width,this._deviceCharHeight=r.device.char.height,this._deviceCharLeft=r.device.char.left,this._deviceCharTop=r.device.char.top,this._canvas.width=r.device.canvas.width,this._canvas.height=r.device.canvas.height,this._canvas.style.width=`${r.css.canvas.width}px`,this._canvas.style.height=`${r.css.canvas.height}px`,this._alpha||this._clearAll(),this._refreshCharAtlas(t,this._themeService.colors)}_fillBottomLineAtCells(t,r,s=1){this._ctx.fillRect(t*this._deviceCellWidth,(r+1)*this._deviceCellHeight-this._coreBrowserService.dpr-1,s*this._deviceCellWidth,this._coreBrowserService.dpr)}_clearAll(){this._alpha?this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height):(this._ctx.fillStyle=this._themeService.colors.background.css,this._ctx.fillRect(0,0,this._canvas.width,this._canvas.height))}_clearCells(t,r,s,a){this._alpha?this._ctx.clearRect(t*this._deviceCellWidth,r*this._deviceCellHeight,s*this._deviceCellWidth,a*this._deviceCellHeight):(this._ctx.fillStyle=this._themeService.colors.background.css,this._ctx.fillRect(t*this._deviceCellWidth,r*this._deviceCellHeight,s*this._deviceCellWidth,a*this._deviceCellHeight))}_fillCharTrueColor(t,r,s,a){this._ctx.font=this._getFont(t,!1,!1),this._ctx.textBaseline=K0,this._clipCell(s,a,r.getWidth()),this._ctx.fillText(r.getChars(),s*this._deviceCellWidth+this._deviceCharLeft,a*this._deviceCellHeight+this._deviceCharTop+this._deviceCharHeight)}_clipCell(t,r,s){this._ctx.beginPath(),this._ctx.rect(t*this._deviceCellWidth,r*this._deviceCellHeight,s*this._deviceCellWidth,this._deviceCellHeight),this._ctx.clip()}_getFont(t,r,s){let a=r?t.options.fontWeightBold:t.options.fontWeight;return`${s?"italic":""} ${a} ${t.options.fontSize*this._coreBrowserService.dpr}px ${t.options.fontFamily}`}};var Te=class extends oe{constructor(e,t,r,s,a,o,n){super(r,e,"link",t,!0,a,o,n),this._register(s.onShowLinkUnderline(L=>this._handleShowLinkUnderline(L))),this._register(s.onHideLinkUnderline(L=>this._handleHideLinkUnderline(L)))}resize(e,t){super.resize(e,t),this._state=void 0}reset(e){this._clearCurrentLink()}_clearCurrentLink(){if(this._state){this._clearCells(this._state.x1,this._state.y1,this._state.cols-this._state.x1,1);let e=this._state.y2-this._state.y1-1;e>0&&this._clearCells(0,this._state.y1+1,this._state.cols,e),this._clearCells(0,this._state.y2,this._state.x2,1),this._state=void 0}}_handleShowLinkUnderline(e){if(e.fg===257?this._ctx.fillStyle=this._themeService.colors.background.css:e.fg!==void 0&&st(e.fg)?this._ctx.fillStyle=this._themeService.colors.ansi[e.fg].css:this._ctx.fillStyle=this._themeService.colors.foreground.css,e.y1===e.y2)this._fillBottomLineAtCells(e.x1,e.y1,e.x2-e.x1);else{this._fillBottomLineAtCells(e.x1,e.y1,e.cols-e.x1);for(let t=e.y1+1;tthis._handleColorChange())),this._cellColorResolver=new G0(this._terminal,this._optionsService,this._model.selection,this._decorationService,this._coreBrowserService,this._themeService),this._core=this._terminal._core,this._renderLayers=[new Te(this._core.screenElement,2,this._terminal,this._core.linkifier,this._coreBrowserService,T,this._themeService)],this.dimensions=Ue(),this._devicePixelRatio=this._coreBrowserService.dpr,this._updateDimensions(),this._updateCursorBlink(),this._register(T.onOptionChange(()=>this._handleOptionsChanged())),this._textBlinkStateManager=this._register(new J0(()=>this._requestRedrawViewport(),this._coreBrowserService,this._optionsService)),this._resetBlinkingRowState(),this._deviceMaxTextureSize=this._gl.getParameter(this._gl.MAX_TEXTURE_SIZE),this._register(le(this._canvas,"webglcontextlost",c=>{this._logService.debug("webglcontextlost event received"),c.preventDefault(),this._contextRestorationTimeout=setTimeout(()=>{this._contextRestorationTimeout=void 0,this._logService.warn("webgl context not restored; firing onContextLoss"),this._onContextLoss.fire(c)},3e3)})),this._register(le(this._canvas,"webglcontextrestored",c=>{this._logService.warn("webglcontextrestored event received"),clearTimeout(this._contextRestorationTimeout),this._contextRestorationTimeout=void 0,be(this._terminal),this._initializeWebGLState(),this._requestRedrawViewport()})),this._observerDisposable.value=ve(this._canvas,this._coreBrowserService.window,(c,u)=>this._setCanvasDevicePixelDimensions(c,u)),this._register(this._coreBrowserService.onWindowChange(c=>{this._observerDisposable.value=ve(this._canvas,c,(u,C)=>this._setCanvasDevicePixelDimensions(u,C))})),this._register(le(this._coreBrowserService.mainDocument,"mousedown",()=>this._cursorBlinkStateManager.value?.restartBlinkAnimation())),this._core.screenElement.appendChild(this._canvas),[this._rectangleRenderer.value,this._glyphRenderer.value]=this._initializeWebGLState(),this._isAttached=this._core.screenElement.isConnected,this._register(M(()=>{clearTimeout(this._contextRestorationTimeout),this._contextRestorationTimeout=void 0;for(let c of this._renderLayers)c.dispose();this._canvas.parentElement?.removeChild(this._canvas),be(this._terminal)}))}get textureAtlas(){return this._charAtlas?.pages[0].canvas}_handleColorChange(){this._refreshCharAtlas(),this._clearModel(!0)}handleDevicePixelRatioChange(){this._devicePixelRatio!==this._coreBrowserService.dpr&&(this._devicePixelRatio=this._coreBrowserService.dpr,this.handleResize(this._terminal.cols,this._terminal.rows))}handleResize(t,r){this._updateDimensions(),this._model.resize(this._terminal.cols,this._terminal.rows),this._resetBlinkingRowState();for(let s of this._renderLayers)s.resize(this._terminal,this.dimensions);this._canvas.width=this.dimensions.device.canvas.width,this._canvas.height=this.dimensions.device.canvas.height,this._canvas.style.width=`${this.dimensions.css.canvas.width}px`,this._canvas.style.height=`${this.dimensions.css.canvas.height}px`,this._core.screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._core.screenElement.style.height=`${this.dimensions.css.canvas.height}px`,this._rectangleRenderer.value?.setDimensions(this.dimensions),this._rectangleRenderer.value?.handleResize(),this._glyphRenderer.value?.setDimensions(this.dimensions),this._glyphRenderer.value?.handleResize(),this._refreshCharAtlas(),this._clearModel(!1),this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1,sync:!0})}handleCharSizeChanged(){this.handleResize(this._terminal.cols,this._terminal.rows)}handleBlur(){for(let t of this._renderLayers)t.handleBlur(this._terminal);this._cursorBlinkStateManager.value?.pause(),this._requestRedrawViewport()}handleFocus(){for(let t of this._renderLayers)t.handleFocus(this._terminal);this._cursorBlinkStateManager.value?.resume(),this._requestRedrawViewport()}handleViewportVisibilityChange(t){this._textBlinkStateManager.setViewportVisible(t)}handleSelectionChanged(t,r,s){for(let a of this._renderLayers)a.handleSelectionChanged(this._terminal,t,r,s);this._model.selection.update(this._core,t,r,s),this._requestRedrawViewport()}handleCursorMove(){for(let t of this._renderLayers)t.handleCursorMove(this._terminal);this._cursorBlinkStateManager.value?.restartBlinkAnimation()}_handleOptionsChanged(){this._updateDimensions(),this._refreshCharAtlas(),this._updateCursorBlink()}_initializeWebGLState(){return this._rectangleRenderer.value=new ae(this._terminal,this._gl,this.dimensions,this._themeService,this._logService),this._glyphRenderer.value=new re(this._terminal,this._gl,this.dimensions,this._optionsService,this._logService),this.handleCharSizeChanged(),[this._rectangleRenderer.value,this._glyphRenderer.value]}_refreshCharAtlas(){if(this.dimensions.device.char.width<=0&&this.dimensions.device.char.height<=0){this._isAttached=!1;return}let t=Y0(this._terminal,this._optionsService.rawOptions,this._themeService.colors,this.dimensions.device.cell.width,this.dimensions.device.cell.height,this.dimensions.device.char.width,this.dimensions.device.char.height,this._coreBrowserService.dpr,this._deviceMaxTextureSize,this._customGlyphs);this._charAtlas!==t&&(this._onChangeTextureAtlas.fire(t.pages[0].canvas),this._charAtlasDisposable.value=De(c0.forward(t.onAddTextureAtlasCanvas,this._onAddTextureAtlasCanvas),c0.forward(t.onRemoveTextureAtlasCanvas,this._onRemoveTextureAtlasCanvas))),this._charAtlas=t,this._charAtlas.warmUp(),this._glyphRenderer.value?.setAtlas(this._charAtlas)}_clearModel(t){this._model.clear(),t&&this._glyphRenderer.value?.clear()}clearTextureAtlas(){this._charAtlas?.clearTexture(),this._clearModel(!0),this._requestRedrawViewport()}clear(){this._clearModel(!0);for(let t of this._renderLayers)t.reset(this._terminal);this._resetBlinkingRowState(),this._textBlinkStateManager.setNeedsBlinkInViewport(!1),this._cursorBlinkStateManager.value?.restartBlinkAnimation(),this._updateCursorBlink()}renderRows(t,r){if(!this._isAttached)if(this._core.screenElement?.isConnected&&this._charSizeService.width&&this._charSizeService.height)this._updateDimensions(),this._refreshCharAtlas(),this._isAttached=!0;else return;for(let o of this._renderLayers)o.handleGridChanged(this._terminal,t,r);if(!this._glyphRenderer.value||!this._rectangleRenderer.value)return;this._glyphRenderer.value.beginFrame()?(this._clearModel(!0),this._updateModel(0,this._terminal.rows-1)):this._updateModel(t,r);let s=!1,a=0;for(;this._charAtlas&&this._glyphRenderer.value.beginFrame()&&a++<32;)s=!0,this._clearModel(!0),this._updateModel(0,this._terminal.rows-1);s&&this._glyphRenderer.value.invalidateAtlasTextures(),this._rectangleRenderer.value.renderBackgrounds(),this._glyphRenderer.value.render(this._model),(!this._cursorBlinkStateManager.value||this._cursorBlinkStateManager.value.isCursorVisible)&&this._rectangleRenderer.value.renderCursor()}_updateCursorBlink(){this._coreService.decPrivateModes.cursorBlink??this._terminal.options.cursorBlink?this._cursorBlinkStateManager.value=new q0(()=>{this._requestRedrawCursor()},this._coreBrowserService):this._cursorBlinkStateManager.clear(),this._requestRedrawCursor()}_updateModel(t,r){let s=this._core,a=this._workCell,o,n,L,T,h,d,_,p,c,u,C,Q,m,R,F,E,y;t=pt(t,s.rows-1,0),r=pt(r,s.rows-1,0);let Y=this._coreService.decPrivateModes.cursorStyle??s.options.cursorStyle??"block",X=this._terminal.buffer.active.baseY+this._terminal.buffer.active.cursorY,A0=X-s.buffer.ydisp,t0=Math.min(this._terminal.buffer.active.cursorX,s.cols-1),L0=-1,A=this._coreService.isCursorInitialized&&!this._coreService.isCursorHidden&&(!this._cursorBlinkStateManager.value||this._cursorBlinkStateManager.value.isCursorVisible);this._model.cursor=void 0;let n0=!1;for(n=t;n<=r;n++){L=n+s.buffer.ydisp;let a0=s.buffer.lines.get(L);if(!a0){for(this._model.lineLengths[n]=0,E=0;E=_,c=E,h.length>0&&E===h[0][0]&&p){u=h.shift();let q=this._model.selection.isCellSelected(this._terminal,u[0],L);for(F=u[0]+1;F=u[1],p?(d=!0,a=new ge(a,T.translateToString(!0,u[0],u[1]),u[1]-u[0]),c=u[1]-1):_=u[1]}if(Q=a.getChars(),m=a.getCode(),F=(n*s.cols+E)*4,!$&&a.isBlink()&&($=!0),this._cellColorResolver.resolve(a,E,L,this.dimensions.device.cell.width,this.dimensions.device.cell.height),A&&L===X&&(E===t0&&(this._model.cursor={x:t0,y:A0,width:a.getWidth(),style:this._coreBrowserService.isFocused?Y:s.options.cursorInactiveStyle,cursorWidth:s.options.cursorWidth,dpr:this._devicePixelRatio},L0=t0+a.getWidth()-1),E>=t0&&E<=L0&&(this._coreBrowserService.isFocused&&Y==="block"||this._coreBrowserService.isFocused===!1&&s.options.cursorInactiveStyle==="block")&&(this._cellColorResolver.result.fg=50331648|this._themeService.colors.cursorAccent.rgba>>8&16777215,this._cellColorResolver.result.bg=50331648|this._themeService.colors.cursor.rgba>>8&16777215)),this._textBlinkStateManager.isEnabled&&!this._textBlinkStateManager.isBlinkOn&&a.isBlink()&&(this._cellColorResolver.result.fg|=1073741824),m!==0&&(this._model.lineLengths[n]=E+1),!(this._model.cells[F]===m&&this._model.cells[F+1]===this._cellColorResolver.result.bg&&this._model.cells[F+2]===this._cellColorResolver.result.fg&&this._model.cells[F+3]===this._cellColorResolver.result.ext)&&(n0=!0,Q.length>1&&(m|=lt),this._model.cells[F]=m,this._model.cells[F+1]=this._cellColorResolver.result.bg,this._model.cells[F+2]=this._cellColorResolver.result.fg,this._model.cells[F+3]=this._cellColorResolver.result.ext,R=a.getWidth(),this._glyphRenderer.value.updateCell(E,n,m,this._cellColorResolver.result.bg,this._cellColorResolver.result.fg,this._cellColorResolver.result.ext,Q,R,o),d)){for(a=this._workCell,E++;E<=c;E++)y=(n*s.cols+E)*4,this._nullModelCell(E,n,y,this._cellColorResolver.result.bg,this._cellColorResolver.result.fg,this._cellColorResolver.result.ext);E--}}this._setRowBlinkState(n,$)}n0&&this._rectangleRenderer.value.updateBackgrounds(this._model),this._rectangleRenderer.value.updateCursor(this._model),this._updateTextBlinkState()}_nullModelCell(t,r,s,a,o,n){this._glyphRenderer.value.updateCell(t,r,0,a,o,n,"",0,0),this._model.cells[s]=0,this._model.cells[s+1]=a,this._model.cells[s+2]=o,this._model.cells[s+3]=n}_resetBlinkingRowState(){this._rowHasBlinkingCells=new Array(this._terminal.rows).fill(!1),this._rowHasBlinkingCellsCount=0}_setRowBlinkState(t,r){this._rowHasBlinkingCells[t]!==r&&(this._rowHasBlinkingCells[t]=r,this._rowHasBlinkingCellsCount+=r?1:-1)}_updateTextBlinkState(){this._textBlinkStateManager.setNeedsBlinkInViewport(this._rowHasBlinkingCellsCount>0)}_updateDimensions(){!this._charSizeService.width||!this._charSizeService.height||(this.dimensions.device.char.width=Math.floor(this._charSizeService.width*this._devicePixelRatio),this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*this._devicePixelRatio),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.top=this._optionsService.rawOptions.lineHeight===1?0:Math.round((this.dimensions.device.cell.height-this.dimensions.device.char.height)/2),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.char.left=Math.floor(this._optionsService.rawOptions.letterSpacing/2),this.dimensions.device.canvas.height=this._terminal.rows*this.dimensions.device.cell.height,this.dimensions.device.canvas.width=this._terminal.cols*this.dimensions.device.cell.width,this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/this._devicePixelRatio),this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/this._devicePixelRatio),this.dimensions.css.cell.height=this.dimensions.device.cell.height/this._devicePixelRatio,this.dimensions.css.cell.width=this.dimensions.device.cell.width/this._devicePixelRatio)}_setCanvasDevicePixelDimensions(t,r){this._canvas.width===t&&this._canvas.height===r||(this._canvas.width=t,this._canvas.height=r,this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1,sync:!0}))}_requestRedrawViewport(){this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1})}_requestRedrawCursor(){let t=this._terminal.buffer.active.cursorY;this._onRequestRedraw.fire({start:t,end:t})}},ge=class extends s0{constructor(t,r,s){super();this.content=0;this.combinedData="";this.fg=t.fg,this.bg=t.bg,this.combinedData=r,this._width=s}isCombined(){return 2097152}getWidth(){return this._width}getChars(){return this.combinedData}getCode(){return 2097151}setFromCharData(t){throw new Error("not implemented")}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}};function pt(i,e,t=0){return Math.max(Math.min(i,e),t)}var Ct=class extends W{constructor(t){if(ue&&He()<16){let r={antialias:!1,depth:!1,preserveDrawingBuffer:!0};if(!document.createElement("canvas").getContext("webgl2",r))throw new Error("WebGL2 is only supported on Safari 16 and above")}super();this._onChangeTextureAtlas=this._register(new U);this.onChangeTextureAtlas=this._onChangeTextureAtlas.event;this._onAddTextureAtlasCanvas=this._register(new U);this.onAddTextureAtlasCanvas=this._onAddTextureAtlasCanvas.event;this._onRemoveTextureAtlasCanvas=this._register(new U);this.onRemoveTextureAtlasCanvas=this._onRemoveTextureAtlasCanvas.event;this._onContextLoss=this._register(new U);this.onContextLoss=this._onContextLoss.event;this._customGlyphs=t?.customGlyphs??!0,this._preserveDrawingBuffer=t?.preserveDrawingBuffer}activate(t){let r=t._core;if(!t.element){this._register(r.onWillOpen(()=>this.activate(t)));return}this._terminal=t;let s=r.coreService,a=r.optionsService,o=r,n=o._renderService,L=o._characterJoinerService,T=o._charSizeService,h=o._coreBrowserService,d=o._decorationService,_=o._logService,p=o._themeService;this._renderer=this._register(new Le(t,L,T,h,s,d,_,a,p,this._customGlyphs,this._preserveDrawingBuffer)),this._register(c0.forward(this._renderer.onContextLoss,this._onContextLoss)),this._register(c0.forward(this._renderer.onChangeTextureAtlas,this._onChangeTextureAtlas)),this._register(c0.forward(this._renderer.onAddTextureAtlasCanvas,this._onAddTextureAtlasCanvas)),this._register(c0.forward(this._renderer.onRemoveTextureAtlasCanvas,this._onRemoveTextureAtlasCanvas)),n.setRenderer(this._renderer),this._register(M(()=>{if(this._terminal._core._store._isDisposed)return;let c=this._terminal._core._renderService;c.setRenderer(this._terminal._core._createRenderer()),c.handleResize(t.cols,t.rows)}))}get textureAtlas(){return this._renderer?.textureAtlas}clearTextureAtlas(){this._renderer?.clearTextureAtlas()}};export{Ct as WebglAddon}; @@ -58,7 +58,7 @@ index 1c3e08a1cb14208e3d8b6dd726c79285200b9df4..4785172e35b65fdc9b2c3b4dad24669a +++ b/src/TextureAtlas.ts @@ -140,12 +140,23 @@ export class TextureAtlas implements ITextureAtlas { } - + public clearTexture(): void { - if (this._pages[0].currentRow.x === 0 && this._pages[0].currentRow.y === 0) { + // Guard on real content, not pages[0].currentRow: after a page merge a @@ -82,7 +82,7 @@ index 1c3e08a1cb14208e3d8b6dd726c79285200b9df4..4785172e35b65fdc9b2c3b4dad24669a this._cacheMapCombined.clear(); this._didWarmUp = false; @@ -1111,6 +1122,10 @@ class AtlasPage { - + public clear(): void { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + // Wiped glyphs must not linger in the page stats: stale _glyphs/_usedPixels diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index c3eee4df250..95369a1bc57 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,14 +1,8 @@ { "schemaVersion": 1, - "updatedAt": "2026-07-10", + "updatedAt": "2026-07-13", "policy": { - "maturityLevels": [ - "experimental", - "soak", - "blocking", - "accepted-gap", - "deprecated" - ], + "maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"], "blockingPromotion": { "minimumSoakRuns": 100, "minimumSoakDays": 14, @@ -16,6 +10,443 @@ } }, "gates": [ + { + "id": "git-worktree.refresh-event-semantics", + "title": "Index-only Git metadata cannot trigger structural worktree refresh fanout", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime", + "layer": "main-preload-renderer-contract", + "surfaces": [ + "terminal input availability", + "worktree discovery", + "Source Control status refresh" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "ssh"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "ssh"], + "coverageNotes": "Local deterministic evidence covers git-common classification, desktop watcher debounce counts, non-overlapping poller semantics, macOS native-watch fallback, preload cleanup, and Source Control active-visible repo filtering. Linux/Windows are covered at the shared poller layer by forcing the non-darwin path; live platform runs remain gaps.", + "motivatingLinks": ["https://github.com/stablyai/orca/pull/7086"], + "invariant": "Index-only Git activity below the common Git directory must not emit worktrees:changed, invalidate worktree caches, or trigger fetchWorktrees fanout; structural add/remove/HEAD/gitdir/locked/config.worktree changes must still refresh worktrees and nudge Source Control; external head moves (commit, amend, reset) must reach background worktree rows through spawn-free metadata reads, never through structural fanout.", + "oracle": "Classify exact git-common paths as structural, status-only, or ignored; count notifications from debounced watcher events; force the Linux/Windows poll path to emit allowlisted leaf events, detect linked HEAD rewrites independent of entry-directory mtime, and surface in-place index rewrites via the backstop re-stat; diff head identities from metadata-file reads and notify only real head moves; assert Source Control subscribes to both structural and status-only signals with active-repo and visibility filters.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/worktree-base-directory-event-filter.test.ts src/main/ipc/worktree-base-directory-watcher.test.ts src/main/ipc/worktree-base-directory-poller.test.ts src/main/ipc/worktree-head-identity-reader.test.ts src/renderer/src/hooks/worktree-head-identity-apply.test.ts src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts src/renderer/src/hooks/useIpcEvents.test.ts" + ], + "testFiles": [ + "src/main/ipc/worktree-base-directory-event-filter.test.ts", + "src/main/ipc/worktree-base-directory-watcher.test.ts", + "src/main/ipc/worktree-base-directory-poller.test.ts", + "src/main/ipc/worktree-head-identity-reader.test.ts", + "src/renderer/src/hooks/worktree-head-identity-apply.test.ts", + "src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts", + "src/renderer/src/hooks/useIpcEvents.test.ts" + ], + "assertionRefs": [ + { + "file": "src/main/ipc/worktree-base-directory-event-filter.test.ts", + "assertions": [ + "primary HEAD and packed-refs classify as structural while primary index classifies as status-only", + "linked HEAD/gitdir/locked classify as structural while linked index classifies as status-only", + "HEAD reflog appends classify as status-only for linked and primary checkouts while per-ref reflogs stay ignored", + "config.worktree classifies as structural at both linked and primary levels", + "ignored common-dir churn, spaces, Windows separators, and outside-root paths do not match structurally" + ] + }, + { + "file": "src/main/ipc/worktree-base-directory-watcher.test.ts", + "assertions": [ + "linked index bursts produce zero notifyWorktreesChanged calls and one debounced status-only notification", + "linked HEAD and locked metadata still produce a structural worktree notification", + "status-only head moves emit head identities without structural fanout and only when heads actually changed", + "structural notifications re-baseline head identities silently and SSH watches never read identities", + "SSH-shaped index renames are status-only while overflow remains conservatively structural" + ] + }, + { + "file": "src/main/ipc/worktree-base-directory-poller.test.ts", + "assertions": [ + "non-darwin git-common polling emits entry create/delete and allowlisted HEAD/index leaf events", + "linked HEAD rewrites are detected even after restoring the entry-directory mtime", + "linked and primary HEAD reflog appends emit despite bumping no watched leaf or entry dir", + "in-place index rewrites surface through the periodic backstop re-stat", + "primary checkout HEAD changes and macOS narrow watch/fallback behavior still emit" + ] + }, + { + "file": "src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts", + "assertions": [ + "Source Control nudges only for the active visible repo on structural and status-only signals", + "preload subscriptions and terminal command-finished listeners are cleaned up" + ] + }, + { + "file": "src/main/ipc/worktree-head-identity-reader.test.ts", + "assertions": [ + "loose-ref, packed-refs, detached, unborn, and relative-gitdir layouts resolve or skip without spawning Git", + "traversal-shaped or backslash/colon symrefs are rejected before any path join and only hex object ids are ever emitted" + ] + }, + { + "file": "src/renderer/src/hooks/worktree-head-identity-apply.test.ts", + "assertions": [ + "head identities patch matching rows by path (including Windows separator/casing drift) and skip unknown rows" + ] + }, + { + "file": "src/renderer/src/hooks/useIpcEvents.test.ts", + "assertions": [ + "renderer preload API fixtures include the status-metadata and head-identity subscription contracts" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/worktree-base-directory-event-filter.test.ts src/main/ipc/worktree-base-directory-watcher.test.ts src/main/ipc/worktree-base-directory-poller.test.ts src/main/ipc/worktree-head-identity-reader.test.ts src/renderer/src/hooks/worktree-head-identity-apply.test.ts src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts src/renderer/src/hooks/useIpcEvents.test.ts", + "result": "passed", + "durationSeconds": 3.12, + "summary": "7 files and 130 tests passed locally, adding head-identity emit-on-change without structural fanout, reflog status triggers, config.worktree structural classification, the in-place index backstop, and the spawn-free head reader with symref traversal rejection and hex-object-id output validation." + } + ], + "runtimeBudget": { + "p95Seconds": 10, + "scope": "focused main/preload/renderer unit and polling tests" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "New experimental deterministic gate with one local macOS run; needs CI soak before promotion." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "The watcher count assertions fail against the old single-signal classifier because linked index events call notifyWorktreesChanged. Saved CI red/green artifacts are still needed before blocking promotion." + }, + "performanceBudget": { + "required": true, + "evidence": "Index-only bursts produce zero structural notifications, so renderer fetchWorktrees and detected-worktree cache invalidation are not reached. The non-darwin poller stays bounded and non-overlapping, compares HEAD/gitdir/locked signatures every tick, and gates linked index inspection behind the entry-directory signature. A 30-second live Electron run with 2,000 external linked-status calls delivered 50 ordered input chunks and recorded zero Orca-owned git worktree spawns across six diagnostic windows; a locked create/delete positive control still caused structural refreshes." + }, + "promotionCriteria": [ + "Run in soak for at least 100 consecutive passes or 14 days across required CI platforms.", + "Attach live Electron main-thread diagnostic evidence for repeated linked-worktree index rewrites while typing.", + "Add Linux/Windows live watcher evidence if shared poller-layer coverage diverges from platform behavior." + ], + "knownGaps": [ + "The live Electron diagnostic and screenshot evidence must remain attached to the motivating PR for durable review.", + "Linux and Windows are forced through the shared non-darwin poller in unit tests but are not live-tested here.", + "Git loose ref watching remains outside this incident fix by design.", + "SSH watches classify head-move triggers but skip the metadata-read identity diff; remote background-worktree heads still wait on a structural event or activation." + ], + "demotionRule": "Keep experimental or demote if the focused gate flakes without a product or harness bug, if index-only churn can emit worktrees:changed, or if structural add/remove/HEAD/lock changes fail to converge." + }, + { + "id": "runtime.headless-desktop-promotion-continuity", + "title": "Headless serve opens its desktop without replacing live terminal sessions", + "maturity": "experimental", + "protection": "partial", + "owner": "runtime-platform", + "layer": "electron-runtime-contract", + "surfaces": [ + "headless orca serve", + "single-instance desktop activation", + "CLI open", + "persistent terminal reattach" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "daemon", "ssh"], + "coverageNotes": "Deterministic unit coverage exercises activation gating, single-instance ownership, quit policy, local/remote CLI status, headless binding persistence, local daemon identity, and SSH identity transfer. A macOS Electron journey starts one headless owner in an isolated profile, creates and writes to a daemon PTY, activates the GUI through a second process, and verifies the original owner/runtime/daemon/PTY identities plus pre- and post-promotion I/O. Live packaged, Linux, and Windows journeys remain uncollected.", + "motivatingLinks": ["https://github.com/stablyai/orca/issues/8457"], + "invariant": "A safely promotable headless serve process is the single app owner. Desktop activation opens a window in that same process only after the persistent PTY provider and runtime RPC are ready; every live persisted local or SSH terminal remains bound to the same PTY/session, and a committed Cmd+Q still exits the promoted app.", + "oracle": "Unit tests coalesce early activation, fail closed on a fallback local provider, preserve the production single-instance path for serve, expose explicit desktop window state to local and remote clients, persist headless tab/leaf bindings, transfer local and SSH reattach metadata, and retain quit intent. The Electron journey asserts one main-process PID, runtime id, daemon PID, and PTY id across activation; confirms output written before promotion is visible afterward; confirms new terminal input still works; and requires the activating second process to exit.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/startup/serve-desktop-activation.test.ts src/main/startup/serve-desktop-activation-wiring.test.ts src/main/startup/single-instance-lock.test.ts src/main/startup/window-all-closed-quit-policy.test.ts src/cli/runtime-client.test.ts src/cli/runtime/websocket-transport.test.ts src/main/runtime/orca-runtime.test.ts", + "pnpm exec electron-vite build --mode e2e", + "pnpm run test:e2e -- tests/e2e/headless-serve-desktop-activation.spec.ts --workers=1" + ], + "testFiles": [ + "src/main/startup/serve-desktop-activation.test.ts", + "src/main/startup/serve-desktop-activation-wiring.test.ts", + "src/main/startup/single-instance-lock.test.ts", + "src/main/startup/window-all-closed-quit-policy.test.ts", + "src/cli/runtime-client.test.ts", + "src/cli/runtime/websocket-transport.test.ts", + "src/main/runtime/orca-runtime.test.ts", + "tests/e2e/headless-serve-desktop-activation.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/main/startup/serve-desktop-activation.test.ts", + "assertions": [ + "early activation requests coalesce until the persistent provider is ready", + "a blocked provider drops pending activation and never opens a window" + ] + }, + { + "file": "src/main/startup/serve-desktop-activation-wiring.test.ts", + "assertions": [ + "second-instance and macOS app activation use the same safety gate", + "headless PTY registration waits for provider settlement and promotion waits for RPC startup" + ] + }, + { + "file": "src/main/startup/single-instance-lock.test.ts", + "assertions": [ + "serve never skips the single-instance lock even in development", + "the isolated E2E profile can opt into the production ownership path" + ] + }, + { + "file": "src/main/startup/window-all-closed-quit-policy.test.ts", + "assertions": [ + "a promoted serve owner remains alive after an ordinary window close but exits after a committed quit" + ] + }, + { + "file": "src/cli/runtime-client.test.ts", + "assertions": [ + "local open activates a reachable headless owner and waits for a desktop window", + "unsafe promotion returns an explicit blocked error instead of launching a second owner" + ] + }, + { + "file": "src/cli/runtime/websocket-transport.test.ts", + "assertions": [ + "remote-paired open reports remote desktop state without launching a local app" + ] + }, + { + "file": "src/main/runtime/orca-runtime.test.ts", + "assertions": [ + "the headless sentinel transfers authority to the first real window", + "headless local and SSH PTY bindings are persisted on first promotion and later windowless reattach without changing ordinary desktop spawn persistence", + "status distinguishes available, openable, initializing, and blocked desktop states", + "desktop-only bell, command, and link scanners remain disabled until a real renderer graph is ready" + ] + }, + { + "file": "tests/e2e/headless-serve-desktop-activation.spec.ts", + "assertions": [ + "desktop activation keeps the same main owner PID, runtime id, daemon PID, and PTY id", + "terminal output written before promotion remains visible and post-promotion input/output still works", + "the activating second process exits instead of becoming another owner" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/startup/serve-desktop-activation.test.ts src/main/startup/serve-desktop-activation-wiring.test.ts src/main/startup/single-instance-lock.test.ts src/main/startup/window-all-closed-quit-policy.test.ts src/cli/runtime-client.test.ts src/cli/runtime/websocket-transport.test.ts src/main/runtime/orca-runtime.test.ts", + "result": "passed", + "durationSeconds": 13, + "summary": "Seven activation, ownership, quit, local/remote CLI, and runtime contract files passed with 704 tests, including first and repeated windowless reattach, local/SSH identity transfer, ordinary desktop persistence isolation, and dynamic side-effect scanner gating." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm run test:e2e -- tests/e2e/headless-serve-desktop-activation.spec.ts --workers=1", + "result": "passed", + "durationSeconds": 52, + "summary": "The isolated Electron journey passed repeatedly on the final source; the latest 51.6-second run retained the same main owner, runtime, daemon, and PTY, restored pre-promotion output, accepted post-promotion input, and observed the activating process exit." + } + ], + "runtimeBudget": { + "p95Seconds": 120, + "scope": "focused unit contracts plus one isolated Electron headless-to-desktop journey" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "New deterministic contracts and two consecutive local macOS Electron passes; CI and cross-platform soak history are not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The first Electron red run proved that bypassing the dev single-instance lock created a second owner. After enforcing one owner, the next red run retained owner/runtime/daemon identity but exposed a new PTY because the headless tab/leaf binding was not persisted. The final implementation persists that binding before renderer hydration and both subsequent Electron runs kept the original PTY and transcript. Focused unit tests were also observed red before the activation gate, promotion metadata transfer, and headless spawn-persistence changes were added." + }, + "performanceBudget": { + "required": false, + "evidence": "Activation adds no polling in the app owner and performs one bounded pass over live PTY records plus persisted terminal bindings only when a headless owner opens its first window. Desktop-only bell, command, PR-link, and mode scanners are rebuilt only while a real renderer graph is ready, preserving the prior pure-headless output path. CLI open keeps its existing 250ms bounded startup poll." + }, + "promotionCriteria": [ + "Collect at least 100 consecutive CI or soak passes or 14 days without an unexplained flake.", + "Add live packaged activation coverage on macOS plus representative Linux and Windows single-instance journeys.", + "Add an Electron SSH promotion journey in addition to the deterministic identity-transfer unit contract." + ], + "knownGaps": [ + "The Electron journey uses an isolated development bundle rather than the installed application so it cannot disturb a real user session.", + "Linux and Windows single-instance activation have unit coverage but no live Electron evidence yet.", + "SSH identity transfer is deterministic unit coverage only; the live Electron journey currently exercises the local daemon provider." + ], + "demotionRule": "Quarantine the Electron journey only with a linked product or harness defect; demote if activation changes the owner/runtime/daemon/PTY identity, loses prior output, opens before provider readiness, or fails to honor a committed quit." + }, + { + "id": "editor.live-log-append-stability", + "title": "Long live session logs retain their Monaco viewport while appending", + "maturity": "experimental", + "protection": "partial", + "owner": "editor-runtime", + "layer": "renderer-electron-contract", + "surfaces": [ + "Agent Session History View Log", + "Monaco external-content reconciliation", + "renderer crash containment" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local"], + "coverageNotes": "Focused tests and real-Monaco 9/50 MiB performance and undo-retention benchmarks are platform-independent. Local macOS Electron evidence opens a synthetic 9 MiB transcript through Agent Session History at fixed 900x720 viewport, 13px font, 1x zoom, and asserts the full 9 MiB model length loaded as a font-metric-independent containment check (word-wrap pixel geometry varies ~10% across runners, so a generous content-height floor is only a collapsed/truncated-render smoke check), then verifies three five-second-cadence watcher appends with Find open and closed. Live Windows/Linux evidence remains uncollected.", + "motivatingLinks": ["https://github.com/stablyai/orca/pull/8432"], + "invariant": "Append-only external file growth changes only Monaco's model suffix, retaining the viewport, selection, Find state, and renderer liveness above the append point; read-only live tails do not create undo history, while editable external updates remain undoable and arbitrary rewrites continue to replace the model content.", + "oracle": "Focused tests assert one post-mount content owner, actual outer lifecycle remount ordering across retained path models, exact end-of-model suffix edits with one model read, no-op equality, full replacement for non-appends, and real-Monaco undo behavior for read-only live tails versus editable files. With Node forced GC, real-Monaco benchmarks alternate 30 suffix and 30 replacement samples after five warmups on fresh equivalent models at 9 and 50 MiB, then compare exact Monaco undo-service and ArrayBuffer retention after five 10 MiB appends. The Electron scenario alternates an e2e-only legacy setValue red control and the fixed watcher append from restored equivalent model/geometry at the measured legacy-failure cadence, asserting that the control disrupts anchor state while the fixed path preserves visible ranges, selection, complete Find state, scroll offset, non-undoability, renderer survival, and forced-GC heap/native-memory budgets.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/monaco-content-sync.test.ts src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts", + "node --expose-gc ./node_modules/vitest/vitest.mjs bench src/renderer/src/components/editor/monaco-content-sync.bench.ts --pool=threads", + "node --expose-gc ./node_modules/vitest/vitest.mjs bench src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts --pool=threads", + "pnpm run test:e2e -- tests/e2e/agent-session-log-tail-stability.spec.ts --workers=1" + ], + "testFiles": [ + "src/renderer/src/components/editor/monaco-content-sync.test.ts", + "src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts", + "src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx", + "src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx", + "src/renderer/src/components/editor/monaco-content-sync.bench.ts", + "src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts", + "tests/e2e/agent-session-log-tail-stability.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/components/editor/monaco-content-sync.test.ts", + "assertions": [ + "append-only drift reads the current model once and inserts only at the previous model end", + "identical content emits no edit and non-append drift retains full replacement plus undo stops", + "read-only live-tail appends, replacements, truncations, and stale retained-model remounts use non-undoing edits", + "a stale retained target model reconciles on mount without explicit undo stops while prior-path content and undo history remain isolated" + ] + }, + { + "file": "src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts", + "assertions": [ + "a real Monaco read-only live-tail append leaves canUndo false while an ordinary external update remains undoable" + ] + }, + { + "file": "src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx", + "assertions": ["the Monaco wrapper receives defaultValue and no controlled value prop"] + }, + { + "file": "src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx", + "assertions": [ + "a same-pane path switch unmounts the prior outer Monaco before real mount reconciliation refreshes the stale target; the prior retained model content and undo sentinel remain untouched" + ] + }, + { + "file": "src/renderer/src/components/editor/monaco-content-sync.bench.ts", + "assertions": [ + "with forced GC and deterministic settlement between every arm, fresh real-Monaco 9 MiB and 50 MiB models alternate 30 append and 30 replacement samples after five warmups; append p95 stays below 50/100ms and at least 2x faster" + ] + }, + { + "file": "src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts", + "assertions": [ + "five 10 MiB read-only live-tail appends retain zero Monaco undo-service and ArrayBuffer bytes while the undoable control retains at least 50 MiB" + ] + }, + { + "file": "tests/e2e/agent-session-log-tail-stability.spec.ts", + "assertions": [ + "production Agent Session History opens a synthetic 9 MiB View Log and confirms the full model length loaded as font-metric-independent containment, with a generous content-height floor as a collapsed/truncated-render smoke check", + "an executable e2e-only legacy setValue control disrupts selection/Find/anchor state at each fixed-geometry five-second sample, then restores the equivalent model state before the fixed arm", + "three alternating watcher suffix appends preserve visible ranges, selection, scroll offset, Find open/query/active-match state, and exact suffix content", + "the production read-only live-tail model remains non-undoable before and after every watcher append", + "the renderer remains responsive with no render-process-gone event and forced-GC JS-heap/working-set/private-memory peak and retained budgets hold against paired legacy controls" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/monaco-content-sync.test.ts src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx", + "result": "passed", + "durationSeconds": 5, + "summary": "Focused editor ownership, edit-shape, mount reconciliation, lifecycle-key, and actual same-pane retained-model remount tests passed." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "node --expose-gc ./node_modules/vitest/vitest.mjs bench src/renderer/src/components/editor/monaco-content-sync.bench.ts --pool=threads", + "result": "passed", + "durationSeconds": 75, + "summary": "Forced-GC, settled, alternating fresh-model Monaco p95: 9 MiB append 4.02-5.51ms versus replacement 81.42-83.76ms; 50 MiB append 22.72-26.60ms versus replacement 445.11-449.21ms." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm run test:e2e -- tests/e2e/agent-session-log-tail-stability.spec.ts --workers=1", + "result": "passed", + "durationSeconds": 78, + "summary": "The production View Log journey alternated retained e2e-only legacy-red controls with fixed appends from restored equivalent state; every control detected instability while the fixed path retained viewport, selection, complete Find state, renderer liveness, and normalized forced-GC/native memory budgets." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts", + "result": "passed", + "durationSeconds": 4, + "summary": "The real-Monaco undo-history test confirmed a read-only live-tail append leaves canUndo false while an ordinary external update remains undoable." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "node --expose-gc ./node_modules/vitest/vitest.mjs bench src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts --pool=threads", + "result": "passed", + "durationSeconds": 5, + "summary": "The undoable 50 MiB control retained 104,858,630 undo-service bytes and 104,857,790 ArrayBuffer bytes; the read-only live-tail arm retained zero of both and remained non-undoable." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm run test:e2e -- tests/e2e/agent-session-log-tail-stability.spec.ts --workers=1", + "result": "passed", + "durationSeconds": 78, + "summary": "The production View Log journey preserved viewport, selection, Find state, renderer liveness, and forced-GC/native budgets across three watcher appends while canUndo remained false." + } + ], + "runtimeBudget": { + "p95Seconds": 600, + "scope": "local focused renderer tests plus one Electron production-journey scenario" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "New deterministic gate with local macOS passes; CI soak history is not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "A fail-first real-Monaco test observed canUndo=true after one read-only live-tail append, and the forced-GC 50 MiB control retained 104,858,630 bytes in Monaco's undo service. After the fix the read-only arm retained zero undo-service bytes while the editable control stayed undoable. The retained Electron gate also proves each fixed watcher arm preserves viewport, selection, Find state, and non-undoability. Production builds never install its legacy setValue control." + }, + "performanceBudget": { + "required": true, + "evidence": "Every update retrieves the model value once and performs at most one equality-or-prefix comparison; a matching append submits only the suffix. The registered Node commands require --expose-gc and --pool=threads so worker GC is available. Current p95: 9 MiB append 5.93-7.12ms versus replacement 114.09-145.89ms; 50 MiB append 26.84-34.91ms versus replacement 602.36-699.21ms. The new 50 MiB retention arm measured 104,858,630 undo-service bytes and 104,857,790 ArrayBuffer bytes for the undoable control versus zero for read-only live-tail sync. Electron forced-GC JS-heap, renderer working-set, and OS-private-memory budgets also pass." + }, + "promotionCriteria": [ + "Collect stable soak history on macOS, Linux, and Windows.", + "Accumulate 100 consecutive deterministic gate passes or 14 days without unexplained flakes." + ], + "knownGaps": ["No live Windows or Linux View Log evidence yet."], + "demotionRule": "Quarantine the Electron scenario if it flakes without a product or harness bug; demote if viewport/Find drift, renderer loss, p95 regression, or memory retention exceeds the registered budgets." + }, { "id": "terminal-session.snapshot-freshness", "title": "Stale liveness snapshots cannot close newer PTY bindings", @@ -23,23 +454,10 @@ "protection": "partial", "owner": "terminal-runtime", "layer": "renderer-unit", - "surfaces": [ - "terminal lifecycle", - "dead-session reconciliation", - "tab creation" - ], - "platforms": [ - "macos", - "linux", - "windows" - ], - "providers": [ - "local", - "daemon" - ], - "coveredPlatforms": [ - "macos" - ], + "surfaces": ["terminal lifecycle", "dead-session reconciliation", "tab creation"], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon"], + "coveredPlatforms": ["macos"], "coveredProviders": [], "coverageNotes": "Local macOS evidence over the reconcile guards that exist on main@1282f5c2d. Broader targeted-hasPty resume paths, no-hot listing counts, and live Electron survival arrive with the pending reliability stack.", "motivatingLinks": [ @@ -119,33 +537,14 @@ "Manage Sessions", "Resource Manager" ], - "platforms": [ - "macos", - "linux", - "windows" - ], - "providers": [ - "local", - "daemon", - "ssh", - "wsl", - "remote-runtime", - "mobile-relay" - ], - "coveredPlatforms": [ - "macos" - ], - "coveredProviders": [ - "local", - "daemon", - "ssh" - ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime", "mobile-relay"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "daemon", "ssh"], "coverageNotes": "Local macOS deterministic evidence covers the renderer snapshot/coordinator, exact local and SSH-shaped PTY request settlement, active-last and pinned terminal-tab routing, component-unmount continuation, and the existing current/legacy daemon management contract. Windows Electron process absence, live SSH/WSL behavior, and remote-runtime/mobile host completion remain explicit gaps.", - "motivatingLinks": [ - "https://github.com/stablyai/orca/issues/8001" - ], + "motivatingLinks": ["https://github.com/stablyai/orca/issues/8001"], "invariant": "Every terminal surface confirmed in the invoking renderer is force-closed exactly once after daemon management settles, later-created surfaces and non-terminal tabs survive, and exact shutdown requests are limited to deduplicated current non-runtime PTY bindings of the confirmed surfaces.", - "oracle": "Snapshot terminal entity IDs before the first await; mutate ownership, active selection, bindings, and tab presence while daemon management is pending; then assert only the immutable targets disappear from both terminal stores, active targets close last with valid editor/browser/deactivated post-state, every captured exact PTY promise settles before callbacks, and no provider inventory sweep or late-tab kill occurs.", + "oracle": "Snapshot terminal entity IDs before the first await; mutate ownership, active selection, bindings, and tab presence while daemon management is pending and between bounded close batches; then assert only the immutable targets disappear from both terminal stores, active targets close last with valid editor/browser/deactivated post-state, every captured exact PTY promise settles before callbacks, and no provider inventory sweep or late-tab kill occurs.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts src/renderer/src/components/shared/useDaemonActions.test.tsx src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts src/main/ipc/pty-management.test.ts" ], @@ -164,7 +563,7 @@ "current exact PTY bindings are deduplicated, remote runtime IDs and stale/late bindings are excluded, and all per-PTY settlements finish before completion", "the production dependency path calls daemon management exactly once and never invokes listSessions for a post-kill sweep", "management rejection and per-close/provider failures do not stop remaining cleanup and produce bounded count/latency diagnostics", - "a real 100-tab Zustand fixture records 100 close attempts and exact kills, at least 100 writes, 49 event-loop yields, and no close batch over 50 ms" + "a real 100-tab Zustand fixture records 100 close attempts and exact kills, at least 100 writes, and 49 event-loop yields; ownership is revalidated at most once after each yield when the store changed" ] }, { @@ -213,11 +612,11 @@ }, "redGreenEvidence": { "status": "partial", - "evidence": "The initial 100-tab implementation exposed a 145.82 ms close batch and failed the 50 ms budget; two-close event-loop batching reduced the focused run below the budget. Saved CI artifacts and intentional-break evidence for the confirmation-boundary assertions are still needed." + "evidence": "The initial 100-tab implementation exposed an oversized close batch; two-close event-loop batching fixed the structural issue. The gate now uses deterministic batch instrumentation because full-suite CPU saturation made wall-clock assertions flaky; saved CI artifacts and intentional-break evidence are still needed." }, "performanceBudget": { "required": true, - "evidence": "The coordinator performs one existing management call, zero provider inventory sweeps, one close per present unique target, and at most one exact kill per unique current non-runtime PTY binding. A real 100-tab Zustand fixture asserts 100 close attempts, 100 exact kill calls, at least 100 store writes, 49 event-loop yields, and a maximum two-close batch at or below 50 ms." + "evidence": "The coordinator performs one management sweep, builds an initial live-owner index, and revalidates at most once after each two-close yield when the Zustand state changed (at most 49 replans for the 100-tab fixture). It closes each present unique target once and sends at most one exact kill per unique current non-runtime PTY not already settled by daemon management. The fixture asserts 100 close attempts, 100 local kill calls, at least 100 store writes, and 49 yields; planner-level tests prove each individual plan build scans terminal and unified ownership stores once. Production diagnostics report measured close-batch duration, while the deterministic gate makes no machine-load-sensitive latency claim." }, "promotionCriteria": [ "Run the focused gate for at least 100 consecutive passes or 14 days across required CI platforms.", @@ -231,7 +630,124 @@ "Runtime-host terminal close is best-effort because closeTerminalTab still discards the existing async host result and its close-intent lifetime is shorter than the possible RPC flow.", "Daemon adapter listing failures remain suppressed by the existing management API, so reported daemon counts are not authoritative verification of every process." ], - "demotionRule": "Keep experimental or demote to protection none if the gate flakes, permits a late-created tab or unrelated PTY to close, or exceeds the 50 ms maximum close-batch budget without a tracked product or harness bug." + "demotionRule": "Keep experimental or demote to protection none if the gate flakes, permits a late-created tab or unrelated PTY to close, duplicates provider shutdown, or performs more than one ownership replan per bounded yield." + }, + { + "id": "terminal-session.explicit-close-retirement", + "title": "Explicit terminal close retires parked PTYs and agent authority exactly once", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime", + "layer": "main-preload-renderer-electron-contract", + "surfaces": [ + "terminal tab close", + "split pane close and detach", + "hidden terminal parking", + "agent resume authority" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon", + "ssh", + "runtime" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [ + "local", + "daemon", + "ssh", + "runtime" + ], + "coverageNotes": "A live macOS Electron test proves exact local PTY disappearance after a parked-tab close. Deterministic unit/store tests cover daemon and SSH routing, ordinary runtime close ownership, unified-only hydration, split ownership, pane detach transfer, restart alias hydration, and late-hook suppression; live Linux, Windows, WSL, SSH, and remote-runtime process evidence remains pending.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/8628" + ], + "invariant": "Close permanently removes the owned provider session and resume authority even when no TerminalPane is mounted; detach and park preserve both; aliases prevent a detached agent's immutable physical pane key from being retired with its former tab.", + "oracle": "Capture the exact PTY before parking, prove it remains listed while the view is absent, close through the product state boundary, and poll the provider inventory until that exact ID disappears; unit tests assert canonical owner dedupe, one teardown owner, exact pane tombstones, chained detach transfer, and restart alias restoration.", + "commands": [ + "pnpm dlx node@24 ./node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-pane-authority.test.ts src/main/ipc/agent-hooks.test.ts src/main/ipc/agent-pane-authority-ownership.test.ts src/main/ipc/pty-management.test.ts src/main/persistence.test.ts src/renderer/src/store/slices/agent-pane-authority.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/renderer/src/store/slices/terminal-tab-retirement.test.ts src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts", + "pnpm run test:e2e -- tests/e2e/terminal-parked-close-retirement.spec.ts --workers=1" + ], + "testFiles": [ + "src/main/agent-hooks/server-pane-authority.test.ts", + "src/main/ipc/agent-hooks.test.ts", + "src/main/ipc/agent-pane-authority-ownership.test.ts", + "src/main/ipc/pty-management.test.ts", + "src/main/persistence.test.ts", + "src/renderer/src/store/slices/agent-pane-authority.test.ts", + "src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts", + "src/renderer/src/store/slices/terminal-tab-retirement.test.ts", + "src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts", + "src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts", + "tests/e2e/terminal-parked-close-retirement.spec.ts" + ], + "assertionRefs": [ + { + "file": "tests/e2e/terminal-parked-close-retirement.spec.ts", + "assertions": [ + "a long-lived exact PTY remains alive after its terminal view is parked", + "closing the parked tab removes the exact PTY from the provider inventory and the visible tab model" + ] + }, + { + "file": "src/main/ipc/agent-pane-authority-ownership.test.ts", + "assertions": [ + "pane authority transfer accepts only the PTY bound to the physical local pane or the canonical legacy/scoped runtime handle" + ] + }, + { + "file": "src/renderer/src/store/slices/agent-pane-authority.test.ts", + "assertions": [ + "exact pane retirement removes resume and launch authority while preserving siblings", + "chained detach keeps physical hooks and resume authority routed to the current owner until that owner closes" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm run test:e2e -- tests/e2e/terminal-parked-close-retirement.spec.ts --workers=1", + "result": "passed", + "durationSeconds": 40.3, + "summary": "A fresh E2E build launched an isolated Electron profile, parked a live terminal, closed it through closeTab, and observed its exact PTY disappear." + } + ], + "runtimeBudget": { + "p95Seconds": 60, + "scope": "fresh E2E build plus one isolated local Electron parked-close test" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "The Electron gate passed three times locally, including the final review-fix head through the registered fresh-build command; CI and soak history are not yet available." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "The test exercises the original parked-view failure shape and passed with the retirement boundary; an archived intentional-break run is not yet attached." + }, + "performanceBudget": { + "required": false, + "evidence": "The close path is user-triggered and bounded by canonical live-owner indexing; kill-all scale remains covered by terminal-session.kill-all-surface-cleanup." + }, + "promotionCriteria": [ + "Accumulate 100 clean runs or 14 days on required CI platforms.", + "Add live Windows/ConPTY, Linux, WSL, SSH, and ordinary runtime process-absence evidence.", + "Add restart/no-resurrection and repeated park-close soak coverage." + ], + "knownGaps": [ + "The live Electron proof currently covers macOS local PTYs only.", + "Disconnected SSH relay death still requires reconnect-aware provider ownership.", + "Daemon owner leases and durable retry inventory remain follow-up hardening." + ], + "demotionRule": "Keep experimental or demote to protection none if exact PTY disappearance flakes, a sibling/detached pane is retired, or late hooks can recreate closed authority." }, { "id": "terminal-session.startup-cwd-missing-dir-recovery", @@ -240,33 +756,11 @@ "protection": "partial", "owner": "terminal-runtime", "layer": "shared-main-renderer-contract", - "surfaces": [ - "terminal lifecycle", - "tab creation", - "PTY spawn", - "startup cwd persistence" - ], - "platforms": [ - "macos", - "linux", - "windows", - "mobile" - ], - "providers": [ - "local", - "daemon", - "ssh", - "wsl", - "remote-runtime" - ], - "coveredPlatforms": [ - "macos" - ], - "coveredProviders": [ - "local", - "ssh", - "remote-runtime" - ], + "surfaces": ["terminal lifecycle", "tab creation", "PTY spawn", "startup cwd persistence"], + "platforms": ["macos", "linux", "windows", "mobile"], + "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "ssh", "remote-runtime"], "coverageNotes": "Local macOS evidence covers the shared missing-dir fallback policy, main pty:spawn recovery and metadata, no-flag and reattach strictness, renderer IPC flag routing, SSH-tagged and remote-runtime omission, and the visibility-gated terminal notice. Daemon shares the same pre-provider main cwd decision but lacks a live daemon-provider run; WSL UNC paths are exempt from the probe by design and lack a live run; Linux/Windows and mobile/API strictness are gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/7239", @@ -390,6 +884,97 @@ ], "demotionRule": "Demote or quarantine if the gate flakes without a product bug, if an existing directory is ever remapped away from the requested cwd, or if a reattach/remote/API caller can engage the fallback." }, + { + "id": "agent-status.pi-hook-liveness", + "title": "Pi status hooks cannot stall a turn or complete a live runtime", + "maturity": "experimental", + "protection": "partial", + "owner": "agent-session", + "layer": "main-provider-contract", + "surfaces": [ + "Pi and OMP managed extensions", + "agent status hooks", + "runtime reload and session replacement", + "loopback restart and stall recovery" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": [], + "coverageNotes": "Local macOS execution of the generated Pi/OMP extension plus the shared hook normalizer. WSL fallback behavior is covered with mocked native-fetch failure and Windows curl handoff. Daemon PTYs use the same generated extension without a distinct delivery path. SSH/relay ingest uses the shared normalizer, but no live remote Pi process is exercised.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/7791", + "https://github.com/stablyai/orca/pull/7802", + "https://github.com/stablyai/orca/pull/7838" + ], + "invariant": "Orca status reporting must return synchronously from every Pi/OMP extension handler, retain at most one active request and one latest pending snapshot, and abandon stalled loopback delivery within one second. A Pi session_shutdown event cannot mark a turn done because Pi also emits it for reload, new, resume, and fork while the PTY remains alive; only agent_end proves turn completion, while real process exit is cleared by PTY teardown.", + "oracle": "Execute the generated extension with a fetch that remains pending and assert the Pi handler returns before delivery; emit three statuses during the stall and assert exactly one request is active and only the latest pending status is sent next; advance fake time by one second and assert the active signal aborts and the latest status proceeds. Through the shared normalizer, assert session_shutdown yields no status while agent_end still yields done.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/pi/agent-status-extension-source.test.ts src/main/agent-hooks/server.test.ts --maxWorkers=1" + ], + "testFiles": [ + "src/main/pi/agent-status-extension-source.test.ts", + "src/main/agent-hooks/server.test.ts" + ], + "assertionRefs": [ + { + "file": "src/main/pi/agent-status-extension-source.test.ts", + "assertions": [ + "a pending loopback fetch does not keep the Pi event handler unresolved", + "three events during a stall produce one active request and one request for only the latest pending status", + "the one-second delivery deadline aborts the active request and advances the latest pending status", + "the managed status extension does not register session_shutdown as a completion event", + "WSL native-fetch failures still hand off to a detached Windows curl process" + ] + }, + { + "file": "src/main/agent-hooks/server.test.ts", + "assertions": [ + "session_shutdown normalizes to no status instead of done", + "agent_end remains the authoritative Pi/OMP done event" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-11", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/pi/agent-status-extension-source.test.ts src/main/agent-hooks/server.test.ts --maxWorkers=1", + "result": "passed", + "durationSeconds": 1.3, + "summary": "2 files and 238 tests passed, including executed generated-extension liveness, latest-only queue bounds, timeout abort, WSL fallback, and Pi shutdown normalization." + } + ], + "runtimeBudget": { + "p95Seconds": 10, + "scope": "generated-extension and shared-normalizer unit gate" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "New deterministic gate with local fake-receiver and fake-timer evidence; needs CI soak before promotion." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Before the fix, the pending-fetch handler assertion remained false and session_shutdown normalized to done (2 focused failures, 233 passes). With the fix, both pass; the bounded count and deadline assertions additionally fail if latest-only coalescing or the timeout is removed. Needs saved CI evidence before blocking promotion." + }, + "performanceBudget": { + "required": true, + "evidence": "Every Pi event does O(1) work and returns without awaiting I/O. Delivery retains at most one active request plus one latest pending object, uses one unref'd timer per active request, and creates no polling, provider scans, subprocesses outside the existing WSL failure fallback, or renderer work. The deterministic burst test proves three stalled events retain two delivery slots rather than an event-count-sized queue." + }, + "promotionCriteria": [ + "Run in soak for at least 100 consecutive passes or 14 days across required CI platforms.", + "Attach saved red/green evidence for both the unresolved-handler and false-done regressions.", + "Add a live Pi Electron test that stalls or restarts the hook listener during a running turn and proves terminal output continues." + ], + "knownGaps": [ + "No live model-backed Pi turn or Electron Running-view assertion is automated; the gate executes the exact generated extension and shared normalizer below those surfaces.", + "An already-running Pi process keeps its previously loaded extension until Pi reloads or restarts.", + "During receiver unavailability, intermediate status details may be coalesced to the latest snapshot; this is intentional and bounded, but no UI test measures the temporary detail loss.", + "SSH/remote and Windows are not exercised with live providers; relay ingest shares normalization and WSL fallback has mocked contract coverage." + ], + "demotionRule": "Demote or quarantine if the gate flakes without a product or harness bug, if a Pi handler can again await receiver I/O, or if coalescing allows an older status to overwrite a newer one." + }, { "id": "agent-session.provider-ownership", "title": "Provider sessions are resumed once per workspace ownership claim", @@ -404,21 +989,9 @@ "provider session dedupe", "sidebar and mobile identity" ], - "platforms": [ - "macos", - "linux", - "windows" - ], - "providers": [ - "local", - "daemon", - "ssh", - "wsl", - "remote-runtime" - ], - "coveredPlatforms": [ - "macos" - ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"], + "coveredPlatforms": ["macos"], "coveredProviders": [], "coverageNotes": "Local macOS evidence over the ownership/dedupe suite on main@1282f5c2d. Queued/pending resume-claim indexing, same-session and wrong-session hook proofs, and Electron repeat-activation coverage arrive with the pending stack (#7008).", "motivatingLinks": [ @@ -432,9 +1005,7 @@ "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts" ], - "testFiles": [ - "src/renderer/src/lib/resume-sleeping-agent-session.test.ts" - ], + "testFiles": ["src/renderer/src/lib/resume-sleeping-agent-session.test.ts"], "assertionRefs": [ { "file": "src/renderer/src/lib/resume-sleeping-agent-session.test.ts", @@ -492,26 +1063,10 @@ "protection": "partial", "owner": "terminal-rendering", "layer": "renderer-provider-contract", - "surfaces": [ - "PTY sizing", - "split layout", - "restore", - "hidden-to-visible transitions" - ], - "platforms": [ - "macos", - "linux", - "windows" - ], - "providers": [ - "local", - "daemon", - "ssh", - "remote-runtime" - ], - "coveredPlatforms": [ - "macos" - ], + "surfaces": ["PTY sizing", "split layout", "restore", "hidden-to-visible transitions"], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "remote-runtime"], + "coveredPlatforms": ["macos"], "coveredProviders": [], "coverageNotes": "Local macOS evidence on main@1282f5c2d, including #7192's runtime-mirror geometry authority slice. Shell-visible size, SSH/remote geometry, Windows ConPTY readback, and resume-time reassertion coverage arrive with the pending stack and #7006.", "motivatingLinks": [ @@ -613,27 +1168,10 @@ "protection": "partial", "owner": "terminal-rendering", "layer": "renderer-unit", - "surfaces": [ - "terminal search", - "links", - "WebGL", - "decorations", - "keyboard navigation" - ], - "platforms": [ - "macos", - "linux", - "windows" - ], - "providers": [ - "local", - "daemon", - "ssh", - "remote-runtime" - ], - "coveredPlatforms": [ - "macos" - ], + "surfaces": ["terminal search", "links", "WebGL", "decorations", "keyboard navigation"], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "remote-runtime"], + "coveredPlatforms": ["macos"], "coveredProviders": [], "coverageNotes": "Local macOS evidence over the WebGL/link/search containment suites on main@1282f5c2d, adopting the #6949 atlas-recovery rename and #7133's reveal hardening tests. Core addon-load throw containment and live typed-input survival arrive with #7004 and a live follow-up.", "motivatingLinks": [ @@ -757,34 +1295,18 @@ "xterm scrollbar DOM", "scrollback" ], - "platforms": [ - "macos", - "linux", - "windows" - ], - "providers": [ - "local", - "daemon", - "ssh", - "wsl", - "remote-runtime" - ], - "coveredPlatforms": [ - "macos" - ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"], + "coveredPlatforms": ["macos"], "coveredProviders": [], "coverageNotes": "Renderer-unit coverage proves the shared xterm DOM intent path. Live Electron evidence is PR validation evidence for local macOS only until the flow has stable automation; live SSH, WSL, Linux, and Windows paths remain unproved.", - "motivatingLinks": [ - "STA-1341" - ], + "motivatingLinks": ["STA-1341"], "invariant": "A user-driven xterm scrollbar thumb or track scroll updates the live terminal scroll intent before tab, visibility, or layout resume enforces intent, so resume preserves the latest dragged viewport instead of an older pinned line.", "oracle": "Pointerdown on .xterm-scrollbar or .xterm-slider followed by xterm viewport movement records the new pinned viewport, and enforcing current intent restores that dragged line instead of stale top intent.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts" ], - "testFiles": [ - "src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts" - ], + "testFiles": ["src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts"], "assertionRefs": [ { "file": "src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts", @@ -841,23 +1363,9 @@ "protection": "none", "owner": "startup-persistence", "layer": "upgrade-fixture", - "surfaces": [ - "startup", - "upgrade", - "session restore", - "daemon restore" - ], - "platforms": [ - "macos", - "linux", - "windows" - ], - "providers": [ - "local", - "daemon", - "ssh", - "wsl" - ], + "surfaces": ["startup", "upgrade", "session restore", "daemon restore"], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "wsl"], "coveredPlatforms": [], "coveredProviders": [], "coverageNotes": "Registered gap only; no executable coverage is wired yet.", @@ -893,9 +1401,7 @@ "Run second restart after current code writes upgraded state.", "Record startup timing and failure artifact." ], - "knownGaps": [ - "No fixture corpus or command yet." - ], + "knownGaps": ["No fixture corpus or command yet."], "demotionRule": "Cannot promote without old production fixture provenance." }, { @@ -914,13 +1420,8 @@ "resize", "exit cleanup" ], - "platforms": [ - "linux", - "macos" - ], - "providers": [ - "local" - ], + "platforms": ["linux", "macos"], + "providers": ["local"], "coveredPlatforms": [], "coveredProviders": [], "coverageNotes": "Registered gap on main. The live Electron Playwright slice exists only on the pending reliability stack. It registers here with its owning split PR.", @@ -979,20 +1480,10 @@ "CJK repaint", "cursor and resize" ], - "platforms": [ - "windows" - ], - "providers": [ - "local", - "daemon", - "wsl" - ], - "coveredPlatforms": [ - "windows" - ], - "coveredProviders": [ - "daemon" - ], + "platforms": ["windows"], + "providers": ["local", "daemon", "wsl"], + "coveredPlatforms": ["windows"], + "coveredProviders": ["daemon"], "coverageNotes": "Issue #8048 now has deterministic wrapper and cold-restore re-anchor tests plus a Windows PR-CI harness that drives the built daemon through 25 real ConPTY workspace-close races while an unrelated witness PTY stays alive. Keyboard reset, CJK repaint, WSL, and full visible Electron coverage remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6541", @@ -1090,18 +1581,8 @@ "resize", "render" ], - "platforms": [ - "macos", - "linux", - "windows" - ], - "providers": [ - "local", - "daemon", - "ssh", - "wsl", - "remote-runtime" - ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"], "coveredPlatforms": [], "coveredProviders": [], "coverageNotes": "Registered gap on main. The targeted-hasPty product hardening and no-hot count assertions exist only on the pending reliability stack. It registers here with its owning split PR.", @@ -1157,18 +1638,8 @@ "provider ownership", "diagnostics bundle" ], - "platforms": [ - "macos", - "linux", - "windows" - ], - "providers": [ - "local", - "daemon", - "ssh", - "wsl", - "remote-runtime" - ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"], "coveredPlatforms": [], "coveredProviders": [], "coverageNotes": "Registered gap on main. The crash-breadcrumb recording and its test exist only on the pending reliability stack. It registers here with its owning split PR.", @@ -1228,20 +1699,9 @@ "xterm scheduler", "hidden output" ], - "platforms": [ - "macos", - "linux", - "windows" - ], - "providers": [ - "local", - "daemon", - "ssh", - "remote-runtime" - ], - "coveredPlatforms": [ - "macos" - ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "remote-runtime"], + "coveredPlatforms": ["macos"], "coveredProviders": [], "coverageNotes": "Local macOS evidence covers the existing main-process pending-output caps plus deterministic runtime path-provenance history reuse, idle-path serve-sim detection, and saturated-tail wait detection. Daemon stream write(false)/drain contracts, cross-session drain priority, and bounded queued tails arrive with the pending perf slice; live flood/latency artifacts remain gaps.", "motivatingLinks": [ @@ -1389,15 +1849,8 @@ "provider ownership", "startup restore" ], - "platforms": [ - "macos", - "linux", - "windows" - ], - "providers": [ - "daemon", - "local" - ], + "platforms": ["macos", "linux", "windows"], + "providers": ["daemon", "local"], "coveredPlatforms": [], "coveredProviders": [], "coverageNotes": "Registered gap on main. The fail-closed degraded-daemon hardening and its contracts exist only on the pending reliability stack. It registers here with its owning split PR.", @@ -1454,16 +1907,8 @@ "reattach", "unknown liveness" ], - "platforms": [ - "macos", - "linux", - "windows" - ], - "providers": [ - "ssh", - "remote-runtime", - "wsl" - ], + "platforms": ["macos", "linux", "windows"], + "providers": ["ssh", "remote-runtime", "wsl"], "coveredPlatforms": [], "coveredProviders": [], "coverageNotes": "Registered gap on main. The SSH unknown-liveness hardening and deferred-reattach contracts exist only on the pending reliability stack. It registers here with its owning split PR.", @@ -1525,12 +1970,8 @@ "provider liveness", "restore" ], - "platforms": [ - "windows" - ], - "providers": [ - "wsl" - ], + "platforms": ["windows"], + "providers": ["wsl"], "coveredPlatforms": [], "coveredProviders": [], "coverageNotes": "Registered gap only; no executable coverage is wired yet.", @@ -1588,17 +2029,8 @@ "metadata-only replay", "WebGL recovery" ], - "platforms": [ - "macos", - "linux", - "windows" - ], - "providers": [ - "local", - "daemon", - "ssh", - "remote-runtime" - ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "remote-runtime"], "coveredPlatforms": [], "coveredProviders": [], "coverageNotes": "Registered gap on main. The replay FIFO/burst coalescing product change and its tests exist only on the pending reliability stack; main still uses a single pendingReplayData slot. It registers here with its owning split PR.", @@ -1657,22 +2089,11 @@ "keyboard bypass", "JIS yen" ], - "platforms": [ - "macos", - "linux", - "windows" - ], - "providers": [ - "local", - "daemon", - "ssh", - "remote-runtime" - ], - "coveredPlatforms": [ - "macos" - ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "remote-runtime"], + "coveredPlatforms": ["macos", "linux"], "coveredProviders": [], - "coverageNotes": "Local macOS evidence on main@1282f5c2d, deterministic renderer-unit coverage for the Linux/Sogou candidate-key policy, and a local Electron/CDP live-PTY Sogou-style repro. Real Linux/Sogou OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.", + "coverageNotes": "Local macOS and containerized Linux evidence, deterministic renderer-unit coverage for the Linux/Sogou candidate-key policy including the legacy orphaned-keyup fallback, and Electron/CDP live-PTY Sogou-style repros. Real Linux/Sogou OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6699", "https://github.com/stablyai/orca/pull/6682", @@ -1680,9 +2101,10 @@ "https://github.com/stablyai/orca/pull/6999" ], "invariant": "Composition, native text forwarding, synthetic input, paste, and platform keyboard bypass paths must not send preedit/control bytes before commit and must commit text exactly once to the intended PTY.", - "oracle": "The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures, and Linux/Sogou candidate Space/digit selectors do not leak keydown/keypress/keyup while ordinary post-IME typing remains available. The Electron/CDP live-PTY repro verifies Sogou-style Space and digit candidate selectors submit only the committed Chinese text. Real IME paths and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.", + "oracle": "The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures, and Linux/Sogou candidate Space/digit selectors do not leak keydown/keypress/keyup while ordinary and long-held letter-to-digit typing remains available. The Electron/CDP live-PTY repro verifies Sogou-style Space and digit selectors submit only the committed Chinese text, while the legacy orphaned-letter-keyup sequence sends no selector byte to the PTY. Real legacy IME commit preservation and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts", "pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts" ], "testFiles": [ @@ -1691,6 +2113,7 @@ "src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts", "src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts", "src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts", + "src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts", "src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts", "src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts", "tests/e2e/chinese-ime-chat-input-repro.spec.ts" @@ -1711,9 +2134,7 @@ }, { "file": "src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts", - "assertions": [ - "paste/runtime forwarding avoids duplicate terminal payloads" - ] + "assertions": ["paste/runtime forwarding avoids duplicate terminal payloads"] }, { "file": "src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts", @@ -1731,6 +2152,14 @@ "fresh keydowns, other keys, modified keys, and expired keypresses are not guarded, and a fresh press drops a stale pending release" ] }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts", + "assertions": [ + "an orphaned plain-letter keyup arms exactly the next bare digit guard", + "ordinary, overlapping, shifted, and long-held letter keydowns keep following digits available", + "physical letter tracking survives cross-pane focus handoff and clears on renderer-window blur" + ] + }, { "file": "src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts", "assertions": [ @@ -1749,11 +2178,39 @@ "assertions": [ "Sogou-style Space candidate selection submits only the committed Chinese character", "Sogou-style digit candidate selection submits only the committed Chinese phrase", - "Post-composition Sogou-style digit selection stays out of the PTY after compositionend" + "Post-composition Sogou-style digit selection stays out of the PTY after compositionend", + "Legacy orphaned-letter-keyup digit selection submits no selector byte to the PTY; real legacy candidate commit preservation remains a platform gap" ] } ], "evidenceRuns": [ + { + "date": "2026-07-11", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts", + "result": "passed", + "durationSeconds": 0.619, + "summary": "1 state test file and 10 tests passed on macOS, including orphan-keyup, shifted/modifier-changed releases, intervening-key cancellation, cross-pane focus handoff, window/terminal blur cleanup, and long-held-letter coverage; the complete 8-file slice also passed 158 tests." + }, + { + "date": "2026-07-11", + "runner": "local", + "platform": "linux", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts", + "result": "passed", + "durationSeconds": 0.747, + "summary": "1 state test file and 10 tests passed in Debian 12 arm64 Docker under Node 24, including orphan-keyup, shifted/modifier-changed releases, intervening-key cancellation, cross-pane focus handoff, window/terminal blur cleanup, and long-held-letter coverage; the complete 8-file slice also passed 158 tests." + }, + { + "date": "2026-07-11", + "runner": "local", + "platform": "linux", + "command": "pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts", + "result": "passed", + "durationSeconds": 30.3, + "summary": "Debian 12 arm64 Docker with Node 24 and Xvfb passed both live Electron/PTY IME scenarios; the orphaned-letter-keyup candidate digit submitted no selector byte, and the real-Codex opt-in scenario was skipped." + }, { "date": "2026-07-07", "runner": "local", @@ -1787,7 +2244,7 @@ }, "performanceBudget": { "required": true, - "evidence": "Terminal IME hot-path audit clean on 2026-07-07: candidate guard adds O(1) boolean checks plus a bounded Space/digit release map; composition listeners are disposed with pane lifecycle; no polling, IPC fan-out, subprocess work, or SSH/remote transport cost added." + "evidence": "Terminal IME hot-path audit clean on 2026-07-11: candidate guards add O(1) boolean checks plus bounded per-pane Space/digit state and one renderer-scoped currently pressed physical-letter set; ref-counted renderer and pane blur listeners are disposed with pane lifecycle; no per-pane keyboard-event fan-out, polling, IPC, subprocess work, or SSH/remote transport cost added." }, "promotionCriteria": [ "Cover deterministic byte/cell oracles first.", @@ -1814,14 +2271,8 @@ "TUI exit", "standard key input" ], - "platforms": [ - "windows" - ], - "providers": [ - "local", - "daemon", - "wsl" - ], + "platforms": ["windows"], + "providers": ["local", "daemon", "wsl"], "coveredPlatforms": [], "coveredProviders": [], "coverageNotes": "Registered gap only; no executable coverage is wired yet.", @@ -1878,32 +2329,21 @@ "WSL", "remote runtime" ], - "platforms": [ - "macos", - "linux", - "windows" - ], - "providers": [ - "local", - "daemon", - "ssh", - "wsl", - "remote-runtime" - ], - "coveredPlatforms": [ - "macos" - ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"], + "coveredPlatforms": ["macos"], "coveredProviders": [], - "coverageNotes": "Deterministic main/renderer tests run on macOS and exercise simulated Windows plus POSIX/Linux process-confirmation behavior. They cover exact local-ConPTY membership, detached-child rejection, unchanged SSH/WSL/remote fallback, process-confirmed Droid routing and launch-triggered confirmation, typed-alias and forged-OSC isolation, shells without OSC 133, split/detach ownership with preserved shell classification, stale PTY exit/rebind rejection, command-generation revocation, daemon v21 warm-reattach identity, unavailable inspection, and lazy subprocess/RPC callback counts. The Electron byte test runs only on Windows; live Windows and Linux PTY evidence remains uncollected. Test failures report exact expected bytes; no product telemetry or raw terminal logging is added.", + "coverageNotes": "Deterministic main/renderer tests run on macOS and exercise simulated Windows plus POSIX/Linux process-confirmation behavior. They cover exact local-ConPTY membership, detached-child rejection, SSH and paired-runtime host-platform routing including legacy runtime PTY IDs, active-PTY ownership after worktree host changes, Windows-to-WSL routing, unknown-metadata fallback, production handler composition, KKP authorization on every host, process-confirmed Droid routing and launch-triggered confirmation, typed-alias and forged-OSC isolation, shells without OSC 133, split/detach ownership with preserved shell classification, stale PTY exit/rebind rejection, command-generation revocation, daemon v21 warm-reattach identity, unavailable inspection, and lazy subprocess/RPC callback counts. A live linux-arm64 Docker target additionally proved real relay deployment, SSH PTY host detection, and exact inactive/active KKP bytes. The Electron byte test runs only on Windows; live Windows evidence remains uncollected. Test failures report exact expected bytes; no product telemetry or raw terminal logging is added.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/7620", "https://github.com/stablyai/orca/pull/7668" ], - "invariant": "Shift+Enter sends CSI-u only when the active pane is a genuine local Windows ConPTY with allowlisted Droid ownership or fresh routing-trusted Droid process evidence. Every new command and PTY replacement revokes stale routing until current evidence settles. All other Windows panes preserve Esc+CR; non-Windows panes remain CSI-u; SSH/WSL/remote behavior is unchanged; split or reused panes cannot inherit sibling or prior-PTY authority; unrelated keys perform no agent or ConPTY lookup.", - "oracle": "Main/renderer tests assert exact bytes for local and non-local Windows plus non-Windows policy cells. Fresh Windows process evidence authorizes bytes only after a recognized global candidate is intersected with the exact ConPTY console process list; detached descendants, helper failure/timeout/root-only fallback, stale PTY exit/rebind results, typed text, and hook/OSC output fail closed. A new OSC 133;C immediately publishes a routing-neutral generation, including during D-to-C races. In cmd.exe/Git Bash/custom shells without OSC 133, accepted inferred commands start the same fresh generation; accepted submit/interrupt, title exit, focus, and visibility revoke trusted Droid bytes while one bounded confirmation runs. Detach preserves the live PTY's source shell override so WSL/native classification cannot change with the current default. Ordinary fast shell commands use cached/no-scan paths; routing-relevant confirmation bypasses cached process snapshots and keeps the bounded three-read ladder. Daemon protocol v21 persists only an allowlisted launchAgent; warm reattach uses it as a display/confirmation hint and restores routing only after current process proof. The Windows Electron test focuses the real xterm textarea and records exact renderer-to-main PTY writes.", + "invariant": "Shift+Enter key recognition follows the client OS, while emitted bytes follow the active application and PTY host: active Kitty keyboard protocol authorizes CSI-u on every host; otherwise Windows hosts use Esc+CR except when allowlisted Droid ownership or fresh routing-trusted Droid process evidence authorizes CSI-u, and non-Windows hosts use Esc+CR. The active SSH connection, remote-runtime PTY owner, or live local session outranks later worktree ownership changes, and missing host-platform metadata falls back to the client. Every new command and PTY replacement revokes stale agent routing until current evidence settles; split or reused panes cannot inherit sibling or prior-PTY authority; unrelated keys perform no host, agent, protocol, or ConPTY lookup.", + "oracle": "Renderer tests assert exact bytes across client/PTY-host combinations, including Kitty keyboard protocol active and inactive on Windows and non-Windows hosts, and prove SSH identity, the environment encoded in an active remote-runtime PTY id, and live local-session identity outrank current worktree ownership, while unavailable platform metadata falls back to the client. A live linux-arm64 SSH PTY independently captured Esc+CR with KKP inactive and CSI-u with KKP active. Fresh Windows process evidence authorizes Droid bytes only after a recognized global candidate is intersected with the exact ConPTY console process list; detached descendants, helper failure/timeout/root-only fallback, stale PTY exit/rebind results, typed text, and hook/OSC output fail closed. A new OSC 133;C immediately publishes a routing-neutral generation, including during D-to-C races. In cmd.exe/Git Bash/custom shells without OSC 133, accepted inferred commands start the same fresh generation; accepted submit/interrupt, title exit, focus, and visibility revoke trusted Droid bytes while one bounded confirmation runs. Detach preserves the live PTY's source shell override so WSL/native classification cannot change with the current default. Ordinary fast shell commands use cached/no-scan paths; routing-relevant confirmation bypasses cached process snapshots and keeps the bounded three-read ladder. Daemon protocol v21 persists only an allowlisted launchAgent; warm reattach uses it as a display/confirmation hint and restores routing only after current process proof. The Windows Electron test focuses the real xterm textarea and records exact renderer-to-main PTY writes.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/daemon-server.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts src/main/daemon/pty-subprocess.test.ts src/main/ipc/pty.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts src/renderer/src/store/slices/store-cascades.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/shared/agent-process-recognition.test.ts src/shared/process-table-snapshot.test.ts tests/e2e/terminal-foreground-confirmation.unit.test.ts", - "pnpm run test:e2e -- tests/e2e/terminal-shortcuts.spec.ts" + "pnpm run test:e2e -- tests/e2e/terminal-shortcuts.spec.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-input-host-platform.test.ts" ], "testFiles": [ "src/main/daemon/daemon-foreground-confirmation-protocol.test.ts", @@ -1918,9 +2358,12 @@ "src/main/providers/local-pty-provider.test.ts", "src/main/providers/windows-conpty-process-membership.test.ts", "src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts", + "src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts", "src/renderer/src/components/terminal-pane/pty-connection.test.ts", "src/renderer/src/components/terminal-pane/pty-transport.test.ts", + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts", "src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts", + "src/renderer/src/components/terminal-pane/terminal-input-host-platform.test.ts", "src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts", "src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts", "src/renderer/src/store/slices/store-cascades.test.ts", @@ -1990,12 +2433,33 @@ "one fresh command-finish scan confirms real shell exit, while unavailable inspection performs exactly three bounded reads and never claims shell" ] }, + { + "file": "src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts", + "assertions": [ + "the production keyboard-policy boundary requires active-host routing and composes macOS Shift+Enter with Windows PTY bytes" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts", + "assertions": [ + "a legacy remote PTY id retains the transport's captured runtime environment identity" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-input-host-platform.test.ts", + "assertions": [ + "SSH terminals use remote-platform metadata and execution-host identity when the active transport has not captured a connection id", + "encoded and legacy active remote-runtime PTY identities plus a live local PTY session outrank later worktree host changes", + "a live WSL session normalizes to a Linux terminal host", + "missing SSH/runtime platform metadata and local terminals fall back to the client platform" + ] + }, { "file": "src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts", "assertions": [ "Windows defaults and non-Droid agents preserve Esc+CR while explicit Droid capability selects CSI-u", - "SSH, WSL, and remote Windows-client panes preserve Esc+CR without consulting the Droid capability", - "non-Windows Shift+Enter remains CSI-u and unrelated keys invoke neither agent nor ConPTY callbacks" + "Windows and non-Windows PTYs reached cross-OS use CSI-u only with active Kitty keyboard protocol unless trusted Windows Droid evidence independently authorizes it", + "unrelated keys invoke neither host, agent, nor protocol callbacks" ] }, { @@ -2096,24 +2560,168 @@ }, "performanceBudget": { "required": true, - "evidence": "Callback-count tests prove unrelated ordinary keys invoke neither ConPTY nor agent resolution. Windows Shift+Enter performs two store reads: one local-ConPTY classification with an O(tabs-in-worktree) tab lookup, then two pane-keyed O(1) ownership lookups. Typed text performs no routing write. Ordinary fast OSC 133;C-to-D shell commands perform no fresh confirmation RPC or process scan. An accepted submit/interrupt while trusted Droid is current, a typed allowlisted agent command, title exit, focus, visibility transition, or an idle burst after trusted Droid Shift+Enter can schedule the bounded confirmation ladder; each event cancels/deduplicates prior work and no timer recurs after settling. Windows confirmation performs one fresh process-table scan and forks exact ConPTY membership only after a recognized agent candidate appears; no recognized candidate makes zero helper calls. Same-turn fresh requests share one scan and later arrivals queue at most one successor. Recovery remains three bounded one-shot reads (350 ms, 1.2 s, 6 s). No polling loop, provider fanout, persistent listener, hidden-pane wake loop, or per-printable-key work is added." + "evidence": "Callback-count tests prove unrelated ordinary keys invoke neither host, agent, nor Kitty-protocol resolution. Host and agent callbacks are created once per keyboard effect, so printable input adds no per-key allocations or store reads. Shift+Enter performs bounded synchronous active-transport and indexed store lookups, plus at most one pane-keyed protocol-mode lookup when trusted Windows Droid evidence does not already authorize CSI-u, with no IPC, RPC, subprocess, polling, listener, or timer. Typed text performs no routing write. Ordinary fast OSC 133;C-to-D shell commands perform no fresh confirmation RPC or process scan. An accepted submit/interrupt while trusted Droid is current, a typed allowlisted agent command, title exit, focus, visibility transition, or an idle burst after trusted Droid Shift+Enter can schedule the bounded confirmation ladder; each event cancels/deduplicates prior work and no timer recurs after settling. Windows confirmation performs one fresh process-table scan and forks exact ConPTY membership only after a recognized agent candidate appears; no recognized candidate makes zero helper calls. Same-turn fresh requests share one scan and later arrivals queue at most one successor. Recovery remains three bounded one-shot reads (350 ms, 1.2 s, 6 s). No polling loop, provider fanout, persistent listener, hidden-pane wake loop, or per-printable-key work is added." }, "promotionCriteria": [ "Run the exact PTY-byte Electron oracle on Windows without silent platform skips.", - "Collect stable runtime and flake history for local and daemon Windows ConPTY while retaining deterministic unchanged-provider assertions for SSH, WSL, and remote runtime.", + "Collect stable runtime and flake history for local and daemon Windows ConPTY while retaining deterministic host-routing assertions for SSH, WSL, and remote runtime.", "Attach saved intentional-break evidence for Droid capability removal and split-sibling identity leakage." ], "knownGaps": [ - "No Windows runner or live Linux PTY evidence is attached; the Electron byte test skips on macOS and Linux, while Linux behavior is covered only by deterministic POSIX unit paths.", - "Droid inside SSH, WSL, or remote-runtime intentionally keeps the pre-existing Windows-client Esc+CR behavior because local foreground confirmation is unavailable; this PR narrows the exception to issue #7620's local ConPTY surface.", + "No Windows runner or live Windows PTY evidence is attached; the Electron byte test skips on macOS and Linux, while a live Linux SSH PTY covers only the remote Linux host path.", + "Droid agent-specific CSI-u authorization is available only from trusted pane evidence; without it, Windows and non-Windows PTY hosts depend on the application's Kitty keyboard protocol state.", "The Electron test injects allowlisted pane identity rather than launching the real Droid and Antigravity binaries.", "If both Windows process-enumeration backends remain unavailable through the bounded ladder, routing fails closed to the legacy Esc+CR path; no live Droid artifact covers that degraded host configuration.", "Protocol v21 restarts older daemons because the minimal persisted launchAgent is a new contract; sessions preserved by pre-v21 daemons cannot supply warm-reattach launch ownership and fail closed until current process confirmation or restart.", - "No live daemon, SSH, WSL, remote-runtime, paired-web, or restore/replay byte artifact has been collected.", + "No live daemon, WSL, remote-runtime, paired-web, or restore/replay byte artifact has been collected; SSH is covered only by a macOS-client-to-Linux-host relay run.", "A running PTY keeps its spawn-time ORCA_PANE_KEY after pane-to-tab detach. This change transfers launch/process routing evidence and retires the stale hook snapshot, but does not redesign future hook reattribution across tab IDs." ], "demotionRule": "Keep non-blocking or demote to protection none if the Windows byte test silently skips, flakes without an actionable artifact, routes bytes using tab/title/terminal-output heuristics, or adds any ordinary-key lookup, IPC, polling, scan, or lifecycle leak." }, + { + "id": "terminal-input.guarded-agent-send-authorization", + "title": "Guarded agent sends authorize only the confirmed exact PTY", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-input", + "layer": "runtime-provider-contract", + "surfaces": [ + "terminal input", + "guarded note sends", + "agent status", + "PTY identity", + "foreground process confirmation" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "daemon"], + "coverageNotes": "Runtime and provider-contract tests on macOS cover exact-PTY authorization, local/daemon fresh confirmation, exact ConPTY membership, and unsupported-provider fail-closed behavior. Physical Windows, live Linux, SSH, WSL, and remote-runtime validation remain explicit gaps; providers without confirmation preserve conservative refusal on a shell conflict.", + "motivatingLinks": ["https://github.com/stablyai/orca/issues/8303"], + "invariant": "A guarded note send writes only to the exact PTY binding checked by the guard and only while permission/wait evidence allows input; fresh hook state conflicting with an ordinary shell foreground requires fresh provider confirmation of a recognized agent in that PTY.", + "oracle": "Fresh explicit state plus ordinary PowerShell plus confirmed recognized agent is sendable on the same PTY. Confirmed shell/non-agent, unavailable confirmation, PTY exit, handle rebind, or a callback PTY mismatch returns a refusal or not-writable result and writes zero bytes.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/ipc/pty.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send.test.ts src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx" + ], + "testFiles": [ + "src/main/runtime/orca-runtime.test.ts", + "src/main/runtime/rpc/terminal-send.test.ts", + "src/main/ipc/pty.test.ts", + "src/main/providers/agent-foreground-process.test.ts", + "src/main/providers/local-pty-provider.test.ts", + "src/main/providers/windows-conpty-process-membership.test.ts", + "src/main/daemon/daemon-foreground-confirmation-protocol.test.ts", + "src/main/daemon/pty-subprocess.test.ts", + "src/renderer/src/lib/active-agent-note-send.test.ts", + "src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx" + ], + "assertionRefs": [ + { + "file": "src/main/runtime/orca-runtime.test.ts", + "assertions": [ + "a recognized fresh provider confirmation authorizes explicit hook state over an ordinary shell result", + "shell, non-agent, null, thrown, and missing confirmation remain fail-closed", + "title blockers and ordinary recognized agent evidence add no confirmation call", + "PTY exit or handle rebind during either foreground read rejects evidence bound to the original PTY", + "controller-less yields preserve stale-handle errors and confirmation methods retain their controller receiver" + ] + }, + { + "file": "src/main/runtime/rpc/terminal-send.test.ts", + "assertions": [ + "a handle rebind during guarded status evaluation returns not writable and writes zero bytes", + "the callback's actual PTY must equal the handle binding before status can authorize a write" + ] + }, + { + "file": "src/main/ipc/pty.test.ts", + "assertions": [ + "runtime confirmation reaches the provider owning the captured PTY", + "unsupported or missing providers return unavailable confirmation" + ] + }, + { + "file": "src/main/providers/agent-foreground-process.test.ts", + "assertions": [ + "recognized Windows candidates require exact ConPTY membership and detached descendants are rejected", + "unavailable process inspection is distinct from authoritative shell evidence" + ] + }, + { + "file": "src/main/providers/local-pty-provider.test.ts", + "assertions": ["fresh confirmation is discarded when its owning local PTY exits"] + }, + { + "file": "src/main/providers/windows-conpty-process-membership.test.ts", + "assertions": [ + "exact ConPTY console membership comes from the fixed node-pty helper", + "malformed, incomplete, timed-out, and spawn-error membership reads fail closed" + ] + }, + { + "file": "src/main/daemon/daemon-foreground-confirmation-protocol.test.ts", + "assertions": [ + "daemons from before the fresh-confirmation RPC are rejected by protocol version" + ] + }, + { + "file": "src/main/daemon/pty-subprocess.test.ts", + "assertions": [ + "fresh confirmation bypasses cached PowerShell and waits for a post-request process scan" + ] + }, + { + "file": "src/renderer/src/lib/active-agent-note-send.test.ts", + "assertions": ["selected active-agent note sends retain guarded paste and submit routing"] + }, + { + "file": "src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx", + "assertions": [ + "browser annotation send content routes through review-notes send so existing agent sessions remain selectable", + "both browser annotation send surfaces wire the shared menu content" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-11", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/ipc/pty.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send.test.ts src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx", + "result": "passed", + "durationSeconds": 9.58, + "summary": "Ten focused test files passed (1183 tests), covering runtime confirmation and PTY revalidation, guarded RPC zero-write behavior, PTY controller routing, local/daemon fresh scans, exact ConPTY membership, and unchanged renderer note routing." + } + ], + "runtimeBudget": { + "p95Seconds": 45, + "scope": "focused runtime, RPC, PTY provider, and renderer routing units" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Deterministic units have local coverage only; promotion requires repeated CI and platform history." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Removing strong confirmation fails the shell-conflict success oracle, while removing either exact-binding comparison fails zero-write rebind coverage; saved intentional-break and physical Windows evidence remain missing." + }, + "performanceBudget": { + "required": true, + "evidence": "Confirmation is invoked at most once per status evaluation and only for fresh explicit hook state whose ordinary foreground result is a shell. Count tests prove permission/title blockers and ordinary recognized-agent evidence add zero confirmations; no polling, retry, session listing, provider fanout, or runtime-global cache is added, and existing provider snapshot dedup remains authoritative." + }, + "promotionCriteria": [ + "Run the browser annotation existing-agent and repeat-send path in Electron on Windows ConPTY without a recognition refusal.", + "Collect stable CI and flake history across local and daemon providers on Windows plus representative macOS/Linux coverage.", + "Attach saved intentional-break evidence for confirmation removal and exact-PTY revalidation removal." + ], + "knownGaps": [ + "Physical Windows validation is unavailable on the current macOS host; exact ConPTY membership is covered deterministically.", + "Electron annotation golden-path, repeat-send, adjacent-menu evidence, and screenshots are left to coordinator validation.", + "SSH, WSL, legacy daemon, and remote-runtime providers without confirmation remain intentionally fail-closed on an ordinary-shell conflict; no live artifacts cover those degraded paths.", + "No live Linux PTY, paired-web, mobile/relay, restore/replay, or multi-window artifact is attached; those surfaces receive no renderer, persistence, or protocol change." + ], + "demotionRule": "Keep non-blocking or demote to protection none if provider confirmation becomes unconditional, exact-PTY mismatch can write bytes, unsupported providers fail open, or the focused gate flakes without an actionable product or harness defect." + }, { "id": "terminal-input.agent-prompt-injection", "title": "Orchestration agent prompts arrive as bracketed paste before submit", @@ -2128,27 +2736,12 @@ "PTY writes", "bracketed paste" ], - "platforms": [ - "macos", - "linux", - "windows" - ], - "providers": [ - "local", - "daemon", - "ssh", - "remote-runtime" - ], - "coveredPlatforms": [ - "macos" - ], - "coveredProviders": [ - "local" - ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local"], "coverageNotes": "Local macOS evidence covers the runtime PTY write contract and a live dev-runtime CLI repro. SSH, daemon, remote-runtime, Linux, and Windows remain provider/platform gaps; the product path stays provider-owned and does not add local filesystem or process assumptions.", - "motivatingLinks": [ - "https://github.com/stablyai/orca/issues/7226" - ], + "motivatingLinks": ["https://github.com/stablyai/orca/issues/7226"], "invariant": "Injected orchestration task prompts for recognized agent CLIs must send the prompt body inside one bracketed-paste frame, sanitize embedded ESC bytes, preserve chunk boundaries without losing the frame, and send Enter only after the paste frame completes.", "oracle": "Runtime tests assert the exact PTY write sequence and failure cleanup; orchestration tests assert dispatch/coordinator use the agent prompt path; the live CLI harness dispatches a 32KB task to a fake Codex-like TUI and requires marker present, bracketed paste present, zero unframed line breaks, and submit observed.", "commands": [ @@ -2267,14 +2860,8 @@ "cursor repaint", "rewrite output" ], - "platforms": [ - "windows" - ], - "providers": [ - "local", - "daemon", - "wsl" - ], + "platforms": ["windows"], + "providers": ["local", "daemon", "wsl"], "coveredPlatforms": [], "coveredProviders": [], "coverageNotes": "Registered gap only; no executable coverage is wired yet.", @@ -2310,10 +2897,7 @@ "Keep stress cases non-blocking until Windows runtime history is stable.", "Fail promotion on silent Windows environment skips." ], - "knownGaps": [ - "No manifest command yet.", - "No Windows CJK/emoji repaint command is wired." - ], + "knownGaps": ["No manifest command yet.", "No Windows CJK/emoji repaint command is wired."], "demotionRule": "Cannot promote if the oracle is screenshot-only or environment-skipped." }, { @@ -2331,14 +2915,8 @@ "local provider", "daemon provider" ], - "platforms": [ - "windows" - ], - "providers": [ - "local", - "daemon", - "wsl" - ], + "platforms": ["windows"], + "providers": ["local", "daemon", "wsl"], "coveredPlatforms": [], "coveredProviders": [], "coverageNotes": "Registered gap only; no executable coverage is wired yet.", @@ -2393,17 +2971,8 @@ "renderer CPU", "resize churn" ], - "platforms": [ - "macos", - "linux", - "windows" - ], - "providers": [ - "local", - "daemon", - "ssh", - "remote-runtime" - ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "remote-runtime"], "coveredPlatforms": [], "coveredProviders": [], "coverageNotes": "Registered gap only; no executable coverage is wired yet.", @@ -2451,21 +3020,9 @@ "protection": "none", "owner": "terminal-performance", "layer": "daemon-provider-contract", - "surfaces": [ - "daemon stream", - "socket write", - "drain", - "hidden output", - "input starvation" - ], - "platforms": [ - "macos", - "linux", - "windows" - ], - "providers": [ - "daemon" - ], + "surfaces": ["daemon stream", "socket write", "drain", "hidden output", "input starvation"], + "platforms": ["macos", "linux", "windows"], + "providers": ["daemon"], "coveredPlatforms": [], "coveredProviders": [], "coverageNotes": "Registered gap on main. The daemon batcher write(false)/drain contracts exist only on the pending reliability stack. It registers here with its owning split PR.", @@ -2523,23 +3080,12 @@ "workspace switch", "agent status" ], - "platforms": [ - "macos", - "linux", - "windows" - ], - "providers": [ - "local", - "daemon", - "ssh", - "remote-runtime" - ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "remote-runtime"], "coveredPlatforms": [], "coveredProviders": [], "coverageNotes": "Registered gap on main. The boot-hydration counters and their tests exist only on the pending reliability stack. It registers here with its owning split PR.", - "motivatingLinks": [ - "https://github.com/stablyai/orca/pull/7002" - ], + "motivatingLinks": ["https://github.com/stablyai/orca/pull/7002"], "invariant": "Terminal typing, focus, resize, tab/workspace switch, and agent/session restore must not trigger unbounded store projection, git status, provider listing, or per-pane polling work.", "oracle": "The current executable slice instruments boot-time local PTY registry hydration with repo counts, local-vs-remote repo skips, worktree enumeration counts, adapter/session listing counts, registration/skipped-session counts, duration, and failure phase. The broader oracle still needs instrumentation that counts store selector recomputes, git status requests, provider listings, and session scans during scripted hot interactions with many worktrees and terminal panes.", "commands": [], @@ -2588,20 +3134,9 @@ "metadata-only replay", "clear semantics" ], - "platforms": [ - "macos", - "linux", - "windows" - ], - "providers": [ - "local", - "daemon", - "ssh", - "remote-runtime" - ], - "coveredPlatforms": [ - "macos" - ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "remote-runtime"], + "coveredPlatforms": ["macos"], "coveredProviders": [], "coverageNotes": "Local macOS evidence over the merged #7133/#7173 restore and hidden-output ordering tests on main@1282f5c2d. The dirty-state exactness contract, normal-buffer clear semantics, and metadata-only replay remain pending-stack work.", "motivatingLinks": [ @@ -2616,9 +3151,7 @@ "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts" ], - "testFiles": [ - "src/renderer/src/components/terminal-pane/pty-connection.test.ts" - ], + "testFiles": ["src/renderer/src/components/terminal-pane/pty-connection.test.ts"], "assertionRefs": [ { "file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts", @@ -2686,23 +3219,12 @@ "renderer stream", "terminal colors" ], - "platforms": [ - "macos", - "linux", - "windows" - ], - "providers": [ - "local", - "daemon", - "ssh", - "remote-runtime" - ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "remote-runtime"], "coveredPlatforms": [], "coveredProviders": [], "coverageNotes": "Registered gap only; no executable coverage is wired yet.", - "motivatingLinks": [ - "https://github.com/stablyai/orca/pull/6949" - ], + "motivatingLinks": ["https://github.com/stablyai/orca/pull/6949"], "invariant": "Startup OSC 10/11 color queries are answered out of band at startup only, never leak into shell/provider output streams, and ordinary runtime OSC color queries remain renderer-handled.", "oracle": "A provider-contract fixture records startup query replies, shell-visible bytes, renderer-visible bytes, and later runtime OSC behavior to prove no query leakage or color deadlock.", "commands": [], @@ -2730,10 +3252,7 @@ "Cover startup-only and runtime OSC paths separately.", "Keep screenshot evidence diagnostic only." ], - "knownGaps": [ - "No manifest command yet.", - "No startup color-query contract is wired." - ], + "knownGaps": ["No manifest command yet.", "No startup color-query contract is wired."], "demotionRule": "Cannot promote if success is based only on absence of visible artifacts." }, { @@ -2750,21 +3269,9 @@ "mobile subscription replay", "multi-mobile input floor" ], - "platforms": [ - "macos", - "linux", - "windows", - "mobile" - ], - "providers": [ - "local", - "daemon", - "ssh", - "remote-runtime" - ], - "coveredPlatforms": [ - "macos" - ], + "platforms": ["macos", "linux", "windows", "mobile"], + "providers": ["local", "daemon", "ssh", "remote-runtime"], + "coveredPlatforms": ["macos"], "coveredProviders": [], "coverageNotes": "Deterministic local tests execute the exact injected mobile replay/generation gate, the React Native query classifier, stale-subscription sender, server-side single-responder election, query-reply RPC semantics, and live-output capture during async mobile fit. The provider write path is shared, but no live iOS/Android, SSH, WSL, or multi-device run is registered yet.", "motivatingLinks": [ @@ -2895,21 +3402,9 @@ "JSON subscribe fallback", "snapshot buffering" ], - "platforms": [ - "macos", - "linux", - "windows", - "mobile" - ], - "providers": [ - "remote-runtime", - "ssh", - "local", - "daemon" - ], - "coveredPlatforms": [ - "macos" - ], + "platforms": ["macos", "linux", "windows", "mobile"], + "providers": ["remote-runtime", "ssh", "local", "daemon"], + "coveredPlatforms": ["macos"], "coveredProviders": [], "coverageNotes": "Local macOS evidence over the runtime-RPC stream budgets on main@1282f5c2d. The pending stack adds byte-exact 512KB/2MB/256KB/48KB budget assertions; legacy JSON subscribe parity remains undecided.", "motivatingLinks": [ @@ -3005,20 +3500,9 @@ "unicode width", "parser configuration" ], - "platforms": [ - "macos", - "linux", - "windows" - ], - "providers": [ - "local", - "daemon", - "ssh", - "remote-runtime" - ], - "coveredPlatforms": [ - "macos" - ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "remote-runtime"], + "coveredPlatforms": ["macos"], "coveredProviders": [], "coverageNotes": "Local macOS evidence over #7148's width-parity oracle on main@1282f5c2d. A broader byte corpus, a shared parser-construction assertion over terminal-unicode-provider.ts/pane-terminal-options.ts, and recorded agent-session corpora remain gaps.", "motivatingLinks": [ @@ -3030,9 +3514,7 @@ "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/headless-emulator-unicode-width.test.ts" ], - "testFiles": [ - "src/main/daemon/headless-emulator-unicode-width.test.ts" - ], + "testFiles": ["src/main/daemon/headless-emulator-unicode-width.test.ts"], "assertionRefs": [ { "file": "src/main/daemon/headless-emulator-unicode-width.test.ts", @@ -3089,23 +3571,9 @@ "protection": "none", "owner": "terminal-rendering", "layer": "renderer-observability", - "surfaces": [ - "hidden-output restore", - "snapshot replay", - "anomaly breadcrumbs", - "telemetry" - ], - "platforms": [ - "macos", - "linux", - "windows" - ], - "providers": [ - "local", - "daemon", - "ssh", - "remote-runtime" - ], + "surfaces": ["hidden-output restore", "snapshot replay", "anomaly breadcrumbs", "telemetry"], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "remote-runtime"], "coveredPlatforms": [], "coveredProviders": [], "coverageNotes": "Registered gap only; the anomaly-breadcrumb machinery exists in this branch but no convergence probe is implemented.", @@ -3162,15 +3630,8 @@ "window wake", "render model" ], - "platforms": [ - "macos", - "linux", - "windows" - ], - "providers": [ - "local", - "daemon" - ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon"], "coveredPlatforms": [], "coveredProviders": [], "coverageNotes": "Registered gap only; the live repro harness with a refresh-repair oracle exists but is not productized into the release-blocking terminal-rendering-golden suite.", @@ -3224,18 +3685,9 @@ "renderer reload / crash recovery", "surviving daemon and local PTYs" ], - "platforms": [ - "macos", - "linux", - "windows" - ], - "providers": [ - "local", - "daemon" - ], - "coveredPlatforms": [ - "macos" - ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon"], + "coveredPlatforms": ["macos"], "coveredProviders": [], "coverageNotes": "Local macOS unit evidence over the shared main-process delivery pipeline in registerPtyHandlers. The oracle drives a local mock PTY, but the in-flight/pending accounting is provider-agnostic and daemon PTYs ride the identical pipeline (the manual dev repro that proved the freeze was daemon-backed). SSH is a separate relay/credit path and never enters these counters; mobile/relay ride unaffected paths; WSL terminals ride this same local/daemon pipeline and are covered by the same accounting (no dedicated provider-contract test yet). Motivated by a production incident on v1.4.131 (three frozen/broken terminal panes in one desktop session) diagnosed to leaked in-flight/pending accounting across a renderer reload (rendererGraphEpoch 3). Live validation of the fix on a rebuilt dev instance surfaced a second leak mode (the boot window): after the reset ran, main resumed flushing a flooding PTY into the still-booting page before its pty:data listener re-registered, so those sends were dropped yet counted and re-pinned rendererInFlightChars at 524288. The gate now also covers holding sends until the renderer's pty:rendererDispatcherReady handshake. A watchdog self-heals a lost handshake: on each reset a one-shot ~10s timer arms and, if the handshake never arrives, force-opens the gate (rendererDispatcherReadyForcedCount increments) so a dropped handshake degrades to pre-handshake behavior instead of a permanent hold; the real handshake or a re-registration cancels it. Two new diagnostics — rendererPtyDispatcherReady and rendererDispatcherReadyForcedCount — expose the otherwise-invisible boot-window hold, which early-returns before ackGatedFlushSkipCount increments.", "motivatingLinks": [ @@ -3322,31 +3774,80 @@ "protection": "partial", "owner": "terminal-runtime", "layer": "dependency-contract", - "surfaces": [ - "terminal shutdown", - "Windows ConPTY", - "SSH relay", - "renderer teardown" - ], + "surfaces": ["terminal shutdown", "Windows ConPTY", "SSH relay", "renderer teardown"], "platforms": ["macos", "windows"], - "providers": ["ssh", "remote-runtime"], + "providers": ["local", "daemon", "ssh", "remote-runtime"], "coveredPlatforms": ["macos"], - "coveredProviders": ["ssh"], - "coverageNotes": "A macOS unit harness loads the installed patched node-pty WindowsTerminal prototype without constructing native state, proving pre-ready kill/destroy dispatch immediately. A relay artifact harness reproduces vanilla npm node-pty retaining one deferred kill with zero native close calls, then proves both Windows relay artifacts hash, ship, apply, and idempotently verify the fix. Relay tests simulate win32 shutdown, one-shot ownership, and retry after native failure. The same relay contract serves paired web/mobile clients; no live Windows ConPTY or Windows-over-SSH run is recorded yet.", + "coveredProviders": ["local", "ssh"], + "coverageNotes": "A macOS unit harness loads the installed patched node-pty WindowsTerminal prototype without constructing native state, proving pre-ready kill/destroy dispatch immediately. A relay artifact harness reproduces vanilla npm node-pty retaining one deferred kill with zero native close calls, then proves both Windows relay artifacts hash, ship, apply, include the local ConPTY console-list fallback, and idempotently verify the fix. Relay tests simulate win32 shutdown, persistent single-timer stale-spawn and graceful fallback ownership, retry after native failure without deleting the only handle before close is proven, and accept OS process-death proof even when native close throws without onExit. Relay boot UUIDs scope recycled pty-N counters through initial spawn, durable-lease persistence, reattach, and SSH target-id migration; mismatched generations expire before raw attach and release their exact in-memory retry owner, while pane identity protects legacy leases. Main-process retry owners persist local daemon and remote-runtime teardown intent across renderer and app restarts, coalesce by exact provider identity, use one shared one-shot timer per owner, and fence SSH relay plus remote-runtime generation reuse before close. Local and daemon providers retain pane/tab identity and reject stale teardown before native or daemon kill; daemon native failures propagate and accepted kills remain listed until onExit or conservative OS proof. Protocol v22 carries pane identity across process boundaries, v23 carries immutable daemon-session generations through create/list/size/exit, and admission revisions protect preserved legacy daemons while replacement creation is in flight. Lease-less SSH retry owners survive transport replacement and re-drive the replacement provider. Local Windows shutdown retains provider records and listeners after native throw unless onExit independently proves death. A disconnected SSH provider rejects shutdown without tombstoning its attachable lease. Paired/mobile headless close refuses to depersist a terminal until the controller accepts durable retry ownership. Project removal keeps local or remote ownership until provider-verified shutdown and successful VM cleanup; runtime repo removal drains pre-spawn trust/team/command preparation plus create-first worktree creation, headless, renderer-backed mobile, renderer split, and selector-less renderer terminal admissions before fresh ownership/liveness snapshots, stop, and metadata mutation. Desktop and duplicate-host repo removal route through the same transaction; unresolved terminal creation may continue for an unrelated host while the removed host remains fenced. Legacy worktree creation, renderer PTY spawn, and every local/SSH/folder worktree removal outcome—including Windows stale registration and orphan-compatible fallback—are fenced and verified before destructive mutation or metadata purge. Hidden local/SSH and remote-runtime background-launch cleanup retain exact PTY/handle retry ownership with bounded one-shot scheduling. Crash-durable teardown intent uses an O(1) revisioned append journal with exact SSH set/remove records, compacted to one checkpoint after a successful full-state save, instead of synchronously serializing every retained owner for each mutation. Targeted terminal.stop probes skip unrelated sessions. The same relay contract serves paired web/mobile clients; no live Windows ConPTY or Windows-over-SSH run is recorded yet.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/8260", "https://github.com/stablyai/orca/pull/8279" ], - "invariant": "A Windows relay PTY closed before its first output executes exactly one no-argument node-pty shutdown immediately; Orca does not delete ownership for a native kill failure, and a later retry can still target the same managed PTY.", - "oracle": "Load the installed patched WindowsTerminal prototype in a pre-ready state and assert kill and destroy each close the terminal and native agent once with zero deferred work, while signal arguments still throw. Build both Windows SSH relay artifacts, reproduce vanilla npm node-pty with close=0/native-kill=0/deferred=1, apply the shipped patch, and assert close=1/native-kill=1/deferred=0 plus an artifact version derived from the patch bytes. Assert remote npm install executes patch enforcement before its runtime probe and never finalizes a failed patch. In the relay, assert immediate, graceful, fallback, and handler-dispose paths issue at most one no-argument kill per request; injected native failures retain one managed PTY, and a successful retry clears it.", + "invariant": "A Windows PTY closed before its first output executes exactly one no-argument node-pty shutdown immediately; Orca never deletes ownership without accepted native shutdown plus physical exit proof or an independently observed exit, delayed OS death remains reapable without reissuing the accepted close, and a later retry can target only the same provider generation and pane while project removal fences the removed host without blocking unrelated hosts.", + "oracle": "Load the installed patched WindowsTerminal prototype in a pre-ready state and assert kill and destroy each close the terminal and native agent once with zero deferred work, while signal arguments still throw. Build both Windows SSH relay artifacts, reproduce vanilla npm node-pty with close=0/native-kill=0/deferred=1, apply the shipped patch, and assert close=1/native-kill=1/deferred=0, AttachConsole failure falls back to the shell pid, and the artifact version derives from all patch bytes. Assert remote npm install executes patch enforcement before its runtime probe and never finalizes a failed patch. In local and relay providers, assert native throws retain exact ownership/listeners and retry succeeds, while a synchronous onExit is accepted as independent death proof; provider absence must retain the SSH lease and reject shutdown. Persist relay generation with every durable lease, expire mismatches before attach while releasing their exact retry owner, and preserve generation through target migration. Reject local daemon and remote-runtime teardown, reload the durable store, wait for the startup provider before replay, replace an in-flight provider, and prove the same caller re-drives the replacement before settling, stronger coalesced shutdown options persist, and capped retries drain exact identities with zero final retry timers; persisted close intent for an already-removed runtime must drain without an unhandled rejection or retry wake loop. A generationless SSH id must not mutate or tombstone a generation-qualified replacement lease. Paired/mobile headless close must preserve persisted UI state when controller ownership is rejected. Create and clear 65 local plus 65 remote-runtime owners, reload immediately at both boundaries, and assert 260 O(1) journal records stay below 100 KiB with a 1 KiB per-record cap; repeat the same byte and record bounds for 50 generation-qualified SSH owners. Synchronous full-store flush count must remain zero, both a partial JSON tail and a complete record missing its newline must preserve all complete records, the next mutation must first rewrite an atomic recovered checkpoint, and the next full-state save must compact the journal to one revision-fenced checkpoint; an older valid sidecar must lose to a newer full-store fallback revision. Replace SSH relay and remote-runtime generations while a retry is retained and prove the new owner is never closed. Start pre-spawn trust/team preparation plus worktree and terminal creation surfaces before whole-project and host-scoped project removal and assert removal performs no ownership/liveness snapshot until they publish; host-scoped liveness must query only its selected provider so an unrelated SSH outage cannot veto removal. Removal-first and failed/concurrent admissions must reject or release without deadlock. Start a real worktree removal, pause its preflight, and assert a late renderer terminal admission is rejected; assert every successful registered, orphaned, leftover, already-missing, and stale-registration removal path verifies shutdown before mutation or metadata purge; assert desktop repo removal, worktree create/remove, and PTY spawn handlers all enter their runtime-owned transactions before mutation or provider work.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts config/scripts/build-relay-windows-node-pty-patch.test.mjs src/main/ssh/ssh-relay-native-deps-install.test.ts src/relay/node-pty-windows-pre-ready-shutdown.test.ts src/relay/pty-handler.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts config/scripts/build-relay-windows-node-pty-patch.test.mjs src/main/ssh/ssh-relay-native-deps-install.test.ts src/relay/node-pty-windows-pre-ready-shutdown.test.ts src/relay/pty-handler.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts src/renderer/src/store/slices/repos.test.ts src/renderer/src/store/slices/repos-runtime-vm-removal.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/shared/ssh-pty-id.test.ts src/shared/ssh-types.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/persistence.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-relay-generation-reattach.test.ts src/main/ssh/ssh-target-id-migration.test.ts src/relay/pty-handler.test.ts src/relay/subprocess.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts src/renderer/src/lib/pty-kill-retry-ownership.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts src/renderer/src/store/slices/repos.test.ts src/main/runtime/orca-runtime.test.ts src/main/ipc/pty.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-create.test.ts src/main/window/attach-main-window-services.test.ts src/main/ipc/worktrees.test.ts src/main/ipc/worktrees-windows.test.ts src/main/ipc/pty.test.ts src/main/runtime/orca-runtime.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/shared/ssh-pty-id.test.ts src/shared/ssh-types.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/persistence.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-relay-generation-reattach.test.ts src/main/ssh/ssh-target-id-migration.test.ts src/relay/pty-handler.test.ts src/relay/subprocess.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts src/renderer/src/lib/pty-kill-retry-ownership.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts src/renderer/src/lib/launch-agent-background-session.test.ts src/renderer/src/store/slices/repos.test.ts src/renderer/src/store/slices/repos-host-identity-routing.test.ts src/main/runtime/orca-runtime.test.ts src/main/ipc/pty.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-create.test.ts src/main/window/attach-main-window-services.test.ts src/main/ipc/worktrees.test.ts src/main/ipc/worktrees-windows.test.ts src/main/ipc/pty.test.ts src/main/runtime/orca-runtime.test.ts src/renderer/src/lib/launch-agent-background-session.test.ts src/renderer/src/store/slices/repos-host-identity-routing.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/shared/ssh-pty-id.test.ts src/shared/ssh-types.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/persistence.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-relay-generation-reattach.test.ts src/main/ssh/ssh-target-id-migration.test.ts src/relay/pty-handler.test.ts src/relay/subprocess.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts src/renderer/src/lib/pty-kill-retry-ownership.test.ts src/renderer/src/lib/runtime-terminal-close-retry-ownership.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts src/renderer/src/lib/launch-agent-background-session.test.ts src/renderer/src/store/slices/repos.test.ts src/renderer/src/store/slices/repos-host-identity-routing.test.ts src/main/runtime/orca-runtime.test.ts src/main/ipc/pty.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-create.test.ts src/main/window/attach-main-window-services.test.ts src/main/ipc/worktrees.test.ts src/main/ipc/worktrees-windows.test.ts src/main/ipc/pty.test.ts src/main/runtime/orca-runtime.test.ts src/renderer/src/lib/launch-agent-background-session.test.ts src/renderer/src/lib/runtime-terminal-close-retry-ownership.test.ts src/renderer/src/store/slices/repos-host-identity-routing.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/ipc/worktrees.test.ts src/main/ipc/pty.test.ts src/main/ipc/runtime-environments.test.ts src/main/runtime/rpc/terminal-close-generation.test.ts src/main/persistence.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ipc/ssh.test.ts src/renderer/src/store/slices/worktrees.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/session.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/daemon/daemon-server.test.ts src/main/ipc/pty.test.ts src/main/ipc/runtime-terminal-close-retry-owner.test.ts src/main/runtime/orca-runtime.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/session.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/daemon/daemon-server.test.ts src/main/ipc/pty.test.ts src/main/ipc/runtime-terminal-close-retry-owner.test.ts src/main/runtime/orca-runtime.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/session.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/daemon/daemon-server.test.ts src/main/daemon/daemon-session-exit-fence.test.ts src/main/ipc/pty.test.ts src/main/ipc/runtime-terminal-close-retry-owner.test.ts src/main/runtime/orca-runtime.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-session-exit-fence.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/local-pty-process-exit-proof.test.ts src/main/runtime/worktree-teardown.test.ts" ], "testFiles": [ "config/scripts/build-relay-windows-node-pty-patch.test.mjs", "src/main/ssh/ssh-relay-native-deps-install.test.ts", "src/relay/node-pty-windows-pre-ready-shutdown.test.ts", - "src/relay/pty-handler.test.ts" + "src/relay/pty-handler.test.ts", + "src/renderer/src/components/terminal-pane/pty-transport.test.ts", + "src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts", + "src/renderer/src/store/slices/repos.test.ts", + "src/renderer/src/store/slices/repos-runtime-vm-removal.test.ts", + "src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts", + "src/main/runtime/orca-runtime.test.ts", + "src/shared/ssh-pty-id.test.ts", + "src/shared/ssh-types.test.ts", + "src/main/providers/local-pty-provider.test.ts", + "src/main/providers/local-pty-process-exit-proof.test.ts", + "src/main/daemon/daemon-pty-adapter.test.ts", + "src/main/daemon/daemon-session-exit-fence.test.ts", + "src/main/daemon/daemon-pty-router.test.ts", + "src/main/daemon/degraded-daemon-pty-provider.test.ts", + "src/main/daemon/session.test.ts", + "src/main/daemon/pty-subprocess.test.ts", + "src/main/daemon/terminal-host.test.ts", + "src/main/daemon/terminal-host-session-reaping-leak.test.ts", + "src/main/daemon/daemon-server.test.ts", + "src/main/providers/ssh-pty-provider.test.ts", + "src/main/persistence.test.ts", + "src/main/ssh/ssh-relay-session.test.ts", + "src/main/ssh/ssh-relay-generation-reattach.test.ts", + "src/main/ssh/ssh-target-id-migration.test.ts", + "src/relay/subprocess.test.ts", + "src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts", + "src/renderer/src/lib/pty-kill-retry-ownership.test.ts", + "src/main/ipc/pty.test.ts", + "src/main/ipc/runtime-terminal-close-retry-owner.test.ts", + "src/main/ipc/repos-create.test.ts", + "src/main/ipc/worktrees.test.ts", + "src/main/ipc/worktrees-windows.test.ts", + "src/main/window/attach-main-window-services.test.ts", + "src/renderer/src/lib/launch-agent-background-session.test.ts", + "src/renderer/src/lib/runtime-terminal-close-retry-ownership.test.ts", + "src/renderer/src/store/slices/repos-host-identity-routing.test.ts", + "src/renderer/src/store/slices/worktrees.test.ts", + "src/main/ipc/runtime-environments.test.ts", + "src/main/runtime/rpc/terminal-close-generation.test.ts", + "src/main/ipc/ssh.test.ts", + "src/main/runtime/worktree-teardown.test.ts" ], "assertionRefs": [ { @@ -3354,6 +3855,7 @@ "assertions": [ "both Windows artifacts contain the strict patcher and derive their version hash from its bytes", "vanilla npm node-pty defers pre-ready kill, while the shipped patch executes one native close immediately and clears deferred work", + "the shipped relay patch preserves the local ConPTY console-list shell-pid fallback", "patch application and dependency verification are idempotent" ] }, @@ -3374,24 +3876,690 @@ { "file": "src/relay/pty-handler.test.ts", "assertions": [ - "Windows immediate, graceful/fallback, and relay-dispose shutdown issue one no-argument kill", - "a native kill failure retains one managed PTY and a later successful retry clears it" + "every Windows shutdown attempt stays signal-free, retries native throws, and never reissues an accepted one-shot close", + "stale spawn native failures retain one managed PTY and one retry timer beyond the eighth failure", + "graceful fallback throws stay contained and re-arm instead of reaching relay-global uncaughtException", + "native close throws followed by OS process-death proof reap immediate and stale-spawn fallback owners without another timer", + "whole-relay shutdown retries native failure and waits for exit proof before clearing ownership", + "cold concurrent spawn requests share one native-addon import instead of failing nondeterministically", + "graceful fallback and whole-relay native throws accept OS death proof with zero retained timers", + "51 concurrent spawn requests produce exactly 50 live PTYs and one cap rejection", + "explicit shutdown retains one managed PTY until onExit or OS liveness proves the child is gone", + "inventory reaps delayed OS death after an accepted close without reissuing native kill" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/pty-transport.test.ts", + "assertions": [ + "a rejected teardown kill forwards exact PTY identity to main and later renderer lifecycle does not duplicate the retry", + "a rejected generation-qualified daemon admission clears its same-id pre-handler bytes and exit before a later successful spawn" + ] + }, + { + "file": "src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts", + "assertions": [ + "project removal preserves PTY lookup ownership after provider rejection and purges only after retry succeeds" + ] + }, + { + "file": "src/renderer/src/store/slices/repos.test.ts", + "assertions": [ + "remote-runtime project removal retains terminal ownership after stop rejection and purges after retry succeeds" + ] + }, + { + "file": "src/renderer/src/store/slices/repos-runtime-vm-removal.test.ts", + "assertions": [ + "runtime-owned SSH project removal retains the project when VM cleanup fails" + ] + }, + { + "file": "src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts", + "assertions": [ + "project-removal cleanup propagates VM teardown failure instead of orphaning a hidden runtime" + ] + }, + { + "file": "src/main/runtime/orca-runtime.test.ts", + "assertions": [ + "terminal.stop awaits stopAndWait and fresh post-stop liveness before acknowledging provider shutdown", + "remote terminal.close rejects until provider stopAndWait verifies shutdown", + "provider shutdown rejection fails terminal.stop closed", + "project removal fences paired terminal admission through provider stop and repo mutation", + "project removal drains create-first headless, renderer-backed mobile, renderer split, and selector-less renderer admissions before its liveness snapshot", + "project removal drains create-first worktree creation before its ownership snapshot and rejects removal-first worktree creation", + "host-scoped project removal drains create-first work and rejects removal-first creation before mutating one duplicate-host row", + "duplicate repo and worktree ids isolate admissions and PTY liveness by execution host while whole-project removal still drains every host scope", + "host-scoped terminal stop succeeds when an unrelated provider is unavailable without disconnecting that host's cached PTYs", + "workspace resolution on an unrelated host proceeds while the removed host remains admission-fenced", + "a matching host removal that starts and finishes during unresolved workspace selection still rejects the stale create", + "a different host removal that starts and finishes during unresolved workspace selection does not reject the resolved owner", + "worktree removal rejects a late renderer terminal admission while destructive preflight is still pending", + "already-missing exceptional removal verifies terminal stop before metadata purge", + "failed and concurrent admissions release without deadlocking project removal", + "targeted terminal stop omits foreground probes for unrelated sessions", + "paired/mobile headless close preserves the persisted terminal when controller retry ownership is rejected" + ] + }, + { + "file": "src/shared/ssh-pty-id.test.ts", + "assertions": [ + "the same relay counter in two boot generations produces distinct app PTY ids" + ] + }, + { + "file": "src/main/providers/ssh-pty-provider.test.ts", + "assertions": [ + "SSH shutdown forwards pane identity and rejects an older relay generation before RPC" + ] + }, + { + "file": "src/main/providers/local-pty-provider.test.ts", + "assertions": [ + "a synchronous Windows native kill throw retains PTY ownership and listeners so retry can succeed", + "an accepted native kill retains the PTY until an exit callback proves physical shutdown", + "ESRCH reaps an exact native owner after accepted or throwing kill when node-pty omits onExit", + "inventory reaps delayed OS death after an accepted Windows close without reissuing native kill", + "a stale pane identity is rejected before the local PTY can be killed", + "immediate POSIX local shutdown uses SIGKILL while Windows remains signal-free" + ] + }, + { + "file": "src/main/daemon/daemon-pty-adapter.test.ts", + "assertions": [ + "daemon shutdown carries pane identity across RPC and rejects a stale kill", + "accepted daemon shutdown remains provider-visible until physical exit proof", + "preserved pre-v22 sessions fail closed when restart cannot recover pane identity for attach or shutdown", + "a stale daemon list started before same-id replacement completion cannot finalize the old exit", + "a protocol-v23 exit from an older immutable session generation is rejected without probing", + "a replacement-generation exit emitted before its create reply is deferred and finalized after admission", + "an older alive readback cannot erase a newer deferred replacement-generation exit", + "a synchronous generation-qualified exit rejects the dead spawn result before callers can republish ownership", + "100 failed admissions with unknown readback and no published owner release every exit-fence map", + "same-id spawn admissions serialize while different session ids remain concurrent", + "100 native spawn failures publish no owner and retain zero queue or fence entries", + "authoritative list absence clears active ownership and every exit-fence map" + ] + }, + { + "file": "src/main/daemon/daemon-session-exit-fence.test.ts", + "assertions": [ + "same-id admission completion invalidates a stale absent-list snapshot and recognizes the older immutable generation", + "generation mismatch classification is deferred while same-id admission is observably active and can still publish a replacement generation", + "2,000 finalized session identities leave zero admission, revision, generation, or pending-exit entries", + "an admission finishing after dispose-style clear cannot publish generation state or recreate any map" + ] + }, + { + "file": "src/main/daemon/daemon-server.test.ts", + "assertions": [ + "one immutable protocol-v23 generation crosses create, list, size, and exit and changes on same-id replacement", + "100 synchronous buffered-output and exit subscriptions leave zero daemon stream routes or host sessions", + "failed attach rollback restores only a still-active prior stream-route owner", + "two overlapping failed attaches recover the nearest still-active live route while an older failure cannot dislodge a newer successful route" + ] + }, + { + "file": "src/main/daemon/terminal-host.test.ts", + "assertions": [ + "a reused live daemon session rejects attach and kill from a replacement pane identity", + "native immediate force-kill failure preserves the live daemon session for retry", + "same-id create waits for physical exit proof without disposing the terminating session owner", + "the host publishes Session ownership and attaches its client before buffered data and exit flush synchronously on subscription" + ] + }, + { + "file": "src/main/daemon/pty-subprocess.test.ts", + "assertions": [ + "native and signal force-kill failure propagates while conservative OS liveness still proves the child exists", + "Windows ConPTY force shutdown records one-shot native close acceptance for exit-proof polling", + "a thrown Windows graceful close remains retryable by the force-shutdown owner unless OS liveness independently proves exit" + ] + }, + { + "file": "src/main/daemon/session.test.ts", + "assertions": [ + "accepted daemon force kill retains the session until onExit or conservative OS proof", + "accepted Windows force kill is never reissued while exit remains unproved", + "native force-kill failures retain one retry timer beyond the eighth attempt with capped logging" + ] + }, + { + "file": "src/main/daemon/terminal-host-session-reaping-leak.test.ts", + "assertions": [ + "immediate and timed-out graceful kills retain their emulator/session until physical exit proof", + "whole-host shutdown replaces a prior long kill timer, retries native failure, and waits for onExit before clearing the session map" + ] + }, + { + "file": "src/main/daemon/daemon-server.test.ts", + "assertions": [ + "production daemon shutdown awaits the host exit-proof drain before closing sockets", + "RPC drain rejection is contained and requests explicit detached-process exit" + ] + }, + { + "file": "src/main/persistence.test.ts", + "assertions": [ + "durable SSH leases store raw relay ids together with their relay generation", + "offline SSH shutdown intent persists on the exact durable lease", + "generationless SSH ids cannot mutate a generation-qualified replacement lease", + "65 local plus 65 remote-runtime owners survive immediate reload and clear through sub-1-KiB O(1) records, then compact to one checkpoint with zero synchronous full-store flushes", + "a stale compact snapshot cannot override a newer full-store fallback revision" + ] + }, + { + "file": "src/main/ssh/ssh-relay-generation-reattach.test.ts", + "assertions": [ + "durable leases attach only within their minting relay generation", + "an older-generation lease expires before its recycled raw id can attach" + ] + }, + { + "file": "src/main/ssh/ssh-target-id-migration.test.ts", + "assertions": [ + "SSH target re-adoption rewrites only the target id and preserves relay generation" + ] + }, + { + "file": "src/relay/subprocess.test.ts", + "assertions": ["relay.status publishes one stable UUID for the relay process lifetime"] + }, + { + "file": "src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts", + "assertions": [ + "ownerless pre-handler exits evict oldest state at the 64-id bound", + "an evicted legitimate exit triggers one targeted liveness readback and disconnects the dead pane", + "unknown or failed liveness retains the tombstone for a later authoritative retry", + "100 unrelated registrations after overflow trigger zero liveness IPC", + "the 1,024-id tombstone cap evicts exactly the oldest id while retaining one targeted successor readback" + ] + }, + { + "file": "src/main/ipc/ssh.test.ts", + "assertions": [ + "target removal retains metadata and the live relay when PTY exit proof fails", + "successful target removal exit-proofs every PTY and force-stops the grace-zero relay before metadata deletion", + "bulk SSH teardown preserves relay generation plus pane identity in durable retry ownership" + ] + }, + { + "file": "src/main/runtime/worktree-teardown.test.ts", + "assertions": [ + "worktree cleanup never clears PTY state merely because an exit-proof provider accepted shutdown" + ] + }, + { + "file": "src/renderer/src/lib/pty-kill-retry-ownership.test.ts", + "assertions": [ + "renderer teardown forwards exact PTY identity to the main-process retry owner", + "renderer lifecycle hooks no longer own retry timers that disappear on reload" + ] + }, + { + "file": "src/main/ipc/pty.test.ts", + "assertions": [ + "renderer pane identity reaches SSH provider shutdown", + "renderer PTY spawn enters runtime terminal admission before provider work", + "renderer PTY spawn qualifies local and SSH admissions with the owning execution host", + "disconnected SSH kill and stop retain the lease and reject without a synthetic exit", + "provider reconnect retries durable shutdown intent with its generation and pane identity", + "persisted local shutdown waits for daemon startup instead of accepting unknown-id success from the fallback provider", + "renderer and runtime same-id restore reject while startup teardown replay retains that exact PTY owner", + "an in-flight replaced local provider cannot complete or delete the newer shutdown owner", + "local and SSH stale-provider not-found rejections preserve replacement ownership and coalesced callers fail closed", + "direct local and SSH kill requests share the retained owner and re-drive after in-flight provider replacement", + "replacement-provider callers settle only after the replacement shutdown succeeds and retain stronger coalesced identity options", + "generationless identity-mismatch failure cannot tombstone a replacement relay-generation lease", + "paired/mobile controller close requires immediate provider shutdown and fails closed when offline SSH intent cannot be made durable", + "host-scoped liveness lists only the selected provider instead of fanning out to unrelated SSH hosts", + "relay generation reuse removes stale retry ownership without touching the new PTY", + "lease-less SSH shutdown survives provider unregister and re-drives the replacement transport", + "accepted local and SSH kills retain retry ownership and poll readback without reissuing provider shutdown", + "runtime stopAndWait shares the retained owner, requires physical exit proof, and never reissues an accepted close", + "50 concurrent accepted SSH shutdown owners share one provider inventory per liveness-proof cohort", + "runtime-owned and renderer SSH spawn persistence failures retain exact retry ownership after cleanup rejection and clear only after retry success", + "an old provider exit readback cannot settle replacement-provider retry ownership", + "unprovable pre-v22 restart intent retires once without synthesizing exit, retrying forever, or killing by recycled id" + ] + }, + { + "file": "src/main/daemon/daemon-pty-router.test.ts", + "assertions": [ + "upgrade routing advertises exit-proof shutdown and retains the legacy adapter until onExit or readback", + "a late legacy exit cannot delete a replacement route after PTY id reuse", + "same-provider exit during replacement spawn admission cannot disconnect the successful same-id replacement", + "deferred exit is delivered when replacement spawn rejects and no same-id process survives", + "overlapping same-id spawn is rejected before it can overwrite admission or exit-proof ownership" + ] + }, + { + "file": "src/main/daemon/degraded-daemon-pty-provider.test.ts", + "assertions": [ + "degraded routing advertises exit-proof shutdown and retains the exact child provider until onExit or readback", + "daemon replacement is blocked while accepted fallback shutdowns remain live, retains their routes, and proves all accepted owners with one inventory", + "authoritative shared inventory can release a fallback route when process death follows a throwing native close", + "fallback exit during replacement spawn admission cannot disconnect the successful same-id replacement", + "deferred fallback exit is delivered when replacement spawn rejects and no same-id process survives", + "overlapping fallback same-id spawn is rejected before it can overwrite admission or exit proof" + ] + }, + { + "file": "src/main/ipc/runtime-terminal-close-retry-owner.test.ts", + "assertions": [ + "a late runtime close completion cannot delete a replacement-generation retry owner", + "same-generation terminal_handle_stale releases durable ownership and cancels the last retry timer" + ] + }, + { + "file": "src/main/ipc/repos-create.test.ts", + "assertions": [ + "desktop repo removal routes through the runtime project-removal transaction", + "host-scoped desktop repo removal routes through the same runtime transaction" + ] + }, + { + "file": "src/main/ipc/worktrees.test.ts", + "assertions": [ + "legacy desktop worktree create and remove enter runtime admission before filesystem or Git mutation", + "duplicate repo creation qualifies admission with the explicitly selected execution host", + "local worktree removal verifies terminal stop before destructive removal", + "already-missing desktop removal verifies terminal stop before metadata purge" + ] + }, + { + "file": "src/main/window/attach-main-window-services.test.ts", + "assertions": [ + "production main-window wiring supplies the runtime authority to desktop repo handlers" + ] + }, + { + "file": "src/renderer/src/lib/launch-agent-background-session.test.ts", + "assertions": [ + "a rejected cleanup kill after local hidden spawn retains exact tab identity and succeeds on lifecycle retry" + ] + }, + { + "file": "src/renderer/src/lib/runtime-terminal-close-retry-ownership.test.ts", + "assertions": [ + "renderer remote-runtime teardown forwards environment and handle identity to main without renderer-owned timers" + ] + }, + { + "file": "src/main/ipc/runtime-environments.test.ts", + "assertions": [ + "persisted remote-runtime close intent hydrates after app restart and drains after bounded retry", + "persisted close intent for a removed environment drains without an unhandled rejection or retry timer", + "replacement runtime generation clears stale intent without sending terminal.close" + ] + }, + { + "file": "src/main/runtime/rpc/terminal-close-generation.test.ts", + "assertions": [ + "runtime atomically rejects a terminal close fenced to an older runtime generation" + ] + }, + { + "file": "src/renderer/src/store/slices/worktrees.test.ts", + "assertions": [ + "renderer worktree creation sends the active duplicate repo owner host through desktop IPC", + "a focus switch during create cannot restamp the result onto another duplicate host", + "runtime-owned SSH cleanup removes duplicate repo ids only on each destroyed target host" + ] + }, + { + "file": "src/renderer/src/store/slices/repos-host-identity-routing.test.ts", + "assertions": [ + "duplicate-host project removal kills only the owner host with expected tab identity" ] } ], "evidenceRuns": [ + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-session-exit-fence.test.ts", + "result": "passed", + "durationSeconds": 1, + "summary": "3 deterministic tests passed: replacement admission invalidated a stale absent-list snapshot, immutable generation rejected the old exit identity, 2,000 finalized identities retained zero fence entries, and a post-clear completion could not recreate state." + }, { "date": "2026-07-12", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts config/scripts/build-relay-windows-node-pty-patch.test.mjs src/main/ssh/ssh-relay-native-deps-install.test.ts src/relay/node-pty-windows-pre-ready-shutdown.test.ts src/relay/pty-handler.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts config/scripts/build-relay-windows-node-pty-patch.test.mjs src/main/ssh/ssh-relay-native-deps-install.test.ts src/relay/node-pty-windows-pre-ready-shutdown.test.ts src/relay/pty-handler.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts src/renderer/src/store/slices/repos.test.ts src/renderer/src/store/slices/repos-runtime-vm-removal.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts", "result": "passed", "durationSeconds": 2, - "summary": "97 tests passed, including the built Windows relay artifact red/green harness, strict remote install ordering/failure/upgrade-repair handling, three installed-dependency pre-ready assertions, and relay shutdown/retry call-count coverage." + "summary": "203 tests passed, including built Windows relay red/green and ConPTY fallback parity, repeated stale-spawn throw containment, renderer retained retry identity, local/remote project removal fail-closed ownership, and VM cleanup propagation." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts", + "result": "passed", + "durationSeconds": 10, + "summary": "662 runtime tests passed, including provider-verified terminal.stop acceptance and post-stop liveness coverage." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/ssh-pty-id.test.ts src/shared/ssh-types.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/persistence.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-relay-generation-reattach.test.ts src/main/ssh/ssh-target-id-migration.test.ts src/relay/pty-handler.test.ts src/relay/subprocess.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts src/renderer/src/lib/pty-kill-retry-ownership.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts src/renderer/src/store/slices/repos.test.ts src/main/runtime/orca-runtime.test.ts src/main/ipc/pty.test.ts", + "result": "passed", + "durationSeconds": 12, + "summary": "1681 tests passed with durable relay-generation persistence and mismatch rejection, local native-throw retry ownership, all 65 renderer shutdown owners retained and drained, stale-spawn retained ownership, worktree/terminal admission ordering, VM cleanup success ordering, and targeted liveness probes." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts", + "result": "passed", + "durationSeconds": 18, + "summary": "670 runtime tests passed, including removal-first rejection and create-first admission drain for worktree creation, headless terminals, renderer-backed mobile, renderer split, and selector-less renderer paths before ownership/liveness snapshots, plus late terminal rejection during worktree-removal preflight." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-create.test.ts src/main/window/attach-main-window-services.test.ts src/main/ipc/worktrees.test.ts src/main/ipc/worktrees-windows.test.ts src/main/ipc/pty.test.ts src/main/runtime/orca-runtime.test.ts", + "result": "passed", + "durationSeconds": 10, + "summary": "1198 tests passed, proving production desktop repo-removal wiring, legacy worktree create/remove admission, renderer PTY-spawn admission, verified stop before local removal, and runtime rejection of late terminal creation during worktree-removal preflight." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts", + "result": "passed", + "durationSeconds": 10, + "summary": "672 runtime tests passed, adding create-first drain and removal-first rejection for host-scoped duplicate-project removal plus verified stop before exceptional worktree metadata purge." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/ssh-pty-id.test.ts src/shared/ssh-types.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/persistence.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-relay-generation-reattach.test.ts src/main/ssh/ssh-target-id-migration.test.ts src/relay/pty-handler.test.ts src/relay/subprocess.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts src/renderer/src/lib/pty-kill-retry-ownership.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts src/renderer/src/lib/launch-agent-background-session.test.ts src/renderer/src/store/slices/repos.test.ts src/renderer/src/store/slices/repos-host-identity-routing.test.ts src/main/runtime/orca-runtime.test.ts src/main/ipc/pty.test.ts", + "result": "passed", + "durationSeconds": 12, + "summary": "1713 tests passed, including disconnected SSH lease retention, hidden background-cleanup retry ownership, duplicate-host routing identity, and all prior relay/provider/admission bounds." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-create.test.ts src/main/window/attach-main-window-services.test.ts src/main/ipc/worktrees.test.ts src/main/ipc/worktrees-windows.test.ts src/main/ipc/pty.test.ts src/main/runtime/orca-runtime.test.ts src/renderer/src/lib/launch-agent-background-session.test.ts src/renderer/src/store/slices/repos-host-identity-routing.test.ts", + "result": "passed", + "durationSeconds": 12, + "summary": "1231 tests passed across desktop admission wiring, host-scoped removal, exceptional worktree cleanup, offline SSH rejection, and hidden-session retained retry ownership." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/ssh-pty-id.test.ts src/shared/ssh-types.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/persistence.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-relay-generation-reattach.test.ts src/main/ssh/ssh-target-id-migration.test.ts src/relay/pty-handler.test.ts src/relay/subprocess.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts src/renderer/src/lib/pty-kill-retry-ownership.test.ts src/renderer/src/lib/runtime-terminal-close-retry-ownership.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts src/renderer/src/lib/launch-agent-background-session.test.ts src/renderer/src/store/slices/repos.test.ts src/renderer/src/store/slices/repos-host-identity-routing.test.ts src/main/runtime/orca-runtime.test.ts src/main/ipc/pty.test.ts", + "result": "passed", + "durationSeconds": 27, + "summary": "1715 tests passed, adding automatic all-due local/SSH retry, repeated remote-runtime handle retry, stale-registration verified stop, and all prior ownership/admission bounds." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-create.test.ts src/main/window/attach-main-window-services.test.ts src/main/ipc/worktrees.test.ts src/main/ipc/worktrees-windows.test.ts src/main/ipc/pty.test.ts src/main/runtime/orca-runtime.test.ts src/renderer/src/lib/launch-agent-background-session.test.ts src/renderer/src/lib/runtime-terminal-close-retry-ownership.test.ts src/renderer/src/store/slices/repos-host-identity-routing.test.ts", + "result": "passed", + "durationSeconds": 26, + "summary": "1232 tests passed across desktop/runtime teardown boundaries, exceptional cleanup outcomes, offline SSH retention, and local plus remote hidden-session retry ownership." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts config/scripts/build-relay-windows-node-pty-patch.test.mjs src/main/ssh/ssh-relay-native-deps-install.test.ts src/relay/node-pty-windows-pre-ready-shutdown.test.ts src/relay/pty-handler.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts src/renderer/src/store/slices/repos.test.ts src/renderer/src/store/slices/repos-runtime-vm-removal.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts", + "result": "passed", + "durationSeconds": 2, + "summary": "203 exact-head tests preserved the vanilla close=0/native-kill=0/deferred=1 red oracle and patched close=1/native-kill=1/deferred=0 result after latest-main integration." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/ssh-pty-id.test.ts src/shared/ssh-types.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/persistence.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-relay-generation-reattach.test.ts src/main/ssh/ssh-target-id-migration.test.ts src/relay/pty-handler.test.ts src/relay/subprocess.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts src/renderer/src/lib/pty-kill-retry-ownership.test.ts src/renderer/src/lib/runtime-terminal-close-retry-ownership.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts src/renderer/src/lib/launch-agent-background-session.test.ts src/renderer/src/store/slices/repos.test.ts src/renderer/src/store/slices/repos-host-identity-routing.test.ts src/main/runtime/orca-runtime.test.ts src/main/ipc/pty.test.ts", + "result": "passed", + "durationSeconds": 11, + "summary": "1720 exact-head tests passed with bounded retained retry ownership plus execution-host-isolated admissions and PTY liveness." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/ipc/worktrees.test.ts src/main/ipc/pty.test.ts src/main/ipc/runtime-environments.test.ts src/main/runtime/rpc/terminal-close-generation.test.ts src/main/persistence.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ipc/ssh.test.ts src/renderer/src/store/slices/worktrees.test.ts", + "result": "passed", + "durationSeconds": 10, + "summary": "1356 exact-head tests proved duplicate local/SSH IDs isolate create, removal, renderer spawn, and PTY liveness ownership while whole-project removal drains every host scope." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts config/scripts/build-relay-windows-node-pty-patch.test.mjs src/main/ssh/ssh-relay-native-deps-install.test.ts src/relay/node-pty-windows-pre-ready-shutdown.test.ts src/relay/pty-handler.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts src/renderer/src/store/slices/repos.test.ts src/renderer/src/store/slices/repos-runtime-vm-removal.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts", + "result": "passed", + "durationSeconds": 3, + "summary": "203 tests passed after the retained-owner fixes, preserving vanilla close=0/native-kill=0/deferred=1 and shipped close=1/native-kill=1/deferred=0 relay evidence." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/ssh-pty-id.test.ts src/shared/ssh-types.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/persistence.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-relay-generation-reattach.test.ts src/main/ssh/ssh-target-id-migration.test.ts src/relay/pty-handler.test.ts src/relay/subprocess.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts src/renderer/src/lib/pty-kill-retry-ownership.test.ts src/renderer/src/lib/runtime-terminal-close-retry-ownership.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts src/renderer/src/lib/launch-agent-background-session.test.ts src/renderer/src/store/slices/repos.test.ts src/renderer/src/store/slices/repos-host-identity-routing.test.ts src/main/runtime/orca-runtime.test.ts src/main/ipc/pty.test.ts", + "result": "passed", + "durationSeconds": 12, + "summary": "1741 tests passed with caller re-drive on provider replacement, monotonic coalesced identity, generationless mismatch fencing, mobile retained-owner refusal, revision-fenced crash durability, and zero synchronous full-store flushes for 130 owners." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/ipc/worktrees.test.ts src/main/ipc/pty.test.ts src/main/ipc/runtime-environments.test.ts src/main/runtime/rpc/terminal-close-generation.test.ts src/main/persistence.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ipc/ssh.test.ts src/renderer/src/store/slices/worktrees.test.ts", + "result": "passed", + "durationSeconds": 11, + "summary": "1854 host/runtime tests passed across local, SSH-shaped, remote-runtime, worktree, startup replay, paired/mobile controller, and persisted teardown ownership contracts." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/session.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/daemon/daemon-server.test.ts src/main/ipc/pty.test.ts src/main/ipc/runtime-terminal-close-retry-owner.test.ts src/main/runtime/orca-runtime.test.ts", + "result": "passed", + "durationSeconds": 9.4, + "summary": "1383 daemon/provider/runtime tests passed, including bounded whole-daemon drain, replacement of a pre-existing long graceful-kill timer, RPC rejection containment, and explicit detached-process exit." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/ssh-pty-id.test.ts src/shared/ssh-types.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/persistence.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-relay-generation-reattach.test.ts src/main/ssh/ssh-target-id-migration.test.ts src/relay/pty-handler.test.ts src/relay/subprocess.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts src/renderer/src/lib/pty-kill-retry-ownership.test.ts src/renderer/src/lib/runtime-terminal-close-retry-ownership.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts src/renderer/src/lib/launch-agent-background-session.test.ts src/renderer/src/store/slices/repos.test.ts src/renderer/src/store/slices/repos-host-identity-routing.test.ts src/main/runtime/orca-runtime.test.ts src/main/ipc/pty.test.ts", + "result": "passed", + "durationSeconds": 9.8, + "summary": "1781 cross-provider ownership tests passed, including whole-relay exit-proof drain and an atomic 50-live-PTY cap under 51 concurrent spawn requests." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/session.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/daemon/daemon-server.test.ts src/main/ipc/pty.test.ts src/main/ipc/runtime-terminal-close-retry-owner.test.ts src/main/runtime/orca-runtime.test.ts", + "result": "passed", + "durationSeconds": 9.3, + "summary": "1414 daemon/provider/runtime tests passed, adding composite-provider exit-proof routing, late-exit generation fencing, and retained persistence-failure cleanup retries." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/ssh-pty-id.test.ts src/shared/ssh-types.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/persistence.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-relay-generation-reattach.test.ts src/main/ssh/ssh-target-id-migration.test.ts src/relay/pty-handler.test.ts src/relay/subprocess.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts src/renderer/src/lib/pty-kill-retry-ownership.test.ts src/renderer/src/lib/runtime-terminal-close-retry-ownership.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts src/renderer/src/lib/launch-agent-background-session.test.ts src/renderer/src/store/slices/repos.test.ts src/renderer/src/store/slices/repos-host-identity-routing.test.ts src/main/runtime/orca-runtime.test.ts src/main/ipc/pty.test.ts", + "result": "passed", + "durationSeconds": 10, + "summary": "1784 cross-provider ownership tests passed, including bounded pre-handler exit overflow with targeted liveness recovery and both persistence-failure cleanup entry points." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts config/scripts/build-relay-windows-node-pty-patch.test.mjs src/main/ssh/ssh-relay-native-deps-install.test.ts src/relay/node-pty-windows-pre-ready-shutdown.test.ts src/relay/pty-handler.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts src/renderer/src/store/slices/repos.test.ts src/renderer/src/store/slices/repos-runtime-vm-removal.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts", + "result": "passed", + "durationSeconds": 2, + "summary": "215 tests passed, including immediate and stale-spawn fallback native-throw paths that accept independent OS death proof, release ownership, and retain zero retry timers." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts config/scripts/build-relay-windows-node-pty-patch.test.mjs src/main/ssh/ssh-relay-native-deps-install.test.ts src/relay/node-pty-windows-pre-ready-shutdown.test.ts src/relay/pty-handler.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts src/renderer/src/store/slices/repos.test.ts src/renderer/src/store/slices/repos-runtime-vm-removal.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts", + "result": "passed", + "durationSeconds": 2, + "summary": "218 tests passed: Windows native throws remain retryable, while accepted one-shot closes are never reissued and stay owned until exit proof." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/session.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/daemon/daemon-server.test.ts src/main/ipc/pty.test.ts src/main/ipc/runtime-terminal-close-retry-owner.test.ts src/main/runtime/orca-runtime.test.ts", + "result": "passed", + "durationSeconds": 10, + "summary": "1434 daemon/provider/runtime tests passed, including overlapping same-id admission rejection, Windows daemon close latching, and 50-owner inventory coalescing." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/ipc/worktrees.test.ts src/main/ipc/pty.test.ts src/main/ipc/runtime-environments.test.ts src/main/runtime/rpc/terminal-close-generation.test.ts src/main/persistence.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ipc/ssh.test.ts src/renderer/src/store/slices/worktrees.test.ts", + "result": "passed", + "durationSeconds": 10, + "summary": "1888 host/runtime tests passed, including generation-qualified SSH teardown and one shared liveness inventory for 50 retained owners." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/local-pty-process-exit-proof.test.ts src/main/runtime/worktree-teardown.test.ts", + "result": "passed", + "durationSeconds": 1, + "summary": "11 tests passed: only ESRCH proved local process death, and worktree cleanup preserved state after rejected or merely accepted unproved shutdown." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/session.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/daemon/daemon-server.test.ts src/main/ipc/pty.test.ts src/main/ipc/runtime-terminal-close-retry-owner.test.ts src/main/runtime/orca-runtime.test.ts", + "result": "passed", + "durationSeconds": 10.05, + "summary": "1438 tests passed: stopAndWait retained one accepted close through failed exit proof, local delayed OS death reaped without another close, and degraded fallback restart retained unproved routes while one shared inventory released proved-dead owners." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/ipc/worktrees.test.ts src/main/ipc/pty.test.ts src/main/ipc/runtime-environments.test.ts src/main/runtime/rpc/terminal-close-generation.test.ts src/main/persistence.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ipc/ssh.test.ts src/renderer/src/store/slices/worktrees.test.ts", + "result": "passed", + "durationSeconds": 9.75, + "summary": "1889 tests passed across startup replay, local/SSH/runtime teardown ownership, selected-provider liveness, and retained stop verification." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/ssh-pty-id.test.ts src/shared/ssh-types.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/persistence.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-relay-generation-reattach.test.ts src/main/ssh/ssh-target-id-migration.test.ts src/relay/pty-handler.test.ts src/relay/subprocess.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts src/renderer/src/lib/pty-kill-retry-ownership.test.ts src/renderer/src/lib/runtime-terminal-close-retry-ownership.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts src/renderer/src/lib/launch-agent-background-session.test.ts src/renderer/src/store/slices/repos.test.ts src/renderer/src/store/slices/repos-host-identity-routing.test.ts src/main/runtime/orca-runtime.test.ts src/main/ipc/pty.test.ts", + "result": "passed", + "durationSeconds": 9.89, + "summary": "1798 tests passed: unknown pre-handler liveness retained its bounded tombstone, delayed local/relay death drained ownership, and cross-provider retry identity remained exact." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts config/scripts/build-relay-windows-node-pty-patch.test.mjs src/main/ssh/ssh-relay-native-deps-install.test.ts src/relay/node-pty-windows-pre-ready-shutdown.test.ts src/relay/pty-handler.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts src/renderer/src/store/slices/repos.test.ts src/renderer/src/store/slices/repos-runtime-vm-removal.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts", + "result": "passed", + "durationSeconds": 1.43, + "summary": "219 tests passed including packaged relay patch parity, vanilla pre-ready red control, one-shot accepted close ownership, and delayed OS-death inventory reaping." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/session.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/daemon/daemon-server.test.ts src/main/ipc/pty.test.ts src/main/ipc/runtime-terminal-close-retry-owner.test.ts src/main/runtime/orca-runtime.test.ts", + "result": "passed", + "durationSeconds": 9.17, + "summary": "1442 tests passed: terminating same-id daemon sessions deferred creation until physical exit, renderer/runtime startup restore rejected retained teardown identities, and same-generation stale runtime handles released persistence plus the last timer." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/ipc/worktrees.test.ts src/main/ipc/pty.test.ts src/main/ipc/runtime-environments.test.ts src/main/runtime/rpc/terminal-close-generation.test.ts src/main/persistence.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ipc/ssh.test.ts src/renderer/src/store/slices/worktrees.test.ts", + "result": "passed", + "durationSeconds": 9.63, + "summary": "1891 tests passed across app-start teardown replay, local/SSH/runtime identity ownership, selected-provider liveness, and durable close settlement." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/ssh-pty-id.test.ts src/shared/ssh-types.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/persistence.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-relay-generation-reattach.test.ts src/main/ssh/ssh-target-id-migration.test.ts src/relay/pty-handler.test.ts src/relay/subprocess.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts src/renderer/src/lib/pty-kill-retry-ownership.test.ts src/renderer/src/lib/runtime-terminal-close-retry-ownership.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts src/renderer/src/lib/launch-agent-background-session.test.ts src/renderer/src/store/slices/repos.test.ts src/renderer/src/store/slices/repos-host-identity-routing.test.ts src/main/runtime/orca-runtime.test.ts src/main/ipc/pty.test.ts", + "result": "passed", + "durationSeconds": 10.49, + "summary": "1800 tests passed with exact same-id startup replay admission, retained pre-handler/liveness bounds, and cross-provider retry identity preserved." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/session.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/daemon/daemon-server.test.ts src/main/ipc/pty.test.ts src/main/ipc/runtime-terminal-close-retry-owner.test.ts src/main/runtime/orca-runtime.test.ts", + "result": "passed", + "durationSeconds": 9.06, + "summary": "1443 tests passed: two-phase Session startup delivered synchronous buffered BOOT output and exit to the published owner/client, reaped the exact map entry, and allowed a clean same-ID replacement." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/session.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/daemon/daemon-server.test.ts src/main/daemon/daemon-session-exit-fence.test.ts src/main/ipc/pty.test.ts src/main/ipc/runtime-terminal-close-retry-owner.test.ts src/main/runtime/orca-runtime.test.ts", + "result": "passed", + "durationSeconds": 9.98, + "summary": "1451 tests passed: 100 synchronous buffered-output/exit subscriptions retained zero daemon routes or sessions, replacement exits before create reply finalized after admission, and older alive readbacks preserved newer deferred exits." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/session.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/daemon/daemon-server.test.ts src/main/daemon/daemon-session-exit-fence.test.ts src/main/ipc/pty.test.ts src/main/ipc/runtime-terminal-close-retry-owner.test.ts src/main/runtime/orca-runtime.test.ts", + "result": "passed", + "durationSeconds": 13.21, + "summary": "1455 tests passed: synchronous pre-reply exit rejected the dead spawn result, 100 ownerless unknown exits released every fence map, overlapping failed routes retained zero owners, and an older failure preserved the newer successful route." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/session.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/daemon/daemon-server.test.ts src/main/daemon/daemon-session-exit-fence.test.ts src/main/ipc/pty.test.ts src/main/ipc/runtime-terminal-close-retry-owner.test.ts src/main/runtime/orca-runtime.test.ts", + "result": "passed", + "durationSeconds": 19.94, + "summary": "1458 tests passed: same-id admissions serialized, different ids remained concurrent, 100 native spawn failures retained zero queue/fence owners, and overlapping route failures recovered the live predecessor." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts config/scripts/build-relay-windows-node-pty-patch.test.mjs src/main/ssh/ssh-relay-native-deps-install.test.ts src/relay/node-pty-windows-pre-ready-shutdown.test.ts src/relay/pty-handler.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts src/renderer/src/store/slices/repos.test.ts src/renderer/src/store/slices/repos-runtime-vm-removal.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts", + "result": "passed", + "durationSeconds": 8.1, + "summary": "220 tests passed including rejected-admission pre-handler cleanup, Windows relay patch parity, renderer teardown settlement, purge retry ownership, and VM cleanup propagation." } ], "runtimeBudget": { - "p95Seconds": 5, + "p95Seconds": 20, "scope": "dependency and relay unit tests" }, "flakeHistory": { @@ -3400,11 +4568,11 @@ }, "redGreenEvidence": { "status": "partial", - "evidence": "The built relay artifact harness records the vanilla npm dependency at close=0, native-kill=0, deferred=1 and the shipped patched artifact at close=1, native-kill=1, deferred=0. Without relay one-shot ownership, graceful fallback and dispose issue duplicate kills." + "evidence": "The built relay artifact harness records the vanilla npm dependency at close=0, native-kill=0, deferred=1 and the shipped patched artifact at close=1, native-kill=1, deferred=0. Accepted local, daemon, and relay kills now remain provider-visible until onExit or conservative OS liveness proves physical exit; Windows retries stay signal-free and only native throws reissue the one-shot close. Same-id exits that race daemon or fallback replacement spawn admission are deferred, then suppressed only when the exact child provider proves the replacement live; overlapping same-id spawn is rejected before provider work can overwrite that proof. Injected daemon native failures propagate without reaping the live session, while ESRCH after accepted or throwing native close reaps the exact local/relay owner even when node-pty omits onExit. Whole-process daemon and relay drains retain listeners and retry ownership until exit proof or the bounded process-exit deadline, and an older 5-second graceful timer is replaced by the shutdown cadence. RPC drain rejection is contained before explicit detached-process exit. Stale relay spawns retain one timer beyond the eighth failure, graceful fallback throws stay below relay-global uncaughtException, and a standalone cold 51-request relay admission deterministically creates 50 live PTYs plus one cap rejection through one shared addon load. Protocol-v23 generations plus epoch-bound admission publication reject old exits, stale absence, and post-clear completion; authoritative absence releases every map. Stream routes are published before subscription, invalidated by their own exit/failure even when not current, and failed overlap walks its provisional chain to the nearest still-active live owner; 100 synchronous output/exit cycles retain zero routes or sessions, while an older failure cannot dislodge a newer successful route. Same-id adapter admissions serialize before provider work while different ids remain concurrent. Replacement-generation exits are queued before cross-socket readback while admission is active, older alive readbacks cannot erase them, and authoritative reconciliation rejects a dead pre-reply spawn result before caller ownership can be republished. One hundred failed admissions with either unknown exit proof or native spawn rejection release every queue and fence map. Renderer connect recognizes that explicit admission rejection and discards only the dead generation's same-id pre-handler bytes and exit before retry. The former 65-id renderer oracle proved the oldest still-live owner was dropped; the exact 1,024-id tombstone oracle evicts only the oldest id, recovers its successor, and probes none of 100 unrelated later registrations. A disconnected SSH provider retains its lease without a synthetic exit; 50 concurrent accepted SSH owners share one provider inventory, bulk teardown preserves relay generation and pane identity in the durable owner, and target removal preserves metadata unless every PTY is exit-proved before the grace-zero relay is force-stopped. A real worktree-removal preflight deterministically rejects a late matching-host admission while allowing an unrelated host removal; fallback cleanup no longer erases state after unproved shutdown." }, "performanceBudget": { "required": true, - "evidence": "Shutdown removes indefinite deferred retention and preserves at most one native kill and one existing graceful timer per PTY request; it adds no polling, listener, startup await, or steady-state work." + "evidence": "Shutdown removes ownerless deferred retention and preserves at most one provider entry plus one timer per PTY until physical exit is proved or the bounded whole-process drain ends. Wait completion is event-driven from map deletion, not polled; daemon/relay retry cadence is 100 ms only during the four-second process drain, keeps one timer per still-live PTY, and caps native-failure logs at attempts 1, 2, and 8. Accepted POSIX signals are followed by conservative exit-proof polling without repeated signals, while Windows close remains signal-free, retries only native throws, and polls without reissuing accepted one-shot closes. Relay admission shares one in-flight native-addon import, rechecks the 50-session cap after that await and before synchronous spawn/store, and creates exactly 50 PTYs from 51 cold concurrent calls. Failed local and SSH kills coalesce in main by exact live PTY id, while failed remote-runtime closes coalesce by environment, runtime generation, and handle: distinct durable records correspond one-for-one with still-unconfirmed provider resources and survive renderer/app restart. Each main-owned retry map owns one shared one-shot timer for its earliest deadline, retries all due owners, caps backoff at 30 seconds, and logs only attempts 1, 2, and 8 to prevent outage backpressure. Concurrent liveness proofs share one provider-scoped in-flight process inventory, so 50 SSH owners issue one list RPC instead of returning 2,500 repeated rows. Successful SSH-target and runtime-environment removal release unreachable records and cancel the last retry timer; provider replacement rebinds the same generation but relay/runtime generation replacement drops stale intent before touching a reused id. Daemon listProcesses is a diagnostic path; its added O(active PTYs) snapshot releases absent session maps and does not touch input/output/render loops. The deterministic 65-local plus 65-remote-runtime burst performs zero synchronous full-store flushes: 260 O(1) append records stay below 100 KiB and 1 KiB each, survive immediate add/remove reloads, and compact to one checkpoint after the full-state save; a two-crash oracle proves a torn final record forces an atomic recovered checkpoint before a new owner is added, while a monotonic revision fences the rare full-store I/O-failure fallback against older journal records. Remote-runtime close adds one generation status probe per teardown attempt and is not on input, output, render, polling, or startup-visible paths. Pre-handler exits retain at most 64 payloads plus 1,024 id-only eviction tombstones; the exact-cap oracle evicts only the oldest id and 100 unrelated registrations perform zero liveness calls. Same-id provider-route admission retains one bounded record per id, rejects overlap before provider work, and keeps at most one deferred exit per bounded child provider. Worktree-create, worktree-removal, whole-project, and host-scoped admissions retain one promise only per active operation; unresolved selection retains scope-generation history only while at least one selection is active and clears it at zero. Targeted runtime stop performs two liveness snapshots against only the selected provider, while foreground probes are limited to the target worktree; whole-project inventory retains the all-provider fanout." }, "promotionCriteria": [ "Run the deterministic command across the required CI platforms without flakes.", @@ -3415,6 +4583,274 @@ "The installed-dependency and packaged-relay harnesses bypass native construction and prove dispatch/ownership shape, not Windows native handle release." ], "demotionRule": "Keep non-blocking or quarantine if the dependency harness becomes platform-sensitive or flakes without a product defect." + }, + { + "id": "runtime-files.watcher-process-isolation", + "title": "Runtime and SSH relay watcher faults stay process-isolated without disrupting host services", + "maturity": "experimental", + "protection": "partial", + "owner": "runtime-platform", + "layer": "process-rpc-contract", + "surfaces": [ + "headless orca serve", + "paired file explorer", + "runtime editor external-change detection", + "desktop filesystem watcher", + "SSH relay filesystem watcher and live PTYs" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "remote-runtime", "ssh"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "remote-runtime", "ssh"], + "coverageNotes": "Deterministic tests cover a four-child healthy runtime pool, a one-child healthy SSH relay pool, and at most four bounded quarantine children, with one quarantine attempt per watch lifetime, queue-aware cancellation, bounded initial and crash-resubscribe crawls, physical desktop-install abort, stale-generation rejection, per-child stat bounds, final-overflow RPC delivery, and renderer eviction before terminal callbacks can retry. The relay policy test maps a standard repository's base, Git common directory, and worktree roots to one healthy supervisor, then proves a shared-child failure recovers those roots in separate quarantine supervisors with overflow and resumed events. A built-entry macOS harness drives the real supervisor, kills its child with SIGSEGV, then requires automatic resubscription and a later event. SSH bundles the same child boundary beside relay.js, requires both artifacts for install completeness, and preserves registration-owned same-root cancellation. A built-relay harness kills that child and requires the original relay PID and existing PTY to survive before overflow and precise events resume.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/5308", + "https://github.com/stablyai/orca/issues/7547", + "https://github.com/stablyai/orca/issues/8212" + ], + "invariant": "Local runtime roots use at most four shared watcher children while healthy, while each SSH relay process uses at most one; either pool uses at most four additional quarantine children after shard failure. Cancelling queued setup removes only that request; cancelling an active crawl restarts one shard and restores its healthy records. Setup deadlines start only when the child owns the crawl; the runtime RPC transport owns the normal 15-second initial budget, while a 60-second recovery deadline quarantines a root that would otherwise pin its shard. A crash-fused shared shard moves affected roots into the bounded quarantine pool, and a second fused quarantine ends those watch lifetimes instead of spawning replacements indefinitely. Every interrupted setup emits overflow after recovery, event delivery stays bounded, and each SSH target's relay, PTYs, Git, and other filesystem operations remain isolated from other targets and never load the watcher native addon or share its fault boundary.", + "oracle": "Fill the four-child healthy runtime pool with multiple roots, and map a standard SSH repository's base, Git common directory, and worktree roots to one relay supervisor. Inject IPC disconnect-before-exit, queued and active setup cancellation, initial and crash-resubscribe timeouts, and shard-level terminal failure, then assert healthy roots are restored, affected roots spread across no more than four quarantine supervisors, a second quarantine fuse creates no replacement, stale generations stop delivering, and existing same-root clients receive overflow plus later events without error/end. Abort a pending desktop install and require worktree close and app shutdown to settle before the native crawl resolves. Verify relay build hashes and remote install probes require both relay.js and relay-watcher.js. Launch the built relay with a real PTY and Parcel watch, kill only the watcher child, and require the same relay PID and PTY to answer before a replacement child delivers overflow and a post-crash event.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts", + "pnpm run build:electron-vite", + "node config/scripts/runtime-file-watcher-fault-harness.mjs", + "ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs", + "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-filesystem-watch-registry.test.ts src/relay/fs-handler.test.ts src/relay/subprocess.test.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", + "pnpm run build:relay", + "node config/scripts/relay-watcher-fault-harness.mjs" + ], + "testFiles": [ + "src/main/ipc/parcel-watcher-process.test.ts", + "src/main/ipc/runtime-watcher-process-pool.test.ts", + "src/main/ipc/parcel-watcher-process-entry.test.ts", + "src/main/ipc/parcel-watcher-event-delivery.test.ts", + "src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts", + "src/main/ipc/filesystem-watcher-wsl.test.ts", + "src/main/runtime/file-watcher-host.test.ts", + "src/main/runtime/rpc/methods/files.test.ts", + "src/main/providers/ssh-filesystem-provider.test.ts", + "src/renderer/src/runtime/runtime-file-client.test.ts", + "config/scripts/runtime-file-watcher-fault-harness.mjs", + "src/relay/relay-filesystem-watch-registry.test.ts", + "src/relay/fs-handler.test.ts", + "src/relay/subprocess.test.ts", + "src/main/ssh/ssh-relay-versioned-install.test.ts", + "config/scripts/package-electron-runtime-contract.test.mjs", + "config/scripts/relay-watcher-fault-harness.mjs" + ], + "assertionRefs": [ + { + "file": "src/main/ipc/parcel-watcher-process.test.ts", + "assertions": [ + "initial-subscribe crashes complete with an interruption refresh", + "IPC disconnect recovery restores every existing root before accepting new setup", + "queued abort removes one request without restarting healthy roots, while active abort or crawl-scoped timeout restores the shard", + "a crash-resubscribe crawl deadline ends only the stuck root and tears down its shard process", + "missing built entries fail closed and unavailable canary storage remains optional", + "fault-harness PID reporting cannot clobber an existing filesystem path" + ] + }, + { + "file": "src/main/ipc/runtime-watcher-process-pool.test.ts", + "assertions": [ + "healthy roots spread across at most four shared supervisors before sharing", + "a supervisor-scoped failure spreads affected roots across at most four quarantine supervisors", + "a second fused quarantine creates no further replacement for that watch lifetime", + "a root-specific failure preserves healthy shard assignments", + "a live root whose crash-resubscribe times out moves to quarantine", + "an older same-root teardown cannot erase a newer watch lease's assignment" + ] + }, + { + "file": "src/main/ipc/parcel-watcher-process-entry.test.ts", + "assertions": [ + "queued cancellation removes the request before Parcel starts its crawl", + "active cancellation requests one host restart and emits no stale subscribed acknowledgement", + "runtime batches receive directory metadata before child IPC", + "an FSEvents dropped-events error is reported as recoverable and later events continue", + "batches above the configured event limit become one overflow before stat or IPC fanout", + "while process.send is backpressured, later bursts retain at most one bounded pending batch and collapse excess paths to overflow", + "unsubscribe discards pending delivery before it can perform stale stat work" + ] + }, + { + "file": "src/main/ipc/parcel-watcher-event-delivery.test.ts", + "assertions": [ + "eight concurrent root batches never exceed eight active directory metadata stats within one child" + ] + }, + { + "file": "src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts", + "assertions": [ + "worktree deletion physically aborts pending native setup and settles before its crawl resolves", + "app shutdown physically aborts pending native setup and releases a late native handle" + ] + }, + { + "file": "src/main/runtime/file-watcher-host.test.ts", + "assertions": [ + "same-root clients share one physical watch and one client abort cannot cancel another owner's setup", + "shard failure preserves existing subscribers through isolated recovery and rejects stale-generation events", + "only failed isolated recovery ends subscribers, after a conservative overflow" + ] + }, + { + "file": "src/main/runtime/rpc/methods/files.test.ts", + "assertions": [ + "pre-ready transport abort reaches RuntimeFileCommands as the owning AbortSignal", + "terminal watcher loss flushes a final overflow change before error/end and unregisters cleanup" + ] + }, + { + "file": "src/main/providers/ssh-filesystem-provider.test.ts", + "assertions": [ + "an SSH caller abort rejects promptly without cancelling another same-root subscriber's shared setup", + "the physical relay request is cancelled when the last pending subscriber leaves", + "provider disposal cancels pending setup and sends one remote unwatch" + ] + }, + { + "file": "src/renderer/src/runtime/runtime-file-client.test.ts", + "assertions": [ + "terminal error evicts the shared renderer watch before an error callback can synchronously retry", + "the old stream's later end cannot evict or orphan the replacement stream", + "the end response releases retained preload and main-process subscription bookkeeping", + "a later same-root listener opens a fresh server subscription instead of reusing a dead stream" + ] + }, + { + "file": "src/relay/relay-filesystem-watch-registry.test.ts", + "assertions": [ + "each SSH host keeps its own healthy watcher supervisor while a standard repository's three distinct roots share that one child", + "shared-child failure moves those roots into separate quarantine supervisors with overflow and resumed events", + "replacement emits overflow before later ordered events", + "terminal shard failure preserves same-root clients through recovery", + "pending setup aborts only after the last same-root client leaves", + "a missing child fails closed even if the relay inherits a VITEST environment" + ] + }, + { + "file": "src/relay/fs-handler.test.ts", + "assertions": [ + "shared watches retain the twenty-root budget and release after every client leaves" + ] + }, + { + "file": "src/relay/subprocess.test.ts", + "assertions": [ + "the real relay bundle serves PTY, filesystem, and Git requests with its adjacent watcher child" + ] + }, + { + "file": "src/main/ssh/ssh-relay-versioned-install.test.ts", + "assertions": [ + "remote completeness requires relay.js, relay-watcher.js, and the install sentinel" + ] + }, + { + "file": "config/scripts/package-electron-runtime-contract.test.mjs", + "assertions": ["every release platform packages and gates the hashed watcher child"] + }, + { + "file": "config/scripts/runtime-file-watcher-fault-harness.mjs", + "assertions": [ + "the built watcher entry delivers an event before fault injection", + "the harness parent survives watcher-child SIGSEGV", + "the real supervisor automatically replaces the child, resubscribes, and delivers a post-crash event" + ] + }, + { + "file": "config/scripts/relay-watcher-fault-harness.mjs", + "assertions": [ + "the built relay and existing PTY survive watcher-child fault injection", + "replacement emits overflow and a precise post-crash event" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-filesystem-watch-registry.test.ts src/relay/fs-handler.test.ts src/relay/subprocess.test.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", + "result": "passed", + "durationSeconds": 4.5, + "summary": "Five relay registry, handler, bundled subprocess, install, and package contract files passed with 114 tests." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "node config/scripts/relay-watcher-fault-harness.mjs", + "result": "passed", + "durationSeconds": 0.61, + "summary": "The relay PID and existing PTY survived watcher-child SIGSEGV; overflow and precise events resumed from a replacement child." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts", + "result": "passed", + "durationSeconds": 1.7, + "summary": "10 focused supervisor, pool, child-entry, delivery, desktop/WSL cleanup, runtime-host, RPC, SSH ownership, and renderer-client test files passed with 223 tests." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "node config/scripts/runtime-file-watcher-fault-harness.mjs", + "result": "passed", + "durationSeconds": 5, + "summary": "All 26 built-entry SIGSEGV injections preserved the parent, replaced and resubscribed the child, and delivered a post-crash event; watcher-canary temp-directory count remained 6 before and after the final five-run leak check." + }, + { + "date": "2026-07-11", + "runner": "local", + "platform": "macos", + "command": "node config/scripts/runtime-file-watcher-fault-harness.mjs", + "result": "passed", + "durationSeconds": 0.3, + "summary": "The source-bundled real supervisor delivered through the built child before and after SIGSEGV; the parent survived and automatic resubscription used a replacement PID." + }, + { + "date": "2026-07-11", + "runner": "local", + "platform": "macos", + "command": "node config/scripts/runtime-file-watcher-fault-harness.mjs", + "result": "passed", + "durationSeconds": 2.2, + "summary": "Ten consecutive invocations survived, automatically replaced and resubscribed the child, and delivered a post-crash event. The watcher-canary temp-directory count remained 8 before and after the loop." + } + ], + "runtimeBudget": { + "p95Seconds": 60, + "scope": "focused process/RPC contract tests plus built child fault harness" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "New deterministic unit coverage and ten consecutive local built-entry harness passes; still below the CI and soak history required for promotion." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Red tests reproduced the reviewed failures: IPC disconnect-before-exit orphaned existing roots, queue-admission deadlines cascaded shard replacement, a fused shard created one child per root, a standard SSH repository created three healthy watcher supervisors, destructive desktop cleanup waited indefinitely for native setup, and synchronous renderer retry joined a terminal stream. Earlier red coverage also reproduced missing-entry fail-open, terminal recovery loss, stale same-root lease deletion, and unbounded per-child stat lanes. The bounded quarantine and cancellation rewrite passes those contracts, the relay-specific healthy width test reduces the three-root case to one supervisor, and the real-supervisor built-entry harness survives SIGSEGV with automatic replacement. CI/soak history is still absent." + }, + "performanceBudget": { + "required": true, + "evidence": "Local runtime operation remains capped at four shared child processes, while each SSH relay is capped at one healthy child instead of spreading a standard repository's three roots across three children. Based on the measured 51.9 MiB per child, normal SSH watcher overhead is bounded near 51.9 MiB rather than 155.7 MiB before relay and PTY overhead. Degraded operation adds at most four quarantine children, distributes affected roots by least load, disposes empty slots, and permits no replacement after a quarantine shard itself fuses. Each child caps directory stats at eight across its assigned roots (at most 32 healthy or 64 total logical stat operations for local runtime; 8 healthy or 40 total per SSH relay), each subscription retains at most one active plus one bounded pending batch, and no polling or serve-process stat fanout is added." + }, + "promotionCriteria": [ + "Run the built-entry SIGSEGV harness in soak for at least 100 consecutive passes or 14 days on macOS.", + "Add an eight-root/six-writer event-loop-delay soak artifact with an explicit latency budget.", + "Add a packaged orca serve pairing scenario that proves a connected client refreshes after child replacement." + ], + "knownGaps": [ + "The fault harness bundles the real source supervisor and drives the built child, but it does not launch a packaged orca serve pairing session or exercise app.asar path resolution.", + "The one-child SSH healthy and four-child quarantine memory ceilings are inferred from the earlier one-child measurement; direct degraded-state RSS plus sustained six-writer event-loop-delay and CPU soak evidence is still absent.", + "Linux native watcher fault containment is source-equivalent but not live fault-injected in this evidence run.", + "Windows runtime watches bypass this path; SSH registration ownership is deterministic-contract tested but not live-relay fault-injected." + ], + "demotionRule": "Demote or quarantine if the fault harness flakes without a product or harness bug, if healthy operation exceeds four local runtime watcher children or one child in any SSH relay, if total healthy plus quarantine operation exceeds eight locally or five in an SSH relay, if quarantine children outlive their roots or repeat after fusing, if event delivery becomes unbounded, or if metadata/stat work returns to the serve process." } ] } diff --git a/config/scripts/build-native-for-platform.mjs b/config/scripts/build-native-for-platform.mjs index 11ad15bc5f4..52900a0363b 100755 --- a/config/scripts/build-native-for-platform.mjs +++ b/config/scripts/build-native-for-platform.mjs @@ -2,6 +2,11 @@ import { spawnSync } from 'node:child_process' +if (process.platform === 'win32') { + runNodeScript('config/scripts/build-windows-cli-launcher.mjs') + process.exit(0) +} + if (process.platform !== 'darwin') { console.log(`[native-build] no macOS native computer build required on ${process.platform}`) process.exit(0) @@ -28,3 +33,13 @@ function runPnpmScript(scriptName) { process.exit(result.status ?? 1) } } + +function runNodeScript(scriptPath) { + const result = spawnSync(process.execPath, [scriptPath], { stdio: 'inherit' }) + if (result.signal) { + process.kill(process.pid, result.signal) + } + if (result.status !== 0 || result.error) { + process.exit(result.status ?? 1) + } +} diff --git a/config/scripts/build-relay-windows-node-pty-patch.test.mjs b/config/scripts/build-relay-windows-node-pty-patch.test.mjs index af4be74c1e1..b5ef98539e2 100644 --- a/config/scripts/build-relay-windows-node-pty-patch.test.mjs +++ b/config/scripts/build-relay-windows-node-pty-patch.test.mjs @@ -4,6 +4,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' +import { runInNewContext } from 'node:vm' import { describe, expect, it } from 'vitest' const projectRoot = resolve(import.meta.dirname, '../..') @@ -37,6 +38,14 @@ WindowsTerminal.prototype._deferNoArgs = function (deferredFn) { }; exports.WindowsTerminal = WindowsTerminal; ` +const vanillaConptyConsoleListAgent = `'use strict'; +var utils_1 = require("./utils"); +var getConsoleProcessList = utils_1.loadNativeModule('conpty_console_list').module.getConsoleProcessList; +var shellPid = parseInt(process.argv[2], 10); +var consoleProcessList = getConsoleProcessList(shellPid); +process.send({ consoleProcessList: consoleProcessList }); +process.exit(0); +` function loadHarness(modulePath) { delete require.cache[require.resolve(modulePath)] @@ -67,9 +76,11 @@ describe('packaged Windows SSH relay node-pty patch', () => { for (const platform of ['win32-x64', 'win32-arm64']) { const relayDir = join(projectRoot, 'out', 'relay', platform) const relayContent = readFileSync(join(relayDir, 'relay.js')) + const watcherContent = readFileSync(join(relayDir, 'relay-watcher.js')) const patchContent = readFileSync(join(relayDir, patchName)) const expectedHash = createHash('sha256') .update(relayContent) + .update(watcherContent) .update(patchContent) .digest('hex') .slice(0, 12) @@ -92,9 +103,17 @@ describe('packaged Windows SSH relay node-pty patch', () => { try { const modulePath = join(tempRoot, 'node_modules', 'node-pty', 'lib', 'windowsTerminal.js') const watcherPath = join(tempRoot, 'node_modules', '@parcel', 'watcher', 'index.js') + const consoleListPath = join( + tempRoot, + 'node_modules', + 'node-pty', + 'lib', + 'conpty_console_list_agent.js' + ) mkdirSync(dirname(modulePath), { recursive: true }) mkdirSync(dirname(watcherPath), { recursive: true }) writeFileSync(modulePath, vanillaWindowsTerminal, 'utf8') + writeFileSync(consoleListPath, vanillaConptyConsoleListAgent, 'utf8') writeFileSync(watcherPath, 'module.exports = {}\n', 'utf8') expect(loadHarness(modulePath)).toEqual({ @@ -102,7 +121,6 @@ describe('packaged Windows SSH relay node-pty patch', () => { nativeKillCount: 0, deferredCount: 1 }) - const patch = spawnSync( process.execPath, [join(projectRoot, 'out', 'relay', 'win32-x64', patchName), '--apply', tempRoot], @@ -115,6 +133,24 @@ describe('packaged Windows SSH relay node-pty patch', () => { nativeKillCount: 1, deferredCount: 0 }) + const sent = [] + runInNewContext(readFileSync(consoleListPath, 'utf8'), { + require: () => ({ + loadNativeModule: () => ({ + module: { + getConsoleProcessList: () => { + throw new Error('AttachConsole failed') + } + } + }) + }), + process: { + argv: ['node', consoleListPath, '42'], + exit: () => {}, + send: (message) => sent.push(message) + } + }) + expect(sent).toEqual([{ consoleProcessList: [42] }]) const secondPatch = spawnSync( process.execPath, diff --git a/config/scripts/build-relay.mjs b/config/scripts/build-relay.mjs index 1fba1da82f2..409821084e5 100644 --- a/config/scripts/build-relay.mjs +++ b/config/scripts/build-relay.mjs @@ -1,9 +1,9 @@ #!/usr/bin/env node /** - * Bundle the relay daemon into a single relay.js file per platform. + * Bundle the relay daemon and its crash-isolated watcher child per platform. * - * The relay runs on remote hosts via `node relay.js`, so it must be a - * self-contained CommonJS bundle with no external dependencies beyond + * The relay runs on remote hosts via `node relay.js`, so both outputs use + * self-contained CommonJS bundles with no external dependencies beyond * Node.js built-ins. Native addons (node-pty, @parcel/watcher) are * marked external and expected to be installed on the remote or * gracefully degraded. @@ -17,6 +17,7 @@ const __dirname = import.meta.dirname // Why: the script lives under config/scripts, so go two levels up to reach the repo root. const ROOT = join(__dirname, '..', '..') const RELAY_ENTRY = join(ROOT, 'src', 'relay', 'relay.ts') +const WATCHER_ENTRY = join(ROOT, 'src', 'main', 'ipc', 'parcel-watcher-process-entry.ts') const WINDOWS_NODE_PTY_PATCH_SOURCE = join( ROOT, 'config', @@ -49,7 +50,22 @@ for (const platform of PLATFORMS) { outfile: join(outDir, 'relay.js'), // Native addons cannot be bundled — they must exist on the remote host. // The relay gracefully degrades when they are absent. - external: ['node-pty', '@parcel/watcher'], + external: ['node-pty', '@parcel/watcher', 'electron'], + sourcemap: false, + minify: true, + define: { + 'process.env.NODE_ENV': '"production"' + } + }) + + await build({ + entryPoints: [WATCHER_ENTRY], + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + outfile: join(outDir, 'relay-watcher.js'), + external: ['@parcel/watcher'], sourcemap: false, minify: true, define: { @@ -58,9 +74,11 @@ for (const platform of PLATFORMS) { }) // Why: include a content hash so the deploy check detects code changes - // even when RELAY_VERSION hasn't been bumped (common during development). + // even when RELAY_VERSION hasn't been bumped. Hash both process artifacts + // and the Windows-only patch so every runtime dependency deploys together. const relayContent = readFileSync(join(outDir, 'relay.js')) - const artifactHash = createHash('sha256').update(relayContent) + const watcherContent = readFileSync(join(outDir, 'relay-watcher.js')) + const artifactHash = createHash('sha256').update(relayContent).update(watcherContent) if (platform.startsWith('win32-')) { // Why: SSH relays install node-pty with npm on the remote, outside pnpm's // patchedDependencies. Hashing the patch forces safe upgrade redeployment. diff --git a/config/scripts/build-windows-cli-launcher.mjs b/config/scripts/build-windows-cli-launcher.mjs new file mode 100644 index 00000000000..6f9b86bdb4b --- /dev/null +++ b/config/scripts/build-windows-cli-launcher.mjs @@ -0,0 +1,60 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process' +import { existsSync, mkdirSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' + +if (process.platform !== 'win32') { + // Why: electron-builder treats a skipped native build like success and can + // continue toward a Windows package whose declared orca.exe does not exist. + throw new Error( + 'Windows CLI launcher compilation requires a Windows host; refusing to package without it.' + ) +} + +const repoRoot = resolve(import.meta.dirname, '../..') +const sourcePath = join(repoRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs') +const outputPath = readArg('--output') ?? defaultOutputPath(repoRoot) +const compilerPath = findFrameworkCompiler(process.env) + +if (!compilerPath) { + throw new Error('Unable to find the .NET Framework C# compiler required for orca.exe.') +} + +mkdirSync(dirname(outputPath), { recursive: true }) +const result = spawnSync( + compilerPath, + ['/nologo', '/target:exe', '/optimize+', '/warnaserror+', `/out:${outputPath}`, sourcePath], + { cwd: repoRoot, stdio: 'inherit' } +) + +if (result.signal) { + process.kill(process.pid, result.signal) +} +if (result.error) { + throw result.error +} +if (result.status !== 0) { + process.exit(result.status ?? 1) +} + +function defaultOutputPath(projectRoot) { + return join(projectRoot, 'native', 'windows-cli-launcher', '.build', 'orca.exe') +} + +function findFrameworkCompiler(env) { + const windowsDirectory = env.WINDIR ?? env.SystemRoot + if (!windowsDirectory) { + return null + } + const candidates = [ + join(windowsDirectory, 'Microsoft.NET', 'Framework64', 'v4.0.30319', 'csc.exe'), + join(windowsDirectory, 'Microsoft.NET', 'Framework', 'v4.0.30319', 'csc.exe') + ] + return candidates.find((candidate) => existsSync(candidate)) ?? null +} + +function readArg(name) { + const index = process.argv.indexOf(name) + return index >= 0 ? process.argv[index + 1] : undefined +} diff --git a/config/scripts/build-windows-cli-launcher.test.mjs b/config/scripts/build-windows-cli-launcher.test.mjs new file mode 100644 index 00000000000..4af5a68dd5a --- /dev/null +++ b/config/scripts/build-windows-cli-launcher.test.mjs @@ -0,0 +1,87 @@ +import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { spawnSync } from 'node:child_process' +import { describe, expect, it } from 'vitest' + +const itWindows = process.platform === 'win32' ? it : it.skip +const itCrossHost = process.platform === 'win32' ? it.skip : it +const projectRoot = resolve(import.meta.dirname, '../..') + +describe('Windows CLI launcher', () => { + itCrossHost('fails closed when the Windows launcher cannot be compiled on this host', () => { + const outputRoot = mkdtempSync(join(tmpdir(), 'orca cross-host launcher ')) + try { + const result = spawnSync( + process.execPath, + ['config/scripts/build-windows-cli-launcher.mjs', '--output', join(outputRoot, 'orca.exe')], + { cwd: projectRoot, encoding: 'utf8' } + ) + + expect(result.status).not.toBe(0) + expect(result.stderr).toContain('Windows CLI launcher') + expect(result.stderr).toContain('Windows host') + } finally { + rmSync(outputRoot, { recursive: true, force: true }) + } + }) + + itWindows('preserves a multiline argument from PowerShell through the native launcher', () => { + const appRoot = mkdtempSync(join(tmpdir(), 'orca cli launcher ')) + try { + const resourcesPath = join(appRoot, 'resources') + const launcherPath = join(resourcesPath, 'bin', 'orca.exe') + const cliPath = join(resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js') + mkdirSync(join(resourcesPath, 'bin'), { recursive: true }) + mkdirSync(dirname(cliPath), { recursive: true }) + copyFileSync(process.execPath, join(appRoot, 'Orca.exe')) + writeFileSync( + cliPath, + `process.stdout.write(JSON.stringify({ + argv: process.argv.slice(2), + electronRunAsNode: process.env.ELECTRON_RUN_AS_NODE, + nodeOptions: process.env.NODE_OPTIONS ?? null, + orcaNodeOptions: process.env.ORCA_NODE_OPTIONS ?? null +}))\n`, + 'utf8' + ) + + const build = spawnSync( + process.execPath, + ['config/scripts/build-windows-cli-launcher.mjs', '--output', launcherPath], + { cwd: projectRoot, encoding: 'utf8' } + ) + expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0) + + const body = 'paragraph one line one\nparagraph one line two\n\nparagraph two' + const powershell = spawnSync( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + '& $env:ORCA_TEST_LAUNCHER orchestration send --body $env:ORCA_TEST_BODY --json' + ], + { + encoding: 'utf8', + env: { + ...process.env, + NODE_OPTIONS: '--no-warnings', + ORCA_TEST_BODY: body, + ORCA_TEST_LAUNCHER: launcherPath + } + } + ) + + expect(powershell.status, powershell.stderr).toBe(0) + expect(JSON.parse(powershell.stdout)).toEqual({ + argv: ['orchestration', 'send', '--body', body, '--json'], + electronRunAsNode: '1', + nodeOptions: null, + orcaNodeOptions: '--no-warnings' + }) + } finally { + rmSync(appRoot, { recursive: true, force: true }) + } + }) +}) diff --git a/config/scripts/check-terminal-perf-report-budgets.mjs b/config/scripts/check-terminal-perf-report-budgets.mjs index dd7185189d8..62f9d2ede99 100644 --- a/config/scripts/check-terminal-perf-report-budgets.mjs +++ b/config/scripts/check-terminal-perf-report-budgets.mjs @@ -20,6 +20,9 @@ const BUDGETS = { maxWorstKeyLatencyMs: 300, maxRevisitLatencyMs: 300, maxTimerDriftMs: 150, + // Why: mirrors MAX_TIMER_DRIFT_UNDER_LOAD_MS in artificial-opencode-terminal-load.spec.ts + // so injected multi-pane redraw rows are not judged against the unloaded ceiling. + maxTimerDriftUnderLoadMs: 2_500, maxScrollLatencyMs: 150, maxRestoreLatencyMs: 1000, maxRendererQueuedChars: 2 * 1024 * 1024, @@ -27,6 +30,17 @@ const BUDGETS = { maxRendererDroppedBacklogs: 0 } +// Why: only these annotation types assert against MAX_TIMER_DRIFT_UNDER_LOAD_MS +// in the e2e suite; other rows keep the unloaded smoke ceiling. +function isUnderLoadTimerDriftScenario(scenario) { + return ( + scenario === 'opencode-same-workspace-typing' || + scenario === 'opencode-cross-workspace-typing' || + scenario.startsWith('opencode-scale-same-workspace-') || + scenario.startsWith('opencode-scale-cross-workspace-') + ) +} + function parseMs(value, fieldName, row, failures) { if (value == null || value === '') { return null @@ -90,7 +104,9 @@ function validateRow(row) { addBudgetCheck( 'timer drift', parseMs(row.maxTimerDrift, 'maxTimerDrift', row, failures), - BUDGETS.maxTimerDriftMs, + isUnderLoadTimerDriftScenario(row.scenario) + ? BUDGETS.maxTimerDriftUnderLoadMs + : BUDGETS.maxTimerDriftMs, 'ms' ) addBudgetCheck( diff --git a/config/scripts/check-terminal-perf-report-budgets.test.mjs b/config/scripts/check-terminal-perf-report-budgets.test.mjs index e6129975151..0a51783841a 100644 --- a/config/scripts/check-terminal-perf-report-budgets.test.mjs +++ b/config/scripts/check-terminal-perf-report-budgets.test.mjs @@ -107,6 +107,41 @@ describe('check-terminal-perf-report-budgets', () => { expect(result.stderr).toContain('renderer dropped backlogs 1 exceeded budget 0') }) + // Why: covers every isUnderLoadTimerDriftScenario branch (two exact + two + // prefix matches) so a predicate regression cannot silently re-apply the + // unloaded 150ms ceiling to multi-pane redraw rows. + it.each([ + 'opencode-same-workspace-typing', + 'opencode-cross-workspace-typing', + 'opencode-scale-same-workspace-50', + 'opencode-scale-cross-workspace-50' + ])('applies the under-load timer-drift budget to %s', (scenario) => { + const passPath = writeReport( + ['panes=50', 'frames=60', 'median=12.0ms', 'worst=40.0ms', 'maxTimerDrift=1510.0ms'].join( + ' ' + ), + scenario + ) + + const passOutput = execFileSync(process.execPath, [scriptPath, passPath], { + cwd: process.cwd(), + encoding: 'utf8' + }) + expect(passOutput).toContain('Terminal perf budget check passed for 1 annotation row(s).') + }) + + it('fails multi-pane redraw scenarios that exceed the under-load timer-drift budget', () => { + const failPath = writeReport( + ['panes=50', 'frames=60', 'median=12.0ms', 'worst=40.0ms', 'maxTimerDrift=2501.0ms'].join( + ' ' + ), + 'opencode-cross-workspace-typing' + ) + const failResult = runChecker(failPath) + expect(failResult.status).toBe(1) + expect(failResult.stderr).toContain('timer drift 2501ms exceeded budget 2500ms') + }) + it('fails malformed metric values instead of treating them as absent', () => { const reportPath = writeReport('panes=1 median=999 worst=abcms rendererQueuedChars=wat') diff --git a/config/scripts/computer-use-skill-guidance.test.mjs b/config/scripts/computer-use-skill-guidance.test.mjs index 0d9d4aa2e43..a3e214b3a5c 100644 --- a/config/scripts/computer-use-skill-guidance.test.mjs +++ b/config/scripts/computer-use-skill-guidance.test.mjs @@ -33,4 +33,11 @@ describe('computer-use skill guidance', () => { expect(skill).toContain('use `--restore-window` so another window does not cover') expect(skill).toContain('trust the tree over potentially occluded pixels') }) + + it('points JSON users to the public accessibility-tree field', () => { + const skill = readFileSync(skillPath, 'utf8') + + expect(skill).toContain('`result.snapshot.treeText`') + expect(skill).not.toContain('`result.elements`') + }) }) diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index cd46c8003fa..faf29fa244f 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -58,6 +58,10 @@ describe('electron-builder config', () => { expect.objectContaining({ from: 'native/computer-use-windows/runtime.ps1', to: 'computer-use-windows/runtime.ps1' + }), + expect.objectContaining({ + from: 'native/windows-cli-launcher/.build/orca.exe', + to: 'bin/orca.exe' }) ]) ) diff --git a/config/scripts/electron-builder-native-rebuild.cjs b/config/scripts/electron-builder-native-rebuild.cjs index 2aa847db2eb..d4ab82f9430 100644 --- a/config/scripts/electron-builder-native-rebuild.cjs +++ b/config/scripts/electron-builder-native-rebuild.cjs @@ -9,6 +9,12 @@ function electronBuilderNativeRebuild(context) { function runElectronBuilderNativeRebuild(context, runner = execFileSync) { const args = buildNativeRebuildArgs(context) + if (readPlatformName(context?.platform) === 'win32') { + runner(process.execPath, ['config/scripts/build-windows-cli-launcher.mjs'], { + cwd: projectDir, + stdio: 'inherit' + }) + } runner(process.execPath, args, { cwd: projectDir, stdio: 'inherit' diff --git a/config/scripts/electron-builder-native-rebuild.test.mjs b/config/scripts/electron-builder-native-rebuild.test.mjs index e92c62b7a3e..5d382934533 100644 --- a/config/scripts/electron-builder-native-rebuild.test.mjs +++ b/config/scripts/electron-builder-native-rebuild.test.mjs @@ -42,6 +42,31 @@ describe('electron-builder native rebuild hook', () => { ]) }) + it('builds the native CLI launcher before packaging Windows resources', () => { + const calls = [] + const result = runElectronBuilderNativeRebuild( + { + platform: { nodeName: 'win32' }, + arch: 'x64' + }, + (...args) => calls.push(args) + ) + + expect(result).toBe(false) + expect(calls).toEqual([ + [ + process.execPath, + ['config/scripts/build-windows-cli-launcher.mjs'], + expect.objectContaining({ stdio: 'inherit' }) + ], + [ + process.execPath, + ['config/scripts/rebuild-native-deps.mjs', '--platform=win32', '--arch=x64', '--force'], + expect.objectContaining({ stdio: 'inherit' }) + ] + ]) + }) + it('rejects incomplete electron-builder contexts', () => { expect(() => buildNativeRebuildArgs({ arch: 'x64' })).toThrow(/platform/) expect(() => buildNativeRebuildArgs({ platform: { nodeName: 'linux' } })).toThrow(/arch/) diff --git a/config/scripts/orca-cli-skill-guidance.test.mjs b/config/scripts/orca-cli-skill-guidance.test.mjs index 3fb29539d9e..b529e2eee0e 100644 --- a/config/scripts/orca-cli-skill-guidance.test.mjs +++ b/config/scripts/orca-cli-skill-guidance.test.mjs @@ -4,9 +4,11 @@ import { describe, expect, it } from 'vitest' const projectDir = resolve(import.meta.dirname, '../..') const skillPath = join(projectDir, 'skills', 'orca-cli', 'SKILL.md') +const orchestrationSkillPath = join(projectDir, 'skills', 'orchestration', 'SKILL.md') +const emulatorSkillPath = join(projectDir, 'skills', 'orca-emulator', 'SKILL.md') -function readSkill() { - return readFileSync(skillPath, 'utf8') +function readSkill(path = skillPath) { + return readFileSync(path, 'utf8') } describe('orca CLI skill guidance', () => { @@ -45,6 +47,43 @@ describe('orca CLI skill guidance', () => { expect(skill).toContain('send the prompt, and stop') }) + it('prefers agent-first workers without duplicating terminal delivery', () => { + const skill = readSkill() + + expect(skill).toContain('Prefer agent-first create for agent workers') + expect(skill).toContain('fallback shell plus a later `terminal create') + expect(skill).toContain('Repo setup or default-terminal settings may still add tabs or splits') + expect(skill).toContain( + 'when no repo default-terminal configuration supplies a primary terminal' + ) + expect(skill).toContain('Configured default tabs are materialized instead') + expect(skill).toContain( + 'only after `terminal list` or `terminal show` confirms it is an unused shell' + ) + expect(skill).not.toContain('bare `worktree create` (no `--agent`) still opens') + expect(skill).not.toContain('ends with **one** tab') + expect(skill).toContain('Use `startupTerminal.handle` as the sole agent handle') + expect(skill).toContain('never dual-send to old and replacement handles') + expect(skill).toContain( + "this checks the caller's inbox and does not remotely deliver input to another terminal" + ) + }) + + it('requires full worktree ids across bundled agent guidance', () => { + const cliSkill = readSkill() + const orchestrationSkill = readSkill(orchestrationSkillPath) + const emulatorSkill = readSkill(emulatorSkillPath) + + for (const skill of [cliSkill, orchestrationSkill, emulatorSkill]) { + expect(skill).toContain('::') + expect(skill).toContain('bare repo id') + } + expect(cliSkill).toContain('id:::') + expect(cliSkill).toContain('two-part address') + expect(orchestrationSkill).toContain('id:') + expect(emulatorSkill).not.toContain('id:abc123') + }) + it('keeps browser injection guidance narrow and avoids literal secret examples', () => { const skill = readSkill() diff --git a/config/scripts/orchestration-skill-guidance.test.mjs b/config/scripts/orchestration-skill-guidance.test.mjs index 78a51ad10a7..16747b1ad3d 100644 --- a/config/scripts/orchestration-skill-guidance.test.mjs +++ b/config/scripts/orchestration-skill-guidance.test.mjs @@ -11,7 +11,9 @@ function readSkill() { function getSection(markdown, heading) { const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const match = markdown.match(new RegExp(`## ${escapedHeading}\\n([\\s\\S]*?)(?=\\n## |$)`)) + const match = markdown.match( + new RegExp(`## ${escapedHeading}\\r?\\n([\\s\\S]*?)(?=\\r?\\n## |$)`) + ) expect(match).not.toBeNull() @@ -188,4 +190,48 @@ describe('orchestration skill guidance', () => { expect(skill).not.toContain('post-completion polling messages') expect(skill).not.toContain('every 2 minutes') }) + + it('documents @grok in the Messaging group address list', () => { + const skill = readSkill() + const messaging = getSection(skill, 'Messaging') + + expect(messaging).toContain('`@grok`') + }) + + it('documents @cursor in the Messaging group address list', () => { + const skill = readSkill() + const messaging = getSection(skill, 'Messaging') + + expect(messaging).toContain('`@cursor`') + }) + + it('keeps agent-first launch, handle recovery, and inbox injection distinct', () => { + const skill = readSkill() + const messaging = getSection(skill, 'Messaging') + const workerTerminals = getSection(skill, 'Worker Terminals') + const agentFirstExample = workerTerminals.match( + /```bash\norca worktree create --name --agent codex --json\n[\s\S]*?```/ + )?.[0] + + expect(workerTerminals).toContain('Agent-first (required for ordinary agent workers)') + expect(workerTerminals).toContain('fallback shell + agent pair') + expect(workerTerminals).toContain( + 'Repo setup or default-terminal settings may still add tabs or splits' + ) + expect(workerTerminals).toContain('without configured default tabs') + expect(workerTerminals).toContain( + 'only after `terminal list` or `terminal show` confirms it is an unused shell' + ) + expect(workerTerminals).not.toContain('bare create opens a default shell') + expect(workerTerminals).not.toContain('ends with **one** agent tab') + expect(agentFirstExample).toBeDefined() + expect(agentFirstExample).not.toContain('orca terminal list') + expect(agentFirstExample).toContain('startupTerminal.handle') + expect(messaging).toContain( + 'Use `startupTerminal.handle` from the create response when present' + ) + expect(messaging).toContain('continue with the replacement only') + expect(messaging).toContain('it does not remotely wake another terminal') + expect(messaging).toContain('Use `orchestration dispatch --inject` to deliver a tracked task') + }) }) diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 5e70cf68f42..e4a118a311f 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -91,6 +91,77 @@ describe('Electron runtime package contract', () => { ) }) + it('blocks Linux and macOS release packaging on watcher process fault recovery', () => { + const releaseWorkflow = parse( + readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8') + ) + const macWorkflow = parse( + readFileSync(join(projectDir, '.github/workflows/release-mac-build.yml'), 'utf8') + ) + const assertFaultGate = (steps, publishStepName, expectedCondition) => { + const names = steps.map((step) => step.name) + const gate = steps.find((step) => step.name === 'Gate runtime file-watcher process isolation') + + expect(gate.if).toBe(expectedCondition) + expect(gate['continue-on-error']).toBeUndefined() + expect(gate.run).toContain('node config/scripts/runtime-file-watcher-fault-harness.mjs') + expect(gate.run).toContain('ELECTRON_RUN_AS_NODE=1 pnpm exec electron') + expect(names.indexOf('Build app')).toBeLessThan(names.indexOf(gate.name)) + expect(names.indexOf(gate.name)).toBeLessThan(names.indexOf(publishStepName)) + } + + assertFaultGate( + releaseWorkflow.jobs.build.steps, + 'Publish release artifacts (Linux)', + "runner.os == 'Linux'" + ) + assertFaultGate( + macWorkflow.jobs['build-mac'].steps, + 'Publish release artifacts (macOS)', + undefined + ) + }) + + it('packages and release-gates the SSH relay watcher child', () => { + const relayBuild = readFileSync(join(projectDir, 'config/scripts/build-relay.mjs'), 'utf8') + const builderConfig = readFileSync( + join(projectDir, 'config/electron-builder.config.cjs'), + 'utf8' + ) + const remoteCommands = readFileSync( + join(projectDir, 'src/main/ssh/ssh-remote-commands.ts'), + 'utf8' + ) + const releaseWorkflow = parse( + readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8') + ) + const macWorkflow = parse( + readFileSync(join(projectDir, '.github/workflows/release-mac-build.yml'), 'utf8') + ) + + expect(relayBuild).toContain("'parcel-watcher-process-entry.ts'") + expect(relayBuild).toContain("outfile: join(outDir, 'relay-watcher.js')") + expect(relayBuild).toContain("readFileSync(join(outDir, 'relay-watcher.js'))") + expect(builderConfig).toContain("from: 'out/relay'") + expect(remoteCommands).toContain("joinRemotePath(host, remoteRelayDir, 'relay-watcher.js')") + + const assertRelayGate = (steps, publishStepName) => { + const names = steps.map((step) => step.name) + const gate = steps.find((step) => step.name === 'Gate SSH relay watcher process isolation') + expect(gate['continue-on-error']).toBeUndefined() + expect(gate.run).toContain('node config/scripts/relay-watcher-fault-harness.mjs') + expect(names.indexOf('Build app')).toBeLessThan(names.indexOf(gate.name)) + expect(names.indexOf(gate.name)).toBeLessThan(names.indexOf(publishStepName)) + } + + assertRelayGate(releaseWorkflow.jobs.build.steps, 'Publish release artifacts (Linux)') + assertRelayGate(macWorkflow.jobs['build-mac'].steps, 'Publish release artifacts (macOS)') + const releaseNames = releaseWorkflow.jobs.build.steps.map((step) => step.name) + expect(releaseNames.indexOf('Gate SSH relay watcher process isolation')).toBeLessThan( + releaseNames.indexOf('Build Windows release artifacts') + ) + }) + it('pins the Windows release builder to the VS 2022 runner image', () => { const releaseWorkflow = parse( readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8') diff --git a/config/scripts/relay-node-pty-windows-pre-ready-shutdown-patch.cjs b/config/scripts/relay-node-pty-windows-pre-ready-shutdown-patch.cjs index 3a4a41af34c..c8b6314dd9a 100644 --- a/config/scripts/relay-node-pty-windows-pre-ready-shutdown-patch.cjs +++ b/config/scripts/relay-node-pty-windows-pre-ready-shutdown-patch.cjs @@ -30,6 +30,15 @@ const PATCHED_KILL = ` WindowsTerminal.prototype.kill = function (signal) { this._close(); this._agent.kill(); };` +const VANILLA_CONPTY_CONSOLE_LIST = `var consoleProcessList = getConsoleProcessList(shellPid);` +const PATCHED_CONPTY_CONSOLE_LIST = `var consoleProcessList; +try { + consoleProcessList = getConsoleProcessList(shellPid); +} +catch (_a) { + // Orca: AttachConsole can fail after the shell exits; preserve the root pid fallback. + consoleProcessList = [shellPid]; +}` function replaceExactlyOnce(source, before, after, label) { const first = source.indexOf(before) @@ -51,20 +60,45 @@ function patchWindowsTerminal(source) { return replaceExactlyOnce(withDestroy, VANILLA_KILL, PATCHED_KILL, 'kill()') } -function applyPatch(rootDir = process.cwd()) { - const target = join(rootDir, 'node_modules', 'node-pty', 'lib', 'windowsTerminal.js') - const source = readFileSync(target, 'utf8') - const patched = patchWindowsTerminal(source) - if (patched !== source) { - const pendingTarget = `${target}.orca-patch-pending` - try { - writeFileSync(pendingTarget, patched, 'utf8') - renameSync(pendingTarget, target) - } finally { - rmSync(pendingTarget, { force: true }) - } +function patchConptyConsoleListAgent(source) { + if (source.includes(PATCHED_CONPTY_CONSOLE_LIST)) { + return source } - return target + return replaceExactlyOnce( + source, + VANILLA_CONPTY_CONSOLE_LIST, + PATCHED_CONPTY_CONSOLE_LIST, + 'ConPTY console-list fallback' + ) +} + +function writeAtomicallyIfChanged(target, source, patched) { + if (patched === source) { + return + } + const pendingTarget = `${target}.orca-patch-pending` + try { + writeFileSync(pendingTarget, patched, 'utf8') + renameSync(pendingTarget, target) + } finally { + rmSync(pendingTarget, { force: true }) + } +} + +function applyPatch(rootDir = process.cwd()) { + const moduleRoot = join(rootDir, 'node_modules', 'node-pty', 'lib') + const terminalTarget = join(moduleRoot, 'windowsTerminal.js') + const terminalSource = readFileSync(terminalTarget, 'utf8') + writeAtomicallyIfChanged(terminalTarget, terminalSource, patchWindowsTerminal(terminalSource)) + + const consoleListTarget = join(moduleRoot, 'conpty_console_list_agent.js') + const consoleListSource = readFileSync(consoleListTarget, 'utf8') + writeAtomicallyIfChanged( + consoleListTarget, + consoleListSource, + patchConptyConsoleListAgent(consoleListSource) + ) + return terminalTarget } function checkNativeDeps(rootDir = process.cwd()) { @@ -73,6 +107,16 @@ function checkNativeDeps(rootDir = process.cwd()) { if (!source.includes(PATCHED_DESTROY) || !source.includes(PATCHED_KILL)) { throw new Error('node-pty Windows pre-ready shutdown patch is missing') } + const consoleListTarget = join( + rootDir, + 'node_modules', + 'node-pty', + 'lib', + 'conpty_console_list_agent.js' + ) + if (!readFileSync(consoleListTarget, 'utf8').includes(PATCHED_CONPTY_CONSOLE_LIST)) { + throw new Error('node-pty ConPTY console-list fallback patch is missing') + } require.resolve('@parcel/watcher', { paths: [rootDir] }) return target } @@ -91,4 +135,10 @@ if (require.main === module) { } } -module.exports = { PATCH_MARKER, applyPatch, checkNativeDeps, patchWindowsTerminal } +module.exports = { + PATCH_MARKER, + applyPatch, + checkNativeDeps, + patchConptyConsoleListAgent, + patchWindowsTerminal +} diff --git a/config/scripts/relay-watcher-fault-harness.mjs b/config/scripts/relay-watcher-fault-harness.mjs new file mode 100644 index 00000000000..c86ba764979 --- /dev/null +++ b/config/scripts/relay-watcher-fault-harness.mjs @@ -0,0 +1,286 @@ +import { build } from 'esbuild' +import { spawn } from 'node:child_process' +import { createRequire } from 'node:module' +import { existsSync } from 'node:fs' +import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' + +const WAIT_TIMEOUT_MS = 20_000 +const require = createRequire(import.meta.url) + +function withTimeout(promise, label, stderr) { + return new Promise((resolvePromise, rejectPromise) => { + const timer = setTimeout(() => { + rejectPromise(new Error(`Timed out waiting for ${label}\n${stderr()}`)) + }, WAIT_TIMEOUT_MS) + promise.then( + (value) => { + clearTimeout(timer) + resolvePromise(value) + }, + (error) => { + clearTimeout(timer) + rejectPromise(error) + } + ) + }) +} + +function pollUntil(readValue, label, stderr) { + const deadline = Date.now() + WAIT_TIMEOUT_MS + return new Promise((resolveValue, rejectValue) => { + const poll = async () => { + try { + const value = await readValue() + if (value !== undefined) { + resolveValue(value) + return + } + } catch {} + if (Date.now() >= deadline) { + rejectValue(new Error(`Timed out waiting for ${label}\n${stderr()}`)) + return + } + setTimeout(poll, 10) + } + void poll() + }) +} + +function waitForExit(proc) { + if (proc.exitCode !== null || proc.signalCode !== null) { + return Promise.resolve() + } + return new Promise((resolveExit) => proc.once('exit', resolveExit)) +} + +async function loadProtocol(bundleDir) { + const outfile = join(bundleDir, 'relay-protocol.cjs') + await build({ + entryPoints: [resolve('src/relay/protocol.ts')], + bundle: true, + platform: 'node', + format: 'cjs', + outfile, + logLevel: 'silent' + }) + return require(outfile) +} + +function createRelayClient(entryPath, args, env, protocol) { + const proc = spawn(process.execPath, [entryPath, ...args], { + cwd: dirname(entryPath), + env, + stdio: ['pipe', 'pipe', 'pipe'] + }) + const messages = [] + let stderr = '' + let nextSequence = 1 + let stdoutBuffer = Buffer.alloc(0) + let ready = false + let resolveReady + const sentinelReceived = new Promise((resolvePromise) => { + resolveReady = resolvePromise + }) + const decoder = new protocol.FrameDecoder((frame) => { + if (frame.type === protocol.MessageType.Regular) { + messages.push(protocol.parseJsonRpcMessage(frame.payload)) + } + }) + proc.stderr.on('data', (chunk) => { + stderr = `${stderr}${String(chunk)}`.slice(-8_000) + }) + proc.stdout.on('data', (chunk) => { + if (ready) { + decoder.feed(chunk) + return + } + stdoutBuffer = Buffer.concat([stdoutBuffer, chunk]) + const sentinel = Buffer.from(protocol.RELAY_SENTINEL) + const index = stdoutBuffer.indexOf(sentinel) + if (index < 0) { + return + } + ready = true + resolveReady() + const remainder = stdoutBuffer.subarray(index + sentinel.length) + if (remainder.length > 0) { + decoder.feed(remainder) + } + }) + + const waitForMessage = (startIndex, predicate, label) => + pollUntil( + () => messages.slice(startIndex).find(predicate), + label, + () => stderr + ) + + const request = async (method, params = {}) => { + const id = nextSequence++ + const startIndex = messages.length + proc.stdin.write(protocol.encodeJsonRpcFrame({ jsonrpc: '2.0', id, method, params }, id, 0)) + const response = await waitForMessage( + startIndex, + (message) => message.id === id, + `response to ${method}` + ) + if (response.error) { + throw new Error(`${method} failed: ${response.error.message}`) + } + return response.result + } + + const notify = (method, params = {}) => { + const sequence = nextSequence++ + proc.stdin.write(protocol.encodeJsonRpcFrame({ jsonrpc: '2.0', method, params }, sequence, 0)) + } + + return { + proc, + request, + notify, + sentinelReceived: withTimeout(sentinelReceived, 'relay sentinel', () => stderr), + messageCount: () => messages.length, + waitForNotification: (startIndex, method, predicate = () => true) => + waitForMessage( + startIndex, + (message) => message.method === method && predicate(message.params ?? {}), + `${method} notification` + ), + stderr: () => stderr + } +} + +async function waitForWatcherPid(pidFile, previousPid, stderr) { + return pollUntil( + async () => { + const pid = Number((await readFile(pidFile, 'utf8')).trim()) + if (!Number.isInteger(pid) || pid <= 0 || pid === previousPid) { + return undefined + } + // Why: replacement children reuse this exclusive path after fault injection. + await rm(pidFile, { force: true }) + return pid + }, + previousPid ? 'replacement watcher child pid' : 'initial watcher child pid', + stderr + ) +} + +function includesWatchPath(params, targetPath) { + return Array.isArray(params.events) + ? params.events.some((event) => event.absolutePath === targetPath) + : false +} + +async function main() { + const platform = `${process.platform}-${process.arch}` + const relayEntry = resolve('out', 'relay', platform, 'relay.js') + const watcherEntry = resolve('out', 'relay', platform, 'relay-watcher.js') + if (!existsSync(relayEntry) || !existsSync(watcherEntry)) { + throw new Error(`Missing built relay artifacts for ${platform}; run pnpm run build:relay first`) + } + + let tempRoot + let relay + try { + tempRoot = await mkdtemp(join(tmpdir(), 'orca-relay-watcher-fault-')) + const watchRoot = await realpath(tempRoot) + const pidFile = join(tempRoot, 'watcher.pid') + const protocol = await loadProtocol(tempRoot) + const socketPath = + process.platform === 'win32' + ? `\\\\.\\pipe\\orca-relay-watcher-fault-${process.pid}-${Date.now()}` + : join(tempRoot, 'relay.sock') + relay = createRelayClient( + relayEntry, + ['--sock-path', socketPath, '--endpoint-dir', join(tempRoot, 'agent-hooks')], + { ...process.env, ORCA_WATCHER_CHILD_PID_FILE: pidFile }, + protocol + ) + await relay.sentinelReceived + + const spawned = await relay.request('pty.spawn', { cols: 80, rows: 24, cwd: watchRoot }) + const beforePtyMarker = `ORCA_PTY_BEFORE_${Date.now()}` + let startIndex = relay.messageCount() + relay.notify('pty.data', { id: spawned.id, data: `echo ${beforePtyMarker}\r` }) + await relay.waitForNotification( + startIndex, + 'pty.data', + (params) => params.id === spawned.id && String(params.data).includes(beforePtyMarker) + ) + + await relay.request('fs.watch', { rootPath: watchRoot }) + const firstWatcherPid = await waitForWatcherPid(pidFile, undefined, relay.stderr) + const beforePath = join(watchRoot, 'before.txt') + startIndex = relay.messageCount() + await writeFile(beforePath, 'before') + await relay.waitForNotification(startIndex, 'fs.changed', (params) => + includesWatchPath(params, beforePath) + ) + + const faultSignal = process.platform === 'win32' ? 'SIGTERM' : 'SIGSEGV' + startIndex = relay.messageCount() + process.kill(firstWatcherPid, faultSignal) + const replacementWatcherPid = await waitForWatcherPid(pidFile, firstWatcherPid, relay.stderr) + await relay.waitForNotification(startIndex, 'fs.changed', (params) => + Array.isArray(params.events) + ? params.events.some( + (event) => event.kind === 'overflow' && event.absolutePath === watchRoot + ) + : false + ) + + const status = await relay.request('relay.status') + if (status.pid !== relay.proc.pid) { + throw new Error('relay.status did not come from the original surviving relay process') + } + const afterPtyMarker = `ORCA_PTY_AFTER_${Date.now()}` + startIndex = relay.messageCount() + relay.notify('pty.data', { id: spawned.id, data: `echo ${afterPtyMarker}\r` }) + await relay.waitForNotification( + startIndex, + 'pty.data', + (params) => params.id === spawned.id && String(params.data).includes(afterPtyMarker) + ) + + const afterPath = join(watchRoot, 'after.txt') + startIndex = relay.messageCount() + await writeFile(afterPath, 'after') + await relay.waitForNotification(startIndex, 'fs.changed', (params) => + includesWatchPath(params, afterPath) + ) + + relay.notify('fs.unwatch', { rootPath: watchRoot }) + await relay.request('pty.shutdown', { id: spawned.id }) + console.log( + JSON.stringify({ + relayPid: relay.proc.pid, + killedWatcherPid: firstWatcherPid, + replacementWatcherPid, + faultSignal, + relaySurvived: true, + existingPtySurvived: true, + overflowRefreshDelivered: true, + postCrashEventDelivered: true + }) + ) + } finally { + if (relay && relay.proc.exitCode === null && relay.proc.signalCode === null) { + relay.proc.kill('SIGTERM') + try { + await withTimeout(waitForExit(relay.proc), 'relay shutdown', relay.stderr) + } catch { + relay.proc.kill('SIGKILL') + await withTimeout(waitForExit(relay.proc), 'forced relay shutdown', relay.stderr) + } + } + if (tempRoot) { + await rm(tempRoot, { recursive: true, force: true }) + } + } +} + +await main() diff --git a/config/scripts/run-ssh-docker-watcher-isolation-e2e.mjs b/config/scripts/run-ssh-docker-watcher-isolation-e2e.mjs new file mode 100644 index 00000000000..5fc10e9e491 --- /dev/null +++ b/config/scripts/run-ssh-docker-watcher-isolation-e2e.mjs @@ -0,0 +1,41 @@ +import { spawnSync } from 'node:child_process' + +const rawExtraArgs = process.argv.slice(2) +const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs +const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' +const env = { + ...process.env, + ORCA_E2E_SSH_DOCKER: '1' +} + +// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows. +const spawnOptions = { + stdio: 'inherit', + env, + shell: process.platform === 'win32' +} + +const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions) + +if (runtime.status !== 0) { + process.exit(runtime.status ?? 1) +} + +const result = spawnSync( + pnpm, + [ + 'exec', + 'playwright', + 'test', + 'tests/e2e/ssh-docker-watcher-isolation.spec.ts', + '--config', + 'tests/playwright.config.ts', + '--project', + 'electron-headless', + '--workers=1', + ...extraArgs + ], + spawnOptions +) + +process.exit(result.status ?? 1) diff --git a/config/scripts/runtime-file-watcher-fault-harness.mjs b/config/scripts/runtime-file-watcher-fault-harness.mjs new file mode 100644 index 00000000000..88fec4245fb --- /dev/null +++ b/config/scripts/runtime-file-watcher-fault-harness.mjs @@ -0,0 +1,180 @@ +import { createRequire } from 'node:module' +import { existsSync } from 'node:fs' +import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { build } from 'esbuild' + +const ENTRY_PATH = resolve('out/main/parcel-watcher-process-entry.js') +const SUPERVISOR_SOURCE = resolve('src/main/ipc/parcel-watcher-process-supervisor.ts') +const WAIT_TIMEOUT_MS = 15_000 +const require = createRequire(import.meta.url) + +function withTimeout(promise, label) { + return new Promise((resolvePromise, rejectPromise) => { + const timer = setTimeout( + () => rejectPromise(new Error(`Timed out waiting for ${label}`)), + WAIT_TIMEOUT_MS + ) + promise.then( + (value) => { + clearTimeout(timer) + resolvePromise(value) + }, + (error) => { + clearTimeout(timer) + rejectPromise(error) + } + ) + }) +} + +function nextMatchingEvent(register, predicate, label) { + return withTimeout( + new Promise((resolveEvent) => { + register((events) => { + if (events.some(predicate)) { + resolveEvent(events) + } + }) + }), + label + ) +} + +async function loadSupervisor(bundleDir) { + const outfile = join(bundleDir, 'watcher-supervisor.cjs') + await build({ + entryPoints: [SUPERVISOR_SOURCE], + bundle: true, + platform: 'node', + format: 'cjs', + outfile, + external: ['@parcel/watcher', 'electron'], + logLevel: 'silent' + }) + return require(outfile).WatcherProcessSupervisor +} + +async function main() { + if (process.platform === 'win32') { + console.log('[runtime-file-watcher-fault] SKIP: SIGSEGV oracle is macOS/Linux only') + return + } + if (!existsSync(ENTRY_PATH)) { + throw new Error(`Missing ${ENTRY_PATH}; run pnpm run build:electron-vite first`) + } + + // Why: mkdtemp/realpath/bundle/construction can fail before the body runs. + // Keep cleanup in finally from the first successful mkdtemp onward, and clean + // the original temp path if realpath never succeeds. + let createdRootPath + let bundleDir + let rootPath + let supervisor + let subscription + let watcherCanaryDir + let eventListener = () => undefined + let rejectWatcherError + const watcherError = new Promise((_, reject) => { + rejectWatcherError = reject + }) + // Attach early so a callback rejection before the race cannot become unhandled. + watcherError.catch(() => undefined) + + try { + createdRootPath = await mkdtemp(join(tmpdir(), 'orca-runtime-watcher-fault-')) + bundleDir = await mkdtemp(join(tmpdir(), 'orca-runtime-watcher-harness-')) + // Parcel reports canonical event paths on macOS, where tmpdir() may use the + // /var symlink spelling. Keep the oracle in the same path domain. + rootPath = await realpath(createdRootPath) + const WatcherProcessSupervisor = await loadSupervisor(bundleDir) + supervisor = new WatcherProcessSupervisor() + + let resolveInterruption + const interrupted = withTimeout( + new Promise((resolveWait) => { + resolveInterruption = resolveWait + }), + 'automatic watcher resubscription' + ) + subscription = await supervisor.subscribe( + rootPath, + (error, events) => { + if (error) { + // Why: throws from the async watcher callback escape main()'s try/finally + // and skip teardown. Surface failures through a harness promise instead. + rejectWatcherError(error) + return + } + eventListener(events) + }, + { ignore: ['.git', 'node_modules'] }, + { + delivery: { includeDirectoryMetadata: true, maxEventsPerBatch: 200 }, + onInterruption: () => resolveInterruption() + } + ) + watcherCanaryDir = supervisor.canaryDir + + const beforeEvent = nextMatchingEvent( + (listener) => { + eventListener = listener + }, + (event) => event.path === join(rootPath, 'before.txt'), + 'pre-crash watch event' + ) + await writeFile(join(rootPath, 'before.txt'), 'before') + await Promise.race([beforeEvent, watcherError]) + + const firstChildPid = supervisor.child?.pid + if (!firstChildPid) { + throw new Error('Watcher supervisor did not expose a live child') + } + process.kill(firstChildPid, 'SIGSEGV') + await Promise.race([interrupted, watcherError]) + + const replacementChildPid = supervisor.child?.pid + if (!replacementChildPid || replacementChildPid === firstChildPid) { + throw new Error('Watcher supervisor did not replace the faulted child') + } + const afterEvent = nextMatchingEvent( + (listener) => { + eventListener = listener + }, + (event) => event.path === join(rootPath, 'after.txt'), + 'post-crash watch event' + ) + await writeFile(join(rootPath, 'after.txt'), 'after') + await Promise.race([afterEvent, watcherError]) + + console.log( + JSON.stringify({ + hostPid: process.pid, + killedWatcherPid: firstChildPid, + replacementWatcherPid: replacementChildPid, + hostSurvived: true, + automaticResubscribe: true, + postCrashEventDelivered: true + }) + ) + } finally { + try { + await subscription?.unsubscribe() + } finally { + supervisor?.dispose() + await Promise.all([ + createdRootPath ? rm(createdRootPath, { recursive: true, force: true }) : Promise.resolve(), + rootPath && rootPath !== createdRootPath + ? rm(rootPath, { recursive: true, force: true }) + : Promise.resolve(), + bundleDir ? rm(bundleDir, { recursive: true, force: true }) : Promise.resolve() + ]) + } + } + if (watcherCanaryDir && existsSync(watcherCanaryDir)) { + throw new Error(`Watcher supervisor leaked its canary directory: ${watcherCanaryDir}`) + } +} + +await main() diff --git a/config/scripts/smoke-packaged-cli.mjs b/config/scripts/smoke-packaged-cli.mjs index 6283e54b2b8..269118d867e 100644 --- a/config/scripts/smoke-packaged-cli.mjs +++ b/config/scripts/smoke-packaged-cli.mjs @@ -25,7 +25,7 @@ function getPackagedCliPath(appDir) { return join(appDir, 'Contents', 'Resources', 'bin', 'orca') } if (process.platform === 'win32') { - return join(appDir, 'resources', 'bin', 'orca.cmd') + return join(appDir, 'resources', 'bin', 'orca.exe') } return join(appDir, 'resources', 'bin', 'orca-ide') } diff --git a/config/scripts/verify-agent-hook-stdin-lifecycle.mjs b/config/scripts/verify-agent-hook-stdin-lifecycle.mjs new file mode 100644 index 00000000000..06ee9734783 --- /dev/null +++ b/config/scripts/verify-agent-hook-stdin-lifecycle.mjs @@ -0,0 +1,394 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process' +import { + accessSync, + chmodSync, + constants as fsConstants, + mkdtempSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const MANAGED_SCRIPTS = [ + ['antigravity-hook.sh', 'antigravity'], + ['claude-hook.sh', 'claude'], + ['codex-hook.sh', 'codex'], + ['command-code-hook.sh', 'command-code'], + ['copilot-hook.sh', 'copilot'], + ['cursor-hook.sh', 'cursor'], + ['devin-hook.sh', 'devin'], + ['droid-hook.sh', 'droid'], + ['gemini-hook.sh', 'gemini'], + ['grok-hook.sh', 'grok'], + ['kimi-hook.sh', 'kimi'], + ['openclaude-hook.sh', 'claude'] +] + +const REQUIRED_JSON_STDOUT = new Set(['antigravity-hook.sh', 'copilot-hook.sh', 'gemini-hook.sh']) + +function parseArgs(argv) { + const result = { home: process.env.HOME ?? '', minMtime: 0 } + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index] + if (value === '--home') { + result.home = argv[index + 1] ?? '' + index += 1 + } else if (value === '--min-mtime') { + result.minMtime = Number(argv[index + 1] ?? 0) + index += 1 + } else { + throw new Error(['Unknown argument: ', value].join('')) + } + } + if (!result.home) { + throw new Error('Pass --home or set HOME to the isolated Electron home directory') + } + return result +} + +function withoutOrcaEnvironment(extra = {}) { + return { + ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('ORCA_'))), + ...extra + } +} + +function runShell(command, payload, env) { + return new Promise((resolve, reject) => { + const child = spawn('/bin/sh', ['-c', command], { + env, + stdio: ['pipe', 'pipe', 'pipe'] + }) + const stdout = [] + const stderr = [] + const stdinErrors = [] + const timeout = setTimeout(() => { + child.kill('SIGKILL') + reject(new Error(['Timed out running: ', command.slice(0, 120)].join(''))) + }, 10_000) + child.stdout.on('data', (chunk) => stdout.push(chunk)) + child.stderr.on('data', (chunk) => stderr.push(chunk)) + child.stdin.on('error', (error) => stdinErrors.push(error)) + child.on('error', (error) => { + clearTimeout(timeout) + reject(error) + }) + child.on('close', (exitCode) => { + clearTimeout(timeout) + resolve({ + exitCode, + stdinErrors, + stdout: Buffer.concat(stdout).toString('utf8'), + stderr: Buffer.concat(stderr).toString('utf8') + }) + }) + child.stdin.end(payload) + }) +} + +function assertSuccessfulWrite(result, label) { + if (result.exitCode !== 0) { + throw new Error( + [label, ' exited ', String(result.exitCode), ': ', result.stderr.slice(0, 500)].join('') + ) + } + if (result.stdinErrors.length > 0) { + throw new Error( + [ + label, + ' produced stdin errors: ', + result.stdinErrors.map((error) => error.message).join(', ') + ].join('') + ) + } +} + +function assertProtocolStdout(fileName, stdout) { + if (!REQUIRED_JSON_STDOUT.has(fileName)) { + return + } + const firstLine = stdout.trim().split(/\r?\n/, 1)[0] + try { + JSON.parse(firstLine) + } catch { + throw new Error([fileName, ' did not emit protocol JSON: ', stdout.slice(0, 200)].join('')) + } +} + +function readGeneratedScripts(home, minMtime) { + const hooksDir = join(home, '.orca', 'agent-hooks') + return MANAGED_SCRIPTS.map(([fileName, source]) => { + const path = join(hooksDir, fileName) + const stats = statSync(path) + if (!stats.isFile()) { + throw new Error([fileName, ' is not a regular file'].join('')) + } + try { + accessSync(path, fsConstants.R_OK | fsConstants.X_OK) + } catch { + throw new Error([fileName, ' is not readable and executable'].join('')) + } + if (minMtime > 0 && stats.mtimeMs < minMtime) { + throw new Error([fileName, ' predates the Electron launch'].join('')) + } + const body = readFileSync(path, 'utf8') + const captureIndex = body.indexOf('payload=$(cat)') + const firstExitIndex = body.indexOf('exit 0') + if (captureIndex < 0 || firstExitIndex <= captureIndex) { + throw new Error([fileName, ' can exit before capturing stdin'].join('')) + } + return { body, fileName, path, source } + }) +} + +function findStrings(value, matches = []) { + if (typeof value === 'string') { + matches.push(value) + return matches + } + if (Array.isArray(value)) { + for (const child of value) { + findStrings(child, matches) + } + return matches + } + if (value && typeof value === 'object') { + for (const child of Object.values(value)) { + findStrings(child, matches) + } + } + return matches +} + +function nextRequest(server) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + server.removeListener('request', onRequest) + reject(new Error('Generated hook did not reach the loopback server')) + }, 8_000) + const onRequest = (request, response) => { + const chunks = [] + request.on('data', (chunk) => chunks.push(chunk)) + request.on('end', () => { + clearTimeout(timeout) + response.writeHead(200, { 'Content-Type': 'application/json' }) + response.end('{}') + resolve({ + body: Buffer.concat(chunks).toString('utf8'), + headers: request.headers, + url: request.url + }) + }) + } + server.once('request', onRequest) + }) +} + +async function verifyNoOpWrites(scripts, home, payload) { + const commandCodeBin = mkdtempSync(join(tmpdir(), 'orca-hook-command-code-bin-')) + symlinkSync('/bin/cat', join(commandCodeBin, 'cat')) + try { + for (const script of scripts) { + const path = + script.fileName === 'command-code-hook.sh' + ? commandCodeBin + : (process.env.PATH ?? '/usr/bin:/bin') + const result = await runShell( + ['/bin/sh ', JSON.stringify(script.path)].join(''), + payload, + withoutOrcaEnvironment({ + HOME: home, + PATH: path, + ORCA_AGENT_HOOK_ENDPOINT: '' + }) + ) + assertSuccessfulWrite(result, [script.fileName, ' no-op'].join('')) + assertProtocolStdout(script.fileName, result.stdout) + } + } finally { + rmSync(commandCodeBin, { recursive: true, force: true }) + } +} + +async function verifyClaudeDevinSkip(scripts, home, payload) { + const claude = scripts.find((script) => script.fileName === 'claude-hook.sh') + let unexpectedRequests = 0 + const server = createServer((_request, response) => { + unexpectedRequests += 1 + response.writeHead(200, { 'Content-Type': 'application/json' }) + response.end('{}') + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + try { + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('Claude skip verifier did not receive a TCP port') + } + const result = await runShell( + ['/bin/sh ', JSON.stringify(claude.path)].join(''), + payload, + withoutOrcaEnvironment({ + DEVIN_PROJECT_DIR: join(home, 'devin-project'), + HOME: home, + ORCA_AGENT_HOOK_ENDPOINT: '', + ORCA_AGENT_HOOK_PORT: String(address.port), + ORCA_AGENT_HOOK_TOKEN: 'electron-verification-token', + ORCA_PANE_KEY: 'electron-verification-pane' + }) + ) + assertSuccessfulWrite(result, 'Claude Devin-import skip') + if (unexpectedRequests !== 0) { + throw new Error('Claude forwarded a hook imported by Devin') + } + } finally { + await new Promise((resolve) => server.close(resolve)) + } +} + +async function verifyForwarding(scripts, home, payload) { + const server = createServer() + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + try { + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('Loopback verifier did not receive a TCP port') + } + for (const script of scripts) { + const requestPromise = nextRequest(server) + const result = await runShell( + ['/bin/sh ', JSON.stringify(script.path)].join(''), + payload, + withoutOrcaEnvironment({ + HOME: home, + ORCA_AGENT_HOOK_ENDPOINT: '', + ORCA_AGENT_HOOK_PORT: String(address.port), + ORCA_AGENT_HOOK_TOKEN: 'electron-verification-token', + ORCA_PANE_KEY: 'electron-verification-pane', + ORCA_TAB_ID: 'electron-verification-tab', + ORCA_WORKTREE_ID: 'electron-verification-worktree', + ORCA_AGENT_HOOK_ENV: 'test', + ORCA_AGENT_HOOK_VERSION: '1', + ORCA_ANTIGRAVITY_EVENT: 'PostInvocation', + ORCA_COPILOT_HOOK_EVENT: 'PostToolUse' + }) + ) + assertSuccessfulWrite(result, [script.fileName, ' forwarding'].join('')) + assertProtocolStdout(script.fileName, result.stdout) + const request = await requestPromise + const form = new URLSearchParams(request.body) + if (request.url !== ['/hook/', script.source].join('')) { + throw new Error( + [script.fileName, ' posted to ', String(request.url), ' instead of ', script.source].join( + '' + ) + ) + } + if (request.headers['x-orca-agent-hook-token'] !== 'electron-verification-token') { + throw new Error([script.fileName, ' lost the hook token header'].join('')) + } + if (form.get('payload') !== payload) { + throw new Error([script.fileName, ' changed the forwarded payload'].join('')) + } + if (form.get('paneKey') !== 'electron-verification-pane') { + throw new Error([script.fileName, ' changed the forwarded pane key'].join('')) + } + } + } finally { + await new Promise((resolve) => server.close(resolve)) + } +} + +// Why: rewrite from the path embedded in the installed command, not a +// reconstructed join(home, ...). That way missing/failing-script cases cannot +// silently re-run the real script if the install layout changes. +function rewriteLauncherScriptPath(command, nextPath) { + const match = /if \[ -f '([^']+)'/.exec(command) + if (!match) { + throw new Error('Installed launcher command did not reference a quoted script path') + } + return command.replaceAll(match[1], nextPath) +} + +async function verifyInstalledLauncher(home, payload) { + const settingsPath = join(home, '.claude', 'settings.json') + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) + const command = findStrings(settings).find( + (value) => value.includes('claude-hook.sh') && value.includes('if [ -f ') + ) + if (!command || !command.includes('] && [ -r ') || !command.includes('else cat >/dev/null')) { + throw new Error('Electron did not install the guarded Claude launcher') + } + const scratch = mkdtempSync(join(tmpdir(), 'orca-hook-launcher-')) + try { + const missingPath = join(scratch, 'missing-hook.sh') + const missingResult = await runShell( + rewriteLauncherScriptPath(command, missingPath), + payload, + withoutOrcaEnvironment({ HOME: home }) + ) + assertSuccessfulWrite(missingResult, 'installed missing-script launcher') + + const failingPath = join(scratch, 'failing-hook.sh') + writeFileSync(failingPath, '#!/bin/sh\ncat >/dev/null\nexit 7\n', 'utf8') + chmodSync(failingPath, 0o755) + const failingResult = await runShell( + rewriteLauncherScriptPath(command, failingPath), + payload, + withoutOrcaEnvironment({ HOME: home }) + ) + if (failingResult.exitCode !== 7 || failingResult.stdinErrors.length > 0) { + throw new Error('Installed launcher did not preserve a running script failure') + } + } finally { + rmSync(scratch, { recursive: true, force: true }) + } +} + +async function main() { + const args = parseArgs(process.argv.slice(2)) + const payload = JSON.stringify({ + hook_event_name: 'PostToolUse', + tool_name: 'shell', + tool_output: 'x'.repeat(1_200_000) + }) + const scripts = readGeneratedScripts(args.home, args.minMtime) + await verifyNoOpWrites(scripts, args.home, payload) + await verifyClaudeDevinSkip(scripts, args.home, payload) + await verifyForwarding(scripts, args.home, payload) + await verifyInstalledLauncher(args.home, payload) + process.stdout.write( + [ + JSON.stringify( + { + forwardingPayloadBytes: Buffer.byteLength(payload), + claudeDevinSkipCases: 1, + launcherCases: 2, + noOpScripts: scripts.length, + forwardedScripts: scripts.length, + status: 'passed' + }, + null, + 2 + ), + '\n' + ].join('') + ) +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack : error) + process.exitCode = 1 +}) diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 5c1ec10b7b2..a8e5dcad27b 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -3,6 +3,7 @@ "include": [ "../src/cli/**/*", "../src/shared/**/*", + "../src/main/agent-hooks/hook-stdin-contract.ts", "../src/main/agent-hooks/installer-utils.ts", "../src/main/agent-hooks/installer-utils-remote.ts", "../src/main/agent-hooks/managed-agent-hook-controls.ts", diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 2e0180ff127..f59489f67c0 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 4.5m + + downloads: 5.0m @@ -15,7 +15,7 @@ downloads downloads - 4.5m - 4.5m + 5.0m + 5.0m diff --git a/docs/claude-scoped-oauth-usage-limits.md b/docs/claude-scoped-oauth-usage-limits.md new file mode 100644 index 00000000000..e5eb3c9e2d3 --- /dev/null +++ b/docs/claude-scoped-oauth-usage-limits.md @@ -0,0 +1,84 @@ +# Claude Scoped OAuth Usage Limits + +## Problem + +Anthropic's current OAuth usage response reports Fable in `limits` as a model-scoped weekly limit instead of one of the legacy top-level Fable fields. Orca ignores `limits`, maps `fableWeekly` to `null`, and then depends on a hidden Claude `/usage` PTY read that is disabled on Windows and can fail silently elsewhere. + +- `src/main/rate-limits/claude-fetcher.ts:300` models only top-level OAuth windows. +- `src/main/rate-limits/claude-fetcher.ts:393` maps only legacy Fable field names. +- `src/main/rate-limits/service.ts:1203` disables the PTY supplement on Windows. +- `src/renderer/src/components/status-bar/tooltip.tsx:172` renders Fable whenever `fableWeekly` is populated. + +## Root Cause + +The OAuth response contract evolved from dedicated model fields to generic entries shaped like `kind: "weekly_scoped"`, `percent`, `resets_at`, and `scope.model.display_name`. Orca's response type and mapper were not updated for that shape. + +## Non-goals + +- Do not change polling, credentials, token refresh, account switching, renderer layout, or usage percentage semantics. +- Do not remove the existing PTY supplement or legacy field compatibility. +- Do not generalize shared renderer state to arbitrary model windows in this targeted bug fix. + +## Design + +1. Extend the private OAuth response type with an optional `limits` array containing only the fields needed for safe parsing. +2. Select a Fable entry only when `kind` is `weekly_scoped`, the model display name is Fable (case-insensitive), and `percent` is finite. +3. Map the scoped entry to the existing seven-day `fableWeekly` window, including its reset timestamp. +4. Prefer the current scoped entry, then retain the three legacy top-level fields as fallbacks. +5. Keep malformed, unrelated, inactive, or absent entries non-fatal. Treat `is_active: false` as unavailable so stale promotional limits disappear; accept a missing activity flag for compatibility. + +## Data Flow + +- OAuth response + - `limits[].weekly_scoped` Fable -> `fableWeekly` + - otherwise legacy explicit Fable field -> `fableWeekly` + - otherwise existing optional PTY supplement +- Existing provider state -> existing status-bar and details rendering + +## Edge Cases + +- `limits` is missing, null, malformed, or contains null entries. +- A scoped entry names another model. +- Fable percent is missing, non-numeric, or non-finite. +- Fable is inactive and should not be rendered. +- `is_active` is omitted by an older server response but the remaining scoped entry is valid. +- Both current and legacy fields exist; the current scoped entry wins. +- Reset timestamps may be ISO strings, epoch seconds, epoch milliseconds, or absent. +- Windows, WSL, SSH, and remote runtimes use the same OAuth mapper and require no platform-specific execution. + +## Test Plan + +- Unit: reproduce a current real-response shape and assert Fable maps without a PTY attempt. +- Unit: assert scoped data wins over a legacy field. +- Unit: assert inactive, malformed, and unrelated scoped entries are ignored while legacy fallback remains available. +- Regression: retain existing legacy-field and bare-`fable` behavior tests. +- Verification: focused Claude fetcher tests, typecheck, lint, and max-lines ratchet. +- Electron: refresh Claude usage and confirm Session, Weekly, and Fable remain visible in the existing status-bar details surface. + +## UI Quality Bar + +No UI implementation changes. The existing Fable row must reappear with the same typography, spacing, progress bar, percentage semantics, and reset copy as adjacent Session and Weekly rows. + +## Review Screenshots + +1. Claude usage details showing Session, Weekly, and Fable from a live OAuth refresh. +2. Adjacent status-bar context showing the Claude provider remains visually unchanged outside the restored row. + +## Rollout + +1. Add the scoped OAuth response types and mapper. +2. Add focused current-schema and compatibility regression tests. +3. Run focused and repository checks. +4. Validate the restored row in Electron and capture review screenshots. +5. Commit, push, and open an unmerged PR. + +## Lightweight Eng Review + +- Scope: Kept to the private OAuth mapper and tests; no shared-state or renderer generalization is required to restore Fable. +- Architecture/data flow: OAuth remains authoritative, with structured scoped data preferred over legacy fields and PTY used only as the existing final supplement. +- Failure modes covered: malformed optional data, unrelated models, inactive limits, missing activity flags, duplicate old/new representations, missing reset metadata, and platform-neutral execution. +- Test coverage required: current-schema success without PTY, precedence, inactivity, malformed/unrelated entries, and legacy fallback. +- Performance/blast radius: One bounded linear scan of the small response `limits` array per existing OAuth refresh; no new requests, polling, subprocesses, IPC, storage, or renderer work. +- UI quality bar: Existing status-bar visuals must remain unchanged except for the restored Fable row. +- Required review screenshots: Live Claude details with all three rows; surrounding status-bar context. +- Residual risks: Anthropic may rename the scoped model display label; legacy and PTY fallbacks remain available. diff --git a/docs/reference/agent-hook-stdin-lifecycle.md b/docs/reference/agent-hook-stdin-lifecycle.md new file mode 100644 index 00000000000..9f875b3d06c --- /dev/null +++ b/docs/reference/agent-hook-stdin-lifecycle.md @@ -0,0 +1,198 @@ +# Agent Hook Stdin Lifecycle + +## Problem + +Orca installs agent hooks in global agent configuration, so a managed hook can +run in an Orca pane, an SSH/WSL runtime, a detached session, or an ordinary +terminal with no Orca environment. Hook runners write an event payload to the +child process's stdin after spawn. + +The generated scripts currently inspect Orca environment variables before they +read stdin. When a guard exits first, the hook runner can still be writing to a +pipe whose reader has closed. POSIX reports `EPIPE`; Windows reports +`ERROR_BROKEN_PIPE`. The same failure exists when a managed launcher finds that +its script was removed or is no longer executable. + +This is an ownership bug rather than an agent-specific parsing bug. Every +process that accepts a hook payload owns the read end until it reaches EOF, +including processes that decide the event is irrelevant. + +## Goals + +- Make successful hook no-ops consume stdin to EOF on macOS, Linux, Windows, + WSL, and SSH hosts. +- Encode stdin ownership once per platform instead of copying ad hoc drains + into every early-exit branch. +- Preserve payload bytes, hook output, transport timeouts, exit-code behavior, + and provider-specific event metadata on the forwarding path. +- Cover every generated script and missing-script launcher with behavioral + regression tests. + +## Non-goals + +- Do not redesign hook configuration schemas, endpoint discovery, relay HTTP + payloads, or event selection. +- Do not make hook payloads unbounded; existing agent and config timeouts remain + the outer lifecycle bound. +- Do not suppress failures from a script that exists and actually runs. Only a + deliberate no-op path exits successfully after consuming stdin. +- Do not change plugin-based integrations that do not receive piped hook stdin. + +## Contract + +1. A generated hook script consumes its input exactly once. +2. No whole-script success exit may occur before that consumption completes. +3. A launcher that declines to start a missing, unreadable, or non-executable + managed script becomes the stdin owner and drains to EOF before returning + success. +4. A launcher propagates the exit code of a managed script that was started. +5. Output required by an agent protocol may be emitted before stdin is read, + but the process must still retain the read end until EOF. +6. Drain commands are platform-qualified where the platform searches the + working directory implicitly. + +## Design + +### POSIX generated scripts + +Capture stdin near the start of the generated script, after any protocol output +that must be immediate and before endpoint refresh, environment guards, or +provider-specific skips: + +```sh +payload=$(cat) +if [ -z "$payload" ]; then + exit 0 +fi +``` + +Antigravity is the one semantic exception: events without payload still post an +empty object, so its shared capture policy maps empty input to `{}` instead of +exiting. Claude's Devin-import skip happens after capture. Command Code captures +before ancestor/endpoint recovery so its comparatively expensive discovery +cannot leave the writer blocked. + +The common payload-capture fragments live in +`src/main/agent-hooks/hook-stdin-contract.ts`. Templates choose the required or +empty-object policy instead of spelling the lifecycle independently. + +### Windows batch generated scripts + +Batch scripts stream stdin directly into system `curl.exe`; buffering arbitrary +JSON in an environment variable would corrupt metacharacters and hit size +limits. Their posting path therefore remains streaming. + +All environment guard failures jump to one epilogue: + +```bat +if "%ORCA_AGENT_HOOK_PORT%"=="" goto :orca_agent_hook_drain_stdin +... +exit /b 0 +:orca_agent_hook_drain_stdin +"%SystemRoot%\System32\more.com" >nul 2>nul +exit /b 0 +``` + +`more.com` is qualified because Windows searches the current working directory +for executables. Shared guard and epilogue builders keep labels and commands +identical across templates. Command Code's existing endpoint-discovery labels +remain subroutines and the drain epilogue is placed after them. + +### PowerShell generated scripts + +PowerShell captures with `[Console]::In.ReadToEnd()` before endpoint and +environment guards. Copilot then parses the captured value only on the posting +path. This mirrors POSIX ownership without starting an additional process. + +### Managed launchers + +- POSIX `/bin/sh` launchers require a regular, readable, executable file. The + rejected path drains with `cat`; the started-script branch keeps propagating + status. +- Encoded PowerShell launchers use `Test-Path -PathType Leaf`. A missing script + calls `[Console]::In.ReadToEnd()` and exits zero; an existing script preserves + `$LASTEXITCODE`. +- Codex's cmd.exe fast path remains PowerShell-free. It rejects missing paths + and directories, using the same qualified `more.com` drain for both. +- Claude's Git Bash fast path uses a POSIX file guard and drain while continuing + to execute the `.cmd` directly rather than interpreting it as shell source. +- Agent-specific direct launchers, including Antigravity event wrappers and + Copilot's PowerShell-file command, adopt the same missing-file behavior. + +## Data Flow + +```text +hook runner spawns command + -> runner writes payload and closes stdin + -> launcher starts managed script + -> script consumes payload + -> refreshes endpoint / evaluates guards + -> posts or exits zero + -> OR launcher cannot start script + -> launcher drains payload + -> exits zero +``` + +Local, WSL, and SSH installs serialize the same POSIX template. Windows local +installs use the batch or PowerShell template. No host assumes another host's +path syntax or shell. + +## Failure Semantics + +- Missing Orca environment: consume input, exit zero, emit only protocol-required + output. +- Empty payload: consume EOF, then follow the agent's existing empty-event rule. +- Missing/unreadable/non-executable script: launcher consumes input and exits + zero. +- Endpoint parse/read failure: preserve the existing fail-open behavior after + stdin ownership has been satisfied. +- Existing script returns nonzero: propagate its status; do not drain again or + disguise the script failure. +- Hook runner never closes stdin: the existing config-level timeout terminates + the hook. Reading to EOF does not introduce an unbounded lifecycle beyond that + already required by normal payload parsing. + +## Verification + +Add one cross-agent lifecycle suite rather than per-service string-position +assertions. + +- Generate every SSH-compatible POSIX managed script, spawn it with Orca + environment removed, write a payload larger than pipe buffers, and assert: + exit zero, no stdin error, and required protocol output remains valid. +- Exercise the Claude Devin skip independently because it is a second no-op + condition before endpoint forwarding. +- Exercise POSIX, encoded PowerShell, cmd.exe, and Git Bash missing-script + launchers with a large payload and verify zero write errors. +- On Windows CI, install and execute every local batch/PowerShell managed script + with missing Orca environment and a large payload. +- Keep structural assertions for shared batch guard/epilogue generation, but do + not use substring placement as the primary regression gate. +- Preserve existing successful-post, timeout, quoting, SSH install, WSL, and + nonzero-exit propagation tests. + +For a macOS smoke test, launch the real Electron app with an isolated `HOME` +and `ORCA_DEV_USER_DATA_PATH`, record the launch time, then run: + +```sh +node config/scripts/verify-agent-hook-stdin-lifecycle.mjs \ + --home "$ISOLATED_HOME" \ + --min-mtime "$LAUNCH_START_MS" +``` + +The verifier rejects stale files, then exercises the 12 POSIX scripts written +by that Electron launch with a payload larger than pipe buffers. It covers +successful no-ops, loopback forwarding without payload changes, required JSON +stdout, Claude's Devin skip, and both branches of the installed missing-script +launcher. + +## Rollout And Compatibility + +The generated scripts are rewritten by existing install/update flows, so no +config migration is needed. Commands use POSIX `sh` primitives, Windows inbox +executables, and PowerShell features available on supported Windows releases. +No Git behavior or provider-specific review behavior changes. + +The change should ship atomically across templates and launchers. A partial +rollout would leave global hooks with platform- or agent-dependent pipe safety, +which is the inconsistency this design removes. diff --git a/docs/reference/terminal-cold-park-reveal-cost.md b/docs/reference/terminal-cold-park-reveal-cost.md new file mode 100644 index 00000000000..e4eecd7fb91 --- /dev/null +++ b/docs/reference/terminal-cold-park-reveal-cost.md @@ -0,0 +1,132 @@ +# Cold-park reveal cost — measured + +Companion to [`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md). +Quantifies the reveal latency a user pays when returning to a terminal whose +hidden view was cold-parked, so the keep-warm policy is tuned from data rather +than guessed. Motivated by field reports that "terminal mounting is jumpy — +switching to a worktree after a while takes ~1s". + +## How to reproduce + +```bash +pnpm bench:cold-park-reveal -- --cycles=12 # empty shell +pnpm bench:cold-park-reveal -- --cycles=10 --scrollback-lines=5000 +pnpm bench:cold-park-reveal -- --cycles=10 --scrollback-lines=20000 +``` + +The bench (`tools/benchmarks/terminal-cold-park-reveal-bench.mjs`) drives the +real dev app over CDP. Each cycle times a **cold** reveal (tab confirmed parked +via `window.__terminalParkingDebug.parkedTabIds()` before revealing) and a +**warm** reveal (revealed inside the hot-retain window, confirmed not parked), +so the cold−warm delta is the isolated cost of parking. It shrinks the 30s +hysteresis with `ORCA_E2E_TERMINAL_PARKING_DELAY_MS` and roots userData under a +short `~/.ocpb` path — the default `/var/folders` tmp path overruns the macOS +104-char Unix-socket limit, the daemon fails to bind, and terminals fall back to +non-snapshot PTYs that never park. + +Phases per reveal (ms from the switch-back click): `activationMs` (store active +worktree flips), `ptyBindMs` (revealed pane has a bound pty — xterm remounted +and attached), `paintSettleMs` (two RAFs after ptyBind). + +## Results (macOS, dev app, medians / p95) + +| Scrollback | warm paintSettle | cold paintSettle | **cold-park penalty** | +| ---------- | ---------------- | ---------------- | --------------------- | +| empty shell | 104 ms | 264 / 298 ms | **+160 ms** | +| 5 000 lines | 131 ms | 318 / 423 ms | **+187 ms** | +| 20 000 lines | 102 ms | 266 / 286 ms | **+164 ms** | + +All cold samples confirmed parked (`parkedConfirmed = N/N`); all warm samples +confirmed not parked. + +## Finding + +**The cold-park reveal penalty is ~160–190 ms and effectively flat across buffer +size.** Growing scrollback 0 → 20 000 lines did not grow the penalty (the 20k +run is within noise of empty). So the cost is **fixed remount overhead** — React +subtree teardown/rebuild, fresh xterm construction, WebGL attach, fit, and the +reattach reset — **not** daemon snapshot replay, which scales fine. + +Implications for tuning: + +- The lever is **how often a remount happens**, not how much is replayed. + Widening the keep-warm working set (worktree hot-retain limit is 4, tabs 12) + and never parking the last-active tab per worktree remove remounts entirely + for the common switch-away-and-back, which is worth more than shaving the + ~170 ms remount itself. +- Pre-warming a predicted reveal (sidebar hover, jump-palette selection) hides + the fixed cost behind the navigation instead of eliminating it. +- This synthetic terminal is a plain shell. A live agent TUI in the alternate + screen pays additional reattach-reset + mode-rehydrate work on reveal (see the + Grok-scrollback reattach path), so real-world cold reveals of a busy agent + pane can exceed these numbers — the ~170 ms here is a floor for the mount + overhead, not a ceiling for the full experience. + +## Is parking worth having? (benefit side) + +`tools/benchmarks/terminal-cold-park-resource-bench.mjs` +(`pnpm bench:cold-park-resource`) A/Bs the `terminalHiddenViewParking` setting +in one session: mount N worktree terminals, background all but one, then read +resources with parking off vs on. Measured at 8 backgrounded worktrees +(5 000-line scrollback each): + +| metric | parking off | parking on | released | +| ------ | ----------- | ---------- | -------- | +| mounted pane managers | 9 | 1 | **8** | +| DOM nodes | 2 941 | 1 389 | **1 552** | +| attached WebGL contexts | 1 | 1 | **0** | + +So parking's unique win is **releasing every hidden terminal's mounted view** — +8 xterm instances + their DOM subtrees (~1 550 nodes) here. At xterm's default +5 000-row scrollback, each terminal buffer alone is ≥ ~4–5 MB of typed-array +cell data (12 bytes/cell × 80 cols × 5 000 rows), so the renderer-memory floor +is on the order of **several MB per hidden terminal** — tens of MB across a +many-worktree session, before DOM and addon overhead. + +Two honest caveats on the numbers: + +- The **WebGL-context budget is NOT parking's win.** Even with parking off, only + one context was attached across 9 mounted terminals — the separate + WebGL-suspend-on-hide path (`suspendRendering`) already releases hidden panes' + contexts. So the #6874 context-exhaustion protection stands with or without + parking; parking's benefit is renderer memory + DOM, not contexts. +- The measured JS-heap delta was small (~1.7 MB) because detached xterm + typed-array buffers GC lazily and the first measurement ran before they were + reclaimed. The bench was upgraded to force multi-pass GC and read whole-app + process memory (`window.api.memory.getSnapshot`), but a machine under disk + + memory pressure could not complete the upgraded run (esbuild dev-service + crashes). Re-run the upgraded bench on a healthy machine to get the settled + whole-app memory figure; the pane-manager / DOM release above is the robust, + reproducible part. + +### Verdict + +Parking earns its keep for the **many-worktree power user** (the reporter's +profile): it is the only mechanism that frees hidden terminals' view memory and +DOM, which is the dominant renderer cost at scale. It does **not** provide the +GPU-context protection (that is suspend-on-hide's job). The reveal price is a +flat ~170 ms remount. Net: keep it, and tune the keep-warm caps so the common +few-worktree rotation never pays the remount. + +### Tuning applied + +Because the reveal cost is a fixed remount, the levers are all about +**frequency**, not replay size. Three changes to +`terminal-hidden-view-parking.ts`: + +- **Worktree hot-retain limit 4 → 8.** Eight covers the ordinary working set; + at the ~4–5 MB renderer floor per hidden worktree that is ~16–20 MB worst + case to eliminate remounts for the common rotation. Beyond 8, parking still + reclaims for the heavy tail it was built for. +- **Hot-retain TTL 5 min → 15 min** (worktree and tab). The cap, not the clock, + is the primary evictor now; 5 min was aggressive enough that a meeting-length + absence parked the whole warm set. The tab count cap stays 12 (already + generous). +- **Last-active exemption.** The single most-recently-hidden worktree/tab is + never parked, regardless of TTL or cap, so returning to the view you just + left is always instant. Implemented in `selectIdsBeyondHotRetain`; it holds + one warm slot and counts against the cap. + +Not done (deliberately): pre-warm on predicted reveal — it hides the cost +behind navigation rather than removing it and adds hover/prediction complexity; +and the 30 s cold-park delay is unchanged (it is the quick-flip guard). diff --git a/docs/reference/terminal-hidden-view-parking.md b/docs/reference/terminal-hidden-view-parking.md index eeaf17877ea..55583ab5a79 100644 --- a/docs/reference/terminal-hidden-view-parking.md +++ b/docs/reference/terminal-hidden-view-parking.md @@ -29,7 +29,11 @@ A pure policy module decides which hidden terminal tabs may park: - Cold-park hysteresis: a tab must be hidden for 30s before parking. - Hot-retain working set: recently visible worktrees/tabs are retained - (5 minutes, bounded count) so quick tab switches never pay a re-hydrate. + (15 minutes; up to 8 worktrees / 12 tabs) so quick tab switches never pay a + re-hydrate. The single most-recently-hidden (last-active) worktree/tab is + exempt from both the retain TTL and the count cap, so switching back to the + view you just left is always instant regardless of how long you were away. + See `terminal-cold-park-reveal-cost.md` for the data behind these caps. - Eligibility excludes: visible panes, hidden-measuring startup probes, activity-portal panes, tabs with pending startup commands or pending activation spawns, floating-panel tabs, and any tab whose PTY is not diff --git a/docs/reference/terminal-session-lifecycle.md b/docs/reference/terminal-session-lifecycle.md new file mode 100644 index 00000000000..29161cd6fdf --- /dev/null +++ b/docs/reference/terminal-session-lifecycle.md @@ -0,0 +1,552 @@ +# Terminal Session Ownership and Teardown + +**Status:** Incident fix and pane-authority transfer implemented +**Date:** 2026-07-13 +**Incident:** [`../../pty-exhaustion-agent-session-leak.md`](../../pty-exhaustion-agent-session-leak.md) +**Related contracts:** [`terminal-model-view-contract.md`](./terminal-model-view-contract.md), +[`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md) + +## Summary + +Terminal process lifetime must be owned by explicit product intent, never by +whether a React view happens to be mounted. + +Orca currently preserves a PTY when a terminal view detaches, which is correct +for view parking, tab moves, renderer reload, and warm reattach. It also relies +on `TerminalPane` unmount to destroy a PTY after a tab is closed. Hidden-view +parking invalidated that implicit invariant: a parked tab has no mounted pane, +so closing it removes renderer state and observers without terminating the PTY. +Agent resume records survive the same close and later interpret the missing pane +as failed recovery, launching the deliberately closed session again. + +This design establishes one authoritative lifecycle contract: + +- **Close tab or pane:** permanently retire the surface, terminate every PTY it + owns, and revoke every resume record for its panes. +- **Detach or park:** remove only the view connection; preserve the PTY and its + durable identity. +- **Sleep:** intentionally checkpoint resumable agents, terminate the PTYs, and + preserve only the checkpoint needed for wake. +- **Quit or reload:** follow the configured persistence policy; warm detach is + allowed, but it is not a tab close. + +The immediate implementation makes the existing terminal-tab state close the +authoritative compatibility boundary for explicit retirement while adding an +explicit natural-exit reason for callers that are only reconciling an already +dead PTY. A follow-up separates the command and reducer APIs once all direct +store callers have migrated. + +## Implementation status + +The incident patch ships the retirement planner, provider-aware close routing, +unified-only retirement, parked watcher/candidate disposal, resume-authority +revocation, explicit natural-exit handling, late-binding rejection, and direct +background-launch ownership checks. It also adds exact pane retirement and a +persisted physical-to-owner pane-key alias for detach, plus a live Electron +parked-close gate. Focused tests cover local, SSH, split, parked, shared, +paired-host, ordinary runtime, pane-transfer, and late-spawn paths. + +Origin-aware defensive expiry remains a follow-up. Immediate deletion on +explicit close fixes the incident without applying a wall-clock policy that +could invalidate intentional long-lived `worktree-sleep` checkpoints. + +## Goals + +1. Closing a terminal tab submits retirement for all exclusively owned local, + daemon, WSL, SSH, or runtime PTYs, including every split pane, whether its + views are mounted or parked. +2. Closing a terminal pane terminates only that pane's PTY; detaching a pane to + another tab preserves its PTY, hook identity, and resume authority under the + new owning pane. +3. Closing a tab permanently removes its agent resume authority. App restart, + worktree activation, mobile wake, and periodic capture must not resurrect it. +4. Parking, tab-group moves, renderer reload, and warm reattach keep their + existing process-preservation behavior. +5. Remote-runtime and web-session surfaces are terminated by their owning host, + not by sending a host-scoped ID to the local PTY provider. +6. Teardown is idempotent and safe under duplicate close requests, late PTY + exits, late agent-hook events, in-flight spawns, and provider disconnects. +7. A future bookkeeping regression cannot grow without bound: unowned defensive + resume records have an origin-aware expiry, and provider-owned detached + sessions gain a bounded orphan policy in the daemon-lease phase. + +## Non-goals + +- Changing terminal output, snapshot, replay, query-response, or hidden-delivery + behavior. +- Killing processes that deliberately daemonize away from the terminal process + group. This contract covers the PTY and its attached foreground process tree. +- Changing agent-provider resume commands or permission flags. +- Making a UI close wait for a remote process to exit before the tab disappears. +- Replacing worktree sleep with tab close. Sleep remains resumable by design. +- Claiming that a disconnected SSH relay process is already dead when main can + only tombstone its app-scoped ID. Durable relay-side kill-on-reconnect is a + follow-up. + +## Terminology + +- **Surface:** a terminal tab or a pane within a split terminal tab. +- **View:** a renderer xterm or pane-less parked watcher observing a surface. +- **Session:** the provider-owned PTY identified by a PTY ID. +- **Resume authority:** a `sleepingAgentSessionsByPaneKey` record that permits + Orca to launch an agent-provider resume command. +- **Retire:** permanently close a surface and revoke its process and resume + ownership. +- **Detach:** disconnect a view while preserving the session for reattachment. + +## Open-source prior art + +The lifecycle distinction is established in mature terminal implementations: + +- VS Code's terminal instance disposal calls process-manager disposal, and the + process manager marks the process `KilledByUser` before shutdown. Its separate + `detachFromProcess` path deliberately clears the client reference without + shutting down the process. It also shuts down a process whose asynchronous + creation completes after the manager was disposed. See + [`terminalInstance.ts`](https://github.com/microsoft/vscode/blob/3f5c62a95ddb886424da463b41ac3ac5e45aa04f/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts#L1287-L1325) + and + [`terminalProcessManager.ts`](https://github.com/microsoft/vscode/blob/3f5c62a95ddb886424da463b41ac3ac5e45aa04f/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts#L207-L239). +- tmux exposes the same contract as separate commands: `detach-client` removes + a client while leaving the session intact, whereas `kill-session` destroys + the session and its windows. See + [`tmux.1`](https://github.com/tmux/tmux/blob/42b3ea0d7411acb4cd0357a3c2829d986b455918/tmux.1#L1177-L1218). + +Orca needs the same semantic split, extended across local, WSL, SSH, persistent +daemon, and remote-runtime ownership. The important precedent is the explicit +intent boundary, not a particular framework or process API. + +## Non-negotiable invariants + +1. View lifetime and process lifetime are independent. Mount and unmount may + attach or detach observers; they do not decide whether a session should live. +2. Every live PTY has an owning surface or an explicit bounded detached state. +3. A user close is terminal: once accepted, no status event, session snapshot, + or runtime replay may recreate the closed surface or resume its agent. +4. A tab's retirement candidates are the union of its live index, tab row, + persisted split layout, last-known relay ID, deferred SSH ID, and pending + reconnect ID. A candidate is killable only when no other live surface claims + it. +5. Provider routing follows the execution owner: + - local/daemon/WSL and app-scoped SSH IDs use `pty.kill`; + - `remote:` runtime mirror IDs are never passed to the local PTY provider; + - paired web tabs are closed through the host session RPC. +6. Teardown requests are idempotent. Repeated kill, exit-after-kill, and + close-after-exit must converge on the same empty state. +7. Resume-record validity is origin-aware. Intentional `worktree-sleep` records + are not expired by agent-status freshness; unowned defensive `live` and + `quit` records use a documented decision-time horizon. +8. Natural process exit and explicit user retirement are distinct intents. + Reconciliation after an exit must not silently adopt destructive user-close + semantics. + +## Lifecycle matrix + +| Intent | View | PTY | Resume record | Persistent tab state | +| --------------------- | ------------------------------------------ | ---------------------------------- | --------------------------------------------------- | -------------------- | +| Switch tabs/worktrees | hidden or parked | keep | keep/update | keep | +| Move pane/tab group | detach, then remount | keep | keep | move | +| Renderer reload | detach | keep when warm persistence applies | keep | keep | +| App quit | detach or terminate per persistence policy | policy-dependent | capture live agents | keep | +| Sleep worktree | unmount | terminate | capture intentional sleep checkpoint | keep identifiers | +| Close split pane | destroy | terminate that pane | delete that pane record | remove pane | +| Close terminal tab | destroy or already absent | terminate all tab PTYs | delete all tab records | remove tab | +| Remove worktree | destroy | terminate all worktree PTYs | delete all worktree records | remove worktree | +| PTY exits naturally | disconnect | already dead | clear or retain completed evidence per agent policy | reconcile surface | + +## Current failure + +The current close path deletes `ptyIdsByTabId[tabId]` and the tab layout, then +relies on a mounted `TerminalPane` cleanup to call `transport.destroy()`. A +parked tab has already run the detach branch and has no mounted cleanup left. +`disposeParkedTabWatchers` only unregisters observers. The provider session is +therefore alive after Orca discards its last renderer-side owner. + +The same state close drops live agent status but not +`sleepingAgentSessionsByPaneKey`. Worktree activation intentionally fresh-resumes +records that no preserved pane can own. A deliberately closed pane therefore +looks like a failed restore. The existing age check compares `capturedAt` with +`updatedAt`; that detects a status that was stale at capture time but never +expires a record as wall time advances. + +## Proposed architecture + +### 1. Authoritative tab retirement plan + +Before deleting any tab-scoped state, build a `TerminalTabRetirementPlan` from +one store snapshot: + +```ts +type TerminalTabRetirementPlan = { + tabId: string + worktreeId: string | null + ptyIds: string[] + localOrSshPtyIds: string[] + runtimeTerminals: Array<{ environmentId: string; handle: string }> + sharedPtyIds: string[] + paneKeys: string[] +} +``` + +`ptyIds` is the deduplicated union of: + +- `ptyIdsByTabId[tabId]`; +- the tab row's legacy `ptyId`; +- every value in `terminalLayoutsByTabId[tabId].ptyIdsByLeafId`; +- `lastKnownRelayPtyIdByTabId[tabId]`; +- `deferredSshSessionIdsByTabId[tabId]`; +- `pendingReconnectPtyIdByTabId[tabId]`. + +This snapshot must be built before any of those maps are pruned. PTY IDs are +classified structurally with `parseRemoteRuntimePtyId`, not by assuming a host +or platform from the current active workspace. Before shutdown, the planner +subtracts IDs referenced by another tab row, live index, split layout, or relay +and reconnect map. A partially completed pane move must not let closing the +source tab kill the target tab's session. + +The immediate compatibility boundary is `closeTab`, because several production +paths still call the store action directly. Its options carry an explicit +`reason: 'user' | 'pty-exit' | 'cleanup'`; `user` and `cleanup` retire, while +`pty-exit` reconciles an already-dead session without killing siblings or +revoking crash-recovery policy by accident. Making only the rendered tab-bar +action destructive would leave background launch cleanup, floating terminal, +onboarding, runtime notifications, and future direct callers vulnerable. + +### 2. Teardown ordering + +A local tab retirement executes in this order: + +1. Snapshot the retirement plan and its execution-owner classifications. +2. Mark the tab recently closed so late agent-hook events cannot reintroduce + status or resume authority. +3. Dispose the whole tab's parked registry entry and captured candidates, and + silence teardown side-effect handlers while preserving deterministic exit + observation. +4. Submit `pty.kill` for exclusive local or SSH IDs and `terminal.close` for + exclusive ordinary runtime handles. A paired host-session close remains + owned by `session.tabs.close` and is not duplicated locally. +5. Atomically remove the tab, layout/binding maps, agent status, and resume + records from renderer state. +6. Let subsequent React unmount cleanup run idempotently. It is no longer the + process-lifetime authority. + +Steps 3 and 4 are issued before the state loses its IDs. The UI state mutation +remains synchronous and does not await provider exit. Completion is observed +with `Promise.allSettled` so rejections cannot become unhandled promises. The +current Phase 1 fallback for a rejected retirement is provider `listSessions` +plus Resource Manager orphan cleanup; durable retry inventory belongs to the +main-owned retirement phase. + +Remote-runtime mirrors are different: their `remote:` IDs are excluded from +local `pty.kill`. Ordinary runtime terminals retire through `terminal.close`. +Paired host-session tabs retire through `session.tabs.close`, which owns the +entire host tab graph. The local mirror may be pruned optimistically only after +the host close intent is recorded, as it is today. + +### 3. Resume-authority revocation + +Tab retirement removes every sleeping record whose: + +- map key begins with `${tabId}:`; or +- record has `tabId === tabId`. + +The explicit `record.tabId` check covers migrated or legacy keys whose key no +longer encodes the current tab identity. Sibling tabs and other worktrees retain +their records by reference when unchanged. + +The existing recently-closed tab registry remains the short-lived race guard +for hook events already in flight. It must prevent those events from creating a +new `origin: 'live'` resume record after retirement. Tests must cover both event +orders: close then late status, and status queued in the same turn as close. + +Periodic capture iterates only current live status entries. Once close removes +the live entry and resume record, the periodic pass has nothing to persist. + +### 4. Origin-aware defensive record expiry + +An unowned defensive `live` or `quit` record may expire when: + +```ts +Date.now() - record.capturedAt > DEFENSIVE_AGENT_RESUME_MAX_AGE_MS +``` + +Intentional `worktree-sleep` records are exempt from the agent-status freshness +window. Originless legacy behavior remains unchanged until its migration policy +is explicitly chosen. The same validator must run before desktop activation and +mobile/background wake so one entry point cannot bypass it. + +Expiry is defense in depth, not the primary close mechanism. A correctly closed +record is deleted immediately; it is not retained for 30 minutes. + +### 5. Pane close remains pane-scoped + +`PaneManager.closePane` already distinguishes `reason: 'close'` from +`reason: 'detach'`. Preserve that distinction with explicit pane authority: + +- `close` must clear the exact pane's resume authority, add a pane-scoped late + event tombstone, and destroy the pane transport; +- `detach` must atomically transfer or alias resume ownership because the agent + process retains its immutable source `ORCA_PANE_KEY`. + +The last-pane path routes to terminal-tab retirement so it receives the same +parked, split, SSH, and resume cleanup behavior as a tab-bar close. + +### 6. Provider-side safety net + +Client correctness is necessary but not sufficient for a daemon deliberately +designed to outlive renderer and app processes. Add a follow-up provider-owned +orphan policy: + +- track attachments/ownership leases rather than treating `detach` as a no-op; +- distinguish warm-reattach grace from ownerless retirement; +- reap sessions that have no owner after a bounded grace period; +- never idle-reap a session merely because it produces no output; +- scope leases to native, WSL, SSH provider, relay connection, or runtime host; +- expose orphan count and oldest orphan age in Resource Manager diagnostics. + +This hardening is not required to land the immediate close fix, because a lease +protocol changes daemon compatibility and needs its own migration. It is the +defense against a future client bookkeeping regression. + +## Provider behavior + +| Provider/session kind | Close operation | Notes | +| ----------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------- | +| Local native | `pty.kill(id)` | Main routes to the owning local/daemon provider. | +| Persistent daemon | `pty.kill(id)` | Explicit close overrides warm-reattach persistence. | +| Windows ConPTY | `pty.kill(id)` | Provider shutdown owns Windows process-tree semantics. | +| WSL | `pty.kill(id)` | Route by PTY ownership; do not infer from path separators. | +| SSH | `pty.kill(appScopedId)` | Main resolves the connection-specific SSH provider. | +| Disconnected SSH | `pty.kill(appScopedId)` | Tombstones locally and prevents fallback; relay-side death requires reconnect-aware follow-up. | +| Ordinary remote runtime | host `terminal.close` | Classify environment and handle from the `remote:` ID. | +| Paired web host tab | host `session.tabs.close` | Host owns the complete tab and pane graph. | +| Web client local mirror | optimistic prune after close intent | Host snapshot confirms final removal. | + +## Failure and race handling + +### Duplicate close + +The first close snapshots and retires the tab. Later closes find no target and +no-op. Provider shutdown is idempotent for an already exited session. + +### PTY exit races close + +Observers are unregistered before shutdown. If an exit event was already +queued, the recently-closed tab guard and missing tab make it a no-op. It must +not recreate a tab, completion row, resume record, or notification. + +### Spawn completes after close + +The transport already kills a PTY when its spawn resolves after `destroyed` is +set, but the two direct background launchers do not use that transport. Each +direct launcher must revalidate tab ownership immediately after `pty.spawn` or +`terminal.create` resolves and before writing any binding, layout, eager buffer, +subscription, or mount request. A retired local or SSH result is killed; a +retired runtime result is closed through `terminal.close`. + +### Provider shutdown fails + +The tab remains closed; reappearing would violate user intent. Report the +provider class through structured diagnostics and leave the session visible in +provider inventory so existing Resource Manager orphan cleanup can retry. A +durable pending-retirement inventory is a later main-process API and is not +claimed by Phase 1. Do not restore resume authority after a failed terminal +kill. + +### Remote host is unavailable + +Keep the local close intent until either a host snapshot confirms removal or the +existing intent TTL expires. If it expires, the host remains authoritative and +may republish the tab; log the failed close. Never pretend a local mirror prune +terminated the host PTY. + +### App exits during close + +Submit shutdown IPC before discarding the only local PTY identifiers. The main +process/provider owns completion after accepting the request. A future batched +shutdown IPC may return per-ID acceptance, but UI close does not wait for it. + +## Implementation plan + +### Phase 1: correctness patch (implemented) + +1. Add a small store-independent terminal-tab retirement planner that collects + all six ID sources, classifies execution owners, and excludes IDs shared by + another live surface. +2. Add an explicit close reason, update natural-exit callers, and make explicit + `closeTab` retirement dispose the whole parked-tab registry, submit local, + SSH, or runtime shutdown, atomically clear all reconnect maps, and remove + tab-owned resume records. +3. Add post-await owner checks to both direct background launchers so a late + local, SSH, or runtime spawn is terminated before any renderer binding. +4. Defer decision-time expiry until unowned defensive `live` or `quit` records + can be distinguished without changing intentional `worktree-sleep` or legacy + recovery behavior. +5. Add focused planner, store, launcher-race, and resume-policy tests. Keep one + local Electron parked-close proof as an RC release gate. + +### Phase 2: remaining API cleanup + +1. Rename the state-only reducer primitive so `closeTab` cannot ambiguously mean + either UI cleanup or lifecycle teardown. +2. Route all user and runtime close call sites through one domain command. +3. Keep explicit `detach` and `sleep` APIs; do not encode them as close options. +4. Add a development assertion when tab state is removed with provider PTY IDs + but no retirement plan. + +Exact pane retirement, persisted detach authority transfer, and unified-only +terminal routing landed with Phase 1 because exact-head review found they were +required to avoid introducing adjacent close regressions. + +### Phase 3: daemon ownership leases + +1. Define versioned attach/detach/retire lease messages. +2. Preserve warm reattach across renderer/app restarts within a bounded grace. +3. Reap ownerless sessions and expose pressure telemetry. +4. Add compatibility tests across the supported daemon protocol versions and + native/WSL/SSH host isolation. + +## Test plan + +### Unit/store tests + +- Closing an active single-pane tab submits exactly its PTY for shutdown and + removes all tab state. +- Closing a parked tab with no mounted transport still submits its PTY. +- Closing a split tab submits every unique PTY found across tab row, index, and + layout plus relay/deferred/reconnect maps, including IDs present in only one + source. +- Duplicate IDs are submitted once by the authoritative retirement plan. +- An ID referenced by another live tab is not killed when the source tab closes. +- `remote:` IDs are excluded from local PTY shutdown. +- App-scoped SSH IDs are included and are not mistaken for remote runtime IDs. +- All sleeping records for the tab are removed by key prefix or `record.tabId`; + sibling records retain identity. +- Periodic capture after close does not recreate the record. +- Late agent status after close cannot recreate live status or resume authority. +- Closing twice and close-after-exit are no-ops after the first retirement. +- A spawn resolving after close kills the newly returned PTY. +- A defensive live/quit record at the expiry boundary is retained; one + millisecond past it is cleared and never launched, while an older intentional + worktree-sleep record still resumes. + +### Renderer integration tests + +- Mounted tab close still destroys its xterm and terminates the PTY. +- Parked watcher disposal occurs before or with PTY shutdown and emits no final + completion/bell notification. +- Closing one split pane preserves the sibling; closing the last pane retires + the tab. +- Pane-to-tab detach preserves the PTY and resume record. +- Pinned-tab cancellation performs no shutdown; confirmed close does. +- Bulk close (`close others`, `close right`, kill-all) uses the same semantics. + +### Provider/main tests + +- Local, daemon, WSL, ConPTY, and SSH ownership route explicit close to the + correct provider. +- Disconnected SSH close tombstones the app-scoped ID and never falls through to + a local provider. +- Repeated `pty.kill` for the same ID is benign. +- Remote-runtime IDs cannot reach the local kill handler from renderer close. + +### Electron end-to-end regression + +1. Create a worktree terminal and start a deterministic long-lived child. +2. Switch worktrees and wait until the source worktree is cold-parked. +3. Close the parked tab. +4. Assert its exact PTY disappears from `pty:listSessions` and the child exits. +5. Restart/reload Orca and activate the worktree. +6. Assert no terminal or agent resume command is recreated for the closed pane. +7. Repeat with two split panes and with an SSH fixture where CI supports it. + +### Soak and pressure gate + +Run repeated create, park, close, reload, and reopen cycles. App-owned PTY count +must return to baseline after each cycle and remain bounded over the run. Record +the peak and final provider session count in CI artifacts. + +## Observability + +Emit a synchronous scheduled summary and an asynchronous completion summary: + +- tab/worktree identifiers in hashed or existing diagnostic form; +- number of PTY IDs discovered by each source; +- provider classification counts; +- submitted count, then fulfilled/rejected counts after `Promise.allSettled`; +- number of resume records removed; +- whether the tab was parked or mounted when retired. + +After the daemon-lease phase, Resource Manager should distinguish: + +- attached sessions; +- warm-detached sessions eligible for reattach; +- ownerless/orphan sessions; +- pending/failed explicit retirement. + +Alert locally before app-owned PTYs approach platform pressure. The warning is a +defense, not a substitute for lifecycle correctness. + +## Rollout + +1. Land the correctness patch without a feature flag. User close semantics are + restorative behavior, not an experiment. +2. Keep hidden-view parking enabled; disabling it masks the ownership bug and + forfeits its memory benefit. +3. Run focused unit/integration tests plus the live parked-close Electron gate. +4. Cut an RC and soak create/close/restart cycles while monitoring provider and + OS PTY counts. +5. Ship daemon leases separately behind protocol compatibility and telemetry. + +## Alternatives rejected + +### Disable hidden-view parking + +This restores the old accidental unmount behavior at significant memory cost +and leaves every other state-only close path fragile. It does not repair resume +records or daemon ownership. + +### Kill only from `TerminalPane` cleanup + +There is intentionally no pane during parking and some headless/runtime flows. +View cleanup cannot be process authority. + +### Clear only resume records + +This prevents resurrection but still leaks the live PTY until app/daemon exit. + +### Kill only the tab row's `ptyId` + +Split panes and partially reconciled layouts can own multiple IDs. The full +union is required. + +### Rely only on a daemon idle timeout + +Interactive agents can be legitimately idle for hours. Reaping by output +idleness loses user work; ownership, not activity, is the safe signal. + +### Await every provider exit before hiding the tab + +Remote and disconnected providers can take seconds or fail. UI retirement must +be immediate once the user confirms; provider completion is asynchronous and +observable. + +## Phase 1 acceptance criteria + +- A confirmed terminal-tab close submits retirement for every exclusively owned + session Orca can currently reach and reports rejected requests without an + unhandled promise. +- A closed agent session is never resumed by restart, activation, or mobile wake. +- Parking, move/detach, sleep/wake, and warm app reattach retain their documented + behavior. +- Split, local, SSH, and remote-runtime ownership are covered by deterministic + tests; Windows/WSL process-tree behavior remains a live-platform follow-up. +- PTY counts remain bounded across the soak scenario. +- No process-lifetime decision depends on React component mount state. + +## Full-contract follow-ups + +- Durable main-owned retirement intent and bounded retry inventory. +- Disconnected SSH relay kill-on-reconnect. +- Serializable daemon attachment leases with protocol-version fallback. +- Positive ownership validation for late hook events beyond the bounded recent- + close registry. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index dbe2fbc02b3..7b8c9cb8318 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -182,7 +182,6 @@ export default defineConfig({ 'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'), 'stt-worker': resolve('src/main/speech/stt-worker.ts'), 'warp-theme-parser-worker': resolve('src/main/warp-themes/warp-theme-parser-worker.ts'), - 'file-watcher-worker': resolve('src/main/runtime/file-watcher-worker.ts'), // Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults // can't take down the main process (issue #7547). 'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'), diff --git a/mobile/app/h/[hostId]/accounts.tsx b/mobile/app/h/[hostId]/accounts.tsx index 2a5f78abfa8..652dfffee75 100644 --- a/mobile/app/h/[hostId]/accounts.tsx +++ b/mobile/app/h/[hostId]/accounts.tsx @@ -23,6 +23,7 @@ import { getActiveProviderRateLimits, getInactiveProviderUsage, getUsageBarState, + getWindowResetLabel, hasActiveProviderUsage, UsageBar } from '../../../src/components/AccountUsage' @@ -40,6 +41,14 @@ export default function AccountsScreen() { const [refreshing, setRefreshing] = useState(false) const [busyAccountId, setBusyAccountId] = useState(null) + // Why: the reset countdown must stay fresh while the screen sits open — + // snapshot pushes only arrive when the desktop's rate-limit poll completes. + const [now, setNow] = useState(() => Date.now()) + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 60_000) + return () => clearInterval(id) + }, []) + useEffect(() => { if (!hostId) { return @@ -163,12 +172,14 @@ export default function AccountsScreen() { usedPercent={activeSessionBar.usedPercent} unavailable={activeSessionBar.unavailable} loading={activeSessionBar.loading} + resetText={getWindowResetLabel(activeUsage, 'session', now)} /> ) : null} @@ -211,12 +222,14 @@ export default function AccountsScreen() { usedPercent={sessionBar.usedPercent} unavailable={sessionBar.unavailable} loading={sessionBar.loading} + resetText={getWindowResetLabel(usage, 'session', now)} /> {usage?.error ? ( diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index d613bff4575..1b2ac212d2f 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -1,5 +1,14 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react' -import { View, Text, StyleSheet, SectionList, Pressable, ActivityIndicator } from 'react-native' +import { + View, + Text, + StyleSheet, + SectionList, + Pressable, + ActivityIndicator, + Alert, + RefreshControl +} from 'react-native' import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' import { useFocusEffect, useLocalSearchParams, usePathname, useRouter } from 'expo-router' import { @@ -20,7 +29,8 @@ import { PanelLeftClose } from 'lucide-react-native' import type { RpcClient } from '../../../src/transport/rpc-client' -import { loadHosts, updateLastConnected, removeHost } from '../../../src/transport/host-store' +import { loadHosts, updateLastConnected } from '../../../src/transport/host-store' +import { removeHostAndCloseClient } from '../../../src/transport/host-removal-lifecycle' import { useHostClient, useCloseHost, @@ -28,6 +38,7 @@ import { useReconnectAttempt, useLastConnectedAt } from '../../../src/transport/client-context' +import { useWorktreeResync } from '../../../src/transport/use-worktree-resync' import { classifyConnection, type ConnectionVerdict @@ -50,7 +61,7 @@ import { ProtocolBlockScreen } from '../../../src/components/ProtocolBlockScreen import { AuthFailedBanner } from '../../../src/components/AuthFailedBanner' import { MobileSearchField } from '../../../src/components/MobileSearchField' import { WorkspaceDetailPlaceholder } from '../../../src/components/WorkspaceDetailPlaceholder' -import { getCachedWorktrees } from '../../../src/cache/worktree-cache' +import { getCachedWorktrees, setCachedWorktrees } from '../../../src/cache/worktree-cache' import { setCachedRepos } from '../../../src/cache/repo-cache' import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme' import { useResponsiveLayout } from '../../../src/layout/responsive-layout' @@ -450,6 +461,14 @@ export function HostScreen({ areWorktreeListsEqual(current, result.worktrees) ? current : result.worktrees ) setWorktreesLoaded(true) + // Why (#8498): the host detail screen seeds its list from the + // home-written cache, so a partial home fetch could poison it until a + // focus poll corrected it. Write the confirmed snapshot back through + // the same cache so a reconnect refetch (or a remount) can't serve a + // stale worktree list. + if (hostId) { + setCachedWorktrees(hostId, result.worktrees) + } // Drop the optimistic active override once the host confirms it (the // activate RPC has landed and worktree.ps now reports it active), so we // stop overriding and respect any later desktop-driven change. @@ -604,6 +623,17 @@ export function HostScreen({ return () => clearInterval(interval) }, [embedded, connState, fetchWorktrees, fetchRepoMetadata, syncViewSettingsFromDesktop]) + // Why (#8498): reconnect refetch + manual pull-to-refresh, extracted to + // useWorktreeResync so this screen stays under its max-lines budget. The + // steady-state focus/embedded polls don't cover the transition INTO + // 'connected' after a background/sleep, which is when the cache is stalest. + const { refreshing, onRefresh } = useWorktreeResync({ + client, + connState, + fetchWorktrees, + fetchRepoMetadata + }) + const updateLocalPins = useCallback( (worktreeId: string, pinned: boolean) => { setPinnedIds((prev) => { @@ -684,13 +714,15 @@ export function HostScreen({ if (!hostId) { return } - // Why: close the shared client first so its WebSocket is gone before - // the host record disappears; otherwise the next loadHosts() the - // provider does (e.g. on remount) wouldn't find this host but the - // socket would still be open, leaking state. - closeHostClient(hostId) - await removeHost(hostId) - leaveHost() + try { + await removeHostAndCloseClient(hostId, closeHostClient) + leaveHost() + } catch { + // Why: metadata commit can fail while the host is still paired; keep the + // screen mounted and re-open confirm (ConfirmModal closes on confirm). + setConfirmRemoveHost(true) + Alert.alert('Could not remove host', 'Please try again.') + } }, [hostId, leaveHost, closeHostClient]) const navigateFromHostList = useCallback( @@ -1218,6 +1250,16 @@ export function HostScreen({ ) }} ItemSeparatorComponent={ListSeparator} + // Why (#8498): manual pull-to-refresh forces a fresh worktree + // snapshot after a reconnect or whenever the cache looks stale. + refreshControl={ + + } renderItem={({ item }) => ( { newWorktreeModalVisibleRef.current = visible }} diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 1b63bf59b7a..5cc9ac912b7 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -1080,6 +1080,13 @@ export default function SessionScreen() { // the row) without any window-dim change. Tracking the measured width lets the // refit hook re-fit the PTY on those resizes — see terminal-viewport-refit.ts. const [terminalFrameWidth, setTerminalFrameWidth] = useState(0) + // Why: a new agent terminal can fit before the accessory/live-input dock lays + // out, over-fitting the PTY so its bottom-pinned input box hides behind the + // dock; tracking the settled height lets the refit hook correct it. + const [terminalFrameHeight, setTerminalFrameHeight] = useState(0) + // Why: lets the height refit skip keyboard-driven resizes (never reflow the + // PTY per keystroke); mirrors keyboardHeight but readable synchronously. + const keyboardVisibleRef = useRef(false) const activeSessionTab = sessionTabs.find((tab) => tab.id === activeSessionTabId) ?? null const { @@ -2528,6 +2535,7 @@ export default function SessionScreen() { } }, []) + const pendingForegroundRecoveryRef = useRef(false) useEffect(() => { let previousAppState: AppStateStatus | null = AppState.currentState const sub = AppState.addEventListener('change', (nextAppState: AppStateStatus) => { @@ -2546,7 +2554,7 @@ export default function SessionScreen() { // Why: iOS can resume a live WKWebView with a blank xterm backing store // without firing web-ready/reconnect; invalidate the native readiness // latch before replay so init waits for the document's pong. - recoverActiveTerminalAfterForeground({ + const outcome = recoverActiveTerminalAfterForeground({ activeHandleRef, terminalRefs, initializedHandlesRef, @@ -2555,12 +2563,36 @@ export default function SessionScreen() { subscribeToTerminal, schedule: scheduleDelayedAction }) + pendingForegroundRecoveryRef.current = outcome === 'deferred' }) return () => { sub.remove() } }, [scheduleDelayedAction, subscribeToTerminal, unsubscribeTerminal]) + // Why: resume usually lands mid-reconnect (the socket dies after ~60-80s of + // background), so the recovery above defers. Re-run it once the connection + // is back; otherwise a blanked WKWebView whose socket was merely probed (no + // stream replay) stays stale until a manual tab switch. + useEffect(() => { + if (connState !== 'connected' || !pendingForegroundRecoveryRef.current) { + return + } + pendingForegroundRecoveryRef.current = false + if (AppState.currentState !== 'active') { + return + } + recoverActiveTerminalAfterForeground({ + activeHandleRef, + terminalRefs, + initializedHandlesRef, + connStateRef, + unsubscribeTerminal, + subscribeToTerminal, + schedule: scheduleDelayedAction + }) + }, [connState, scheduleDelayedAction, subscribeToTerminal, unsubscribeTerminal]) + // Why: viewport refits for layout changes outside the subscribe path // (tab strip toggling, fold/unfold, rotation) live in a dedicated hook — // see terminal-viewport-refit.ts for the full rationale. @@ -2577,15 +2609,19 @@ export default function SessionScreen() { tabStripVisible: terminals.length > 1, textScale: terminalTextScale, terminalFrameWidth, + terminalFrameHeight, + keyboardVisibleRef, unsubscribeTerminal, subscribeToTerminal }) useEffect(() => { const onShow = (e: KeyboardEvent) => { + keyboardVisibleRef.current = true setKeyboardHeight(e.endCoordinates?.height ?? 0) } const onHide = () => { + keyboardVisibleRef.current = false setKeyboardHeight(0) } const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow' @@ -3046,10 +3082,15 @@ export default function SessionScreen() { // this, subscribe(viewport=null) lands on the server first and the // post-scrollback measure path's resubscribe sees alreadyMeasured=true // (because measureViewportOnce won the race) and silently skips. - if (handle === activeHandleRef.current && !terminalUnsubsRef.current.has(handle)) { + // Why: a just-created tab can briefly lose activeHandleRef to a lagging + // session-tab snapshot; honor the pending marker so its one web-ready + // subscribe still fires (see handleCreateTerminal). + const isIntendedActive = () => + handle === activeHandleRef.current || handle === pendingActiveTerminalHandleRef.current + if (isIntendedActive() && !terminalUnsubsRef.current.has(handle)) { void (async () => { await measureViewportOnce(handle) - if (handle === activeHandleRef.current && !terminalUnsubsRef.current.has(handle)) { + if (isIntendedActive() && !terminalUnsubsRef.current.has(handle)) { subscribeToTerminal(handle) } })() @@ -3937,6 +3978,11 @@ export default function SessionScreen() { if (typeof created.terminal === 'string') { const createdHandle = created.terminal defaultTerminalHandlesToLiveInput([createdHandle]) + // Why: session-tab snapshots can lag the create RPC. Without the + // handle marker, applySessionTabs snaps activeHandleRef back to the + // previous terminal, and the new pane's web-ready subscribe (gated + // on the active handle) is skipped — blank tab until a manual switch. + pendingActiveTerminalHandleRef.current = createdHandle activeHandleRef.current = createdHandle setActiveHandle(createdHandle) setTerminals((prev) => { @@ -3991,6 +4037,9 @@ export default function SessionScreen() { }) } } else { + // Why: a prior pending handle must not outlive a create that returned + // no terminal; web-ready subscribe gates on this ref as active. + pendingActiveTerminalHandleRef.current = null activeHandleRef.current = null setActiveHandle(null) } @@ -4793,10 +4842,12 @@ export default function SessionScreen() { style={styles.terminalFrame} onLayout={(e) => { terminalFrameHeightRef.current = e.nativeEvent.layout.height - // Trigger a refit only when the width actually changes (sidebar - // resize, fold, rotation) — avoids churn on height-only changes. + // Track width AND height so the refit hook re-fits on sidebar/ + // fold/rotation (width) and on the dock settling (height). const nextWidth = Math.round(e.nativeEvent.layout.width) + const nextHeight = Math.round(e.nativeEvent.layout.height) setTerminalFrameWidth((prev) => (prev === nextWidth ? prev : nextWidth)) + setTerminalFrameHeight((prev) => (prev === nextHeight ? prev : nextHeight)) }} > {terminals.map((terminal) => ( diff --git a/mobile/app/h/[hostId]/tasks.tsx b/mobile/app/h/[hostId]/tasks.tsx index fea23f01764..c61d01559cd 100644 --- a/mobile/app/h/[hostId]/tasks.tsx +++ b/mobile/app/h/[hostId]/tasks.tsx @@ -61,6 +61,7 @@ import { } from '../../../src/session/mobile-file-syntax' import { buildGitHubCheckSummary } from '../../../src/tasks/github-check-summary' import { buildTaskWorkspaceCreateParams } from '../../../src/tasks/workspace-create-params' +import { MOBILE_TASKS_CAPABILITY } from '../../../src/tasks/mobile-tasks-capability' import { filterWorkspaceAgents, isWorkspaceAgentEnabled, @@ -858,7 +859,6 @@ const GITHUB_REPO_CONCURRENCY = 3 const MAX_RENDERED_PR_DIFF_LINES = 400 const GITLAB_PER_PAGE = 50 const LINEAR_LIMIT = 50 -const MOBILE_TASKS_CAPABILITY = 'mobile.tasks.v1' // Why: task detail drawers can launch child sheets; children must layer above // the still-mounted parent while its dismissal animation/state remains alive. const TASK_SECONDARY_DRAWER_Z_INDEX = 1100 diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index eaee0e5f935..d92dffb0a3e 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -1,5 +1,5 @@ import { useState, useCallback, useEffect, useMemo, useRef } from 'react' -import { View, Text, StyleSheet, Pressable, FlatList } from 'react-native' +import { View, Text, StyleSheet, Pressable, FlatList, Alert } from 'react-native' import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' import { useRouter, useFocusEffect } from 'expo-router' import { @@ -25,7 +25,8 @@ import { UsageBar } from '../src/components/AccountUsage' import AsyncStorage from '@react-native-async-storage/async-storage' -import { loadHosts, removeHost, renameHost } from '../src/transport/host-store' +import { loadHosts, renameHost } from '../src/transport/host-store' +import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle' import { pickResumeWorktree } from '../src/worktree/resume-worktree' import type { RpcClient } from '../src/transport/rpc-client' import { @@ -710,15 +711,16 @@ export default function HomeScreen() { if (!confirmRemove) { return } + const hostToRemove = confirmRemove try { - // Why: close the shared client first so the WebSocket is gone - // before the host record disappears from loadHosts(). - closeHostClient(confirmRemove.id) - await removeHost(confirmRemove.id) + await removeHostAndCloseClient(hostToRemove.id, closeHostClient) setConfirmRemove(null) setHosts(await loadHosts()) } catch { - setConfirmRemove(null) + // Why: ConfirmModal closes on confirm; re-open for retry and surface the + // failure instead of silently leaving the host listed. + setConfirmRemove(hostToRemove) + Alert.alert('Could not remove host', 'Please try again.') } } diff --git a/mobile/app/settings.tsx b/mobile/app/settings.tsx index d12b13a15e8..b0bd73f651f 100644 --- a/mobile/app/settings.tsx +++ b/mobile/app/settings.tsx @@ -1,6 +1,15 @@ -import { View, Text, StyleSheet, Pressable, Linking } from 'react-native' +import { useCallback, useRef, useState } from 'react' +import { + View, + Text, + StyleSheet, + Pressable, + Linking, + ActivityIndicator, + ScrollView +} from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { useRouter } from 'expo-router' +import { useFocusEffect, useRouter } from 'expo-router' import { ChevronLeft, ChevronRight, @@ -11,13 +20,76 @@ import { LifeBuoy, Mic, Globe, - Terminal as TerminalIcon + Terminal as TerminalIcon, + KeyRound } from 'lucide-react-native' -import { colors, spacing, typography } from '../src/theme/mobile-theme' +import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' +import { + loadPendingHostCredentialCleanup, + subscribePendingHostCredentialCleanup +} from '../src/transport/host-credential-cleanup' +import { retryPendingHostCredentialCleanup } from '../src/transport/host-store' export default function SettingsScreen() { const router = useRouter() const insets = useSafeAreaInsets() + const [pendingCredentialIds, setPendingCredentialIds] = useState([]) + const [credentialStorageUnreadable, setCredentialStorageUnreadable] = useState(false) + const [retryingCredentialCleanup, setRetryingCredentialCleanup] = useState(false) + const [credentialRetryFailed, setCredentialRetryFailed] = useState(false) + const credentialRefreshGenerationRef = useRef(0) + + useFocusEffect( + useCallback(() => { + let active = true + setCredentialRetryFailed(false) + const refresh = () => { + const generation = ++credentialRefreshGenerationRef.current + void loadPendingHostCredentialCleanup().then((state) => { + if (active && generation === credentialRefreshGenerationRef.current) { + setPendingCredentialIds(state.ids) + setCredentialStorageUnreadable(state.storageUnreadable) + // Why: neutral copy once the queue is confirmed empty so a later + // pending set does not inherit a previous Retry failure message. + if (state.ids.length === 0 && !state.storageUnreadable) { + setCredentialRetryFailed(false) + } + } + }) + } + const unsubscribe = subscribePendingHostCredentialCleanup(refresh) + refresh() + return () => { + active = false + credentialRefreshGenerationRef.current += 1 + unsubscribe() + } + }, []) + ) + + const retryCredentialCleanup = useCallback(async () => { + if (retryingCredentialCleanup) { + return + } + setCredentialRetryFailed(false) + setRetryingCredentialCleanup(true) + try { + const result = await retryPendingHostCredentialCleanup() + setPendingCredentialIds(result.remainingIds) + setCredentialStorageUnreadable(result.storageUnreadable) + setCredentialRetryFailed(result.remainingIds.length > 0 || result.storageUnreadable) + } catch { + setCredentialRetryFailed(true) + } finally { + setRetryingCredentialCleanup(false) + } + }, [retryingCredentialCleanup]) + + const pendingCredentialCount = pendingCredentialIds.length + // Why: show the cleanup card whenever cleanup is pending OR the durable queue + // is unreadable — an unreadable queue can hide an orphaned token, so keep a + // retry affordance rather than a silently-empty (hidden) section. + const showCredentialCleanup = pendingCredentialCount > 0 || credentialStorageUnreadable return ( @@ -28,79 +100,123 @@ export default function SettingsScreen() { Settings - - [styles.row, pressed && styles.rowPressed]} - onPress={() => router.push('/terminal-settings')} - > - - Terminal - - - - [styles.row, pressed && styles.rowPressed]} - onPress={() => router.push('/browser-settings')} - > - - Browser - - - - [styles.row, pressed && styles.rowPressed]} - onPress={() => router.push('/voice-settings')} - > - - Voice - - - - [styles.row, pressed && styles.rowPressed]} - onPress={() => router.push('/notifications')} - > - - Notifications - - - - [styles.row, pressed && styles.rowPressed]} - onPress={() => router.push('/troubleshoot')} - > - - Troubleshooting - - - - [styles.row, pressed && styles.rowPressed]} - onPress={() => router.push('/about')} - > - - About - - - + + + [styles.row, pressed && styles.rowPressed]} + onPress={() => router.push('/terminal-settings')} + > + + Terminal + + + + [styles.row, pressed && styles.rowPressed]} + onPress={() => router.push('/browser-settings')} + > + + Browser + + + + [styles.row, pressed && styles.rowPressed]} + onPress={() => router.push('/voice-settings')} + > + + Voice + + + + [styles.row, pressed && styles.rowPressed]} + onPress={() => router.push('/notifications')} + > + + Notifications + + + + [styles.row, pressed && styles.rowPressed]} + onPress={() => router.push('/troubleshoot')} + > + + Troubleshooting + + + + [styles.row, pressed && styles.rowPressed]} + onPress={() => router.push('/about')} + > + + About + + + - - [styles.row, pressed && styles.rowPressed]} - onPress={() => void Linking.openURL('https://www.onorca.dev/privacy')} - > - - Privacy Policy - - - [styles.row, pressed && styles.rowPressed]} - onPress={() => void Linking.openURL('https://github.com/stablyai/orca/issues')} - > - - Support - - + {showCredentialCleanup ? ( + + + + + Pairing credential cleanup + + {credentialRetryFailed + ? "Cleanup still couldn't be confirmed. Try again later." + : pendingCredentialCount > 0 + ? `Couldn't confirm cleanup for ${pendingCredentialCount} credential${pendingCredentialCount === 1 ? '' : 's'} on this device.` + : "Couldn't check cleanup status on this device. Retry to be safe."} + + + [ + styles.retryButton, + pressed && !retryingCredentialCleanup && styles.rowPressed + ]} + onPress={() => void retryCredentialCleanup()} + > + {retryingCredentialCleanup ? ( + + ) : ( + Retry + )} + + + + ) : null} + + + [styles.row, pressed && styles.rowPressed]} + onPress={() => void Linking.openURL('https://www.onorca.dev/privacy')} + > + + Privacy Policy + + + [styles.row, pressed && styles.rowPressed]} + onPress={() => void Linking.openURL('https://github.com/stablyai/orca/issues')} + > + + Support + + + ) } @@ -109,7 +225,7 @@ const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: colors.bgBase, - padding: spacing.lg + paddingHorizontal: spacing.lg }, topRow: { flexDirection: 'row', @@ -153,6 +269,40 @@ const styles = StyleSheet.create({ fontWeight: '500', color: colors.textPrimary }, + credentialCleanupRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + 2, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + credentialCleanupCopy: { + flex: 1, + gap: spacing.xs + }, + credentialCleanupTitle: { + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textPrimary + }, + rowHint: { + fontSize: typography.metaSize, + color: colors.textSecondary, + lineHeight: 17 + }, + retryButton: { + width: 72, + height: 32, + borderRadius: radii.button, + backgroundColor: colors.bgRaised, + alignItems: 'center', + justifyContent: 'center' + }, + retryButtonText: { + fontSize: typography.metaSize, + fontWeight: '600', + color: colors.textPrimary + }, separator: { height: StyleSheet.hairlineWidth, backgroundColor: colors.borderSubtle, diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 224511d38c5..d28e9ac1d40 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -1935,48 +1935,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-arm64-musl@0.52.0': resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxfmt/binding-linux-ppc64-gnu@0.52.0': resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-riscv64-gnu@0.52.0': resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-riscv64-musl@0.52.0': resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxfmt/binding-linux-s390x-gnu@0.52.0': resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-x64-gnu@0.52.0': resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-x64-musl@0.52.0': resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxfmt/binding-openharmony-arm64@0.52.0': resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==} @@ -2049,48 +2057,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-arm64-musl@1.71.0': resolution: {integrity: sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxlint/binding-linux-ppc64-gnu@1.71.0': resolution: {integrity: sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-riscv64-gnu@1.71.0': resolution: {integrity: sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-riscv64-musl@1.71.0': resolution: {integrity: sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxlint/binding-linux-s390x-gnu@1.71.0': resolution: {integrity: sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxlint/binding-linux-x64-gnu@1.71.0': resolution: {integrity: sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-x64-musl@1.71.0': resolution: {integrity: sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxlint/binding-openharmony-arm64@1.71.0': resolution: {integrity: sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==} @@ -2552,36 +2568,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.1.3': resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.1.3': resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.1.3': resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.1.3': resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.1.3': resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.1.3': resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==} @@ -4881,24 +4903,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -7025,22 +7051,22 @@ snapshots: '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.7)': dependencies: @@ -7050,7 +7076,7 @@ snapshots: '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-export-default-from@7.28.6(@babel/core@7.29.7)': dependencies: @@ -7085,12 +7111,12 @@ snapshots: '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.7)': dependencies: @@ -7105,42 +7131,42 @@ snapshots: '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.7)': dependencies: @@ -8604,7 +8630,7 @@ snapshots: '@jest/console@29.7.0': dependencies: '@jest/types': 29.6.3 - '@types/node': 25.6.0 + '@types/node': 26.1.1 chalk: 4.1.2 jest-message-util: 29.7.0 jest-util: 29.7.0 @@ -8771,7 +8797,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 25.6.0 + '@types/node': 26.1.1 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -9529,24 +9555,24 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/chai@5.2.3': dependencies: @@ -9561,7 +9587,7 @@ snapshots: '@types/graceful-fs@4.1.9': dependencies: - '@types/node': 25.6.0 + '@types/node': 26.1.1 '@types/hammerjs@2.0.46': {} @@ -9639,7 +9665,7 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3) @@ -9659,7 +9685,7 @@ snapshots: dependencies: '@typescript-eslint/scope-manager': 8.59.2 '@typescript-eslint/types': 8.59.2 - '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.2(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.59.2 debug: 4.4.3 eslint: 9.39.4 @@ -9676,6 +9702,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/project-service@8.59.2(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3) + '@typescript-eslint/types': 8.59.2 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/scope-manager@8.59.2': dependencies: '@typescript-eslint/types': 8.59.2 @@ -9685,6 +9720,10 @@ snapshots: dependencies: typescript: 5.9.3 + '@typescript-eslint/tsconfig-utils@8.59.2(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + '@typescript-eslint/type-utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.59.2 @@ -9714,6 +9753,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@8.59.2(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.2(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3) + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/visitor-keys': 8.59.2 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) @@ -9972,7 +10026,7 @@ snapshots: babel-plugin-istanbul@6.1.1: dependencies: - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@istanbuljs/load-nyc-config': 1.1.0 '@istanbuljs/schema': 0.1.6 istanbul-lib-instrument: 5.2.1 @@ -10273,7 +10327,7 @@ snapshots: chrome-launcher@0.15.2: dependencies: - '@types/node': 25.6.0 + '@types/node': 26.1.1 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -10282,7 +10336,7 @@ snapshots: chromium-edge-launcher@0.2.0: dependencies: - '@types/node': 25.6.0 + '@types/node': 26.1.1 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -10678,7 +10732,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.3 + hasown: 2.0.4 es-shim-unscopables@1.1.0: dependencies: @@ -10776,11 +10830,11 @@ snapshots: eslint-config-universe@15.0.4(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3) '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3) eslint: 9.39.4 eslint-config-prettier: 9.1.2(eslint@9.39.4) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4) eslint-plugin-n: 17.24.0(eslint@9.39.4)(typescript@5.9.3) eslint-plugin-node: 11.1.0(eslint@9.39.4) eslint-plugin-prettier: 5.5.5(eslint-config-prettier@9.1.2(eslint@9.39.4))(eslint@9.39.4)(prettier@2.8.8) @@ -10804,7 +10858,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4): dependencies: debug: 3.2.7 optionalDependencies: @@ -10827,7 +10881,7 @@ snapshots: eslint-utils: 2.1.0 regexpp: 3.2.0 - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -10838,7 +10892,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4 eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4) hasown: 2.0.3 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -11515,7 +11569,7 @@ snapshots: get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.3 + hasown: 2.0.4 math-intrinsics: 1.1.0 get-nonce@1.0.1: {} @@ -12087,7 +12141,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.9 - '@types/node': 25.6.0 + '@types/node': 26.1.1 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -12242,7 +12296,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 25.6.0 + '@types/node': 26.1.1 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -12287,7 +12341,7 @@ snapshots: jest-worker@29.7.0: dependencies: - '@types/node': 25.6.0 + '@types/node': 26.1.1 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -14008,6 +14062,10 @@ snapshots: dependencies: typescript: 5.9.3 + ts-api-utils@2.5.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + ts-declaration-location@1.0.7(typescript@5.9.3): dependencies: picomatch: 4.0.4 diff --git a/mobile/pnpm-workspace.yaml b/mobile/pnpm-workspace.yaml new file mode 100644 index 00000000000..35f983a345a --- /dev/null +++ b/mobile/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +allowBuilds: + esbuild: true + +overrides: + xcode>uuid: 11.1.1 diff --git a/mobile/scripts/start-emulator.mjs b/mobile/scripts/start-emulator.mjs index eb50c9a45c0..d6489b530ee 100755 --- a/mobile/scripts/start-emulator.mjs +++ b/mobile/scripts/start-emulator.mjs @@ -506,6 +506,11 @@ async function openPairingUrlInSimulator(pairingUrl, deviceUdid, runtime, worktr await execFileAsync('xcrun', ['simctl', 'openurl', deviceUdid, pairingUrl]) await new Promise((resolve) => setTimeout(resolve, 2000)) + // Why: the first deep link can arrive while the freshly opened Expo app is + // still mounting, so resend it once the JS router is ready to receive URLs. + await execFileAsync('xcrun', ['simctl', 'openurl', deviceUdid, pairingUrl]) + await new Promise((resolve) => setTimeout(resolve, 2000)) + // Why: the mobile app intentionally asks for a trust confirmation before // saving a host. This lands on the Pair button on current iPhone simulators. await orca(['emulator', 'tap', '0.5', '0.56', '--worktree', worktree, '--json'], { diff --git a/mobile/src/cache/worktree-cache.test.ts b/mobile/src/cache/worktree-cache.test.ts new file mode 100644 index 00000000000..f167c6c547d --- /dev/null +++ b/mobile/src/cache/worktree-cache.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { setCachedWorktrees, getCachedWorktrees } from './worktree-cache' + +// Why: AC #8498 guarantees a reconnect refetch writes through the +// same cache path the host detail screen seeds from, so a reconnect can't +// serve a stale snapshot. This unit pins the write-through contract. +describe('worktree-cache write-through', () => { + it('returns the most-recently written snapshot, not a stale one', () => { + const hostId = 'host-write-through' + const stale = [{ worktreeId: 'a', name: 'stale' }] + const fresh = [ + { worktreeId: 'a', name: 'fresh' }, + { worktreeId: 'b', name: 'added' } + ] + + setCachedWorktrees(hostId, stale) + expect(getCachedWorktrees(hostId)).toEqual(stale) + + // Why: simulates the reconnect refetch write-through — the fresh + // worktree.ps snapshot must fully replace the poisoned cache entry. + setCachedWorktrees(hostId, fresh) + expect(getCachedWorktrees(hostId)).toEqual(fresh) + expect(getCachedWorktrees(hostId)).not.toEqual(stale) + }) + + it('exposes a fresh snapshot to a remounting screen after reconnect', () => { + // Why: the host detail screen reads getCachedWorktrees(hostId) + // on (re)mount as its initialCache. A reconnect that writes + // through must therefore surface here instead of the pre-reconnect data. + const hostId = 'host-remount' + setCachedWorktrees(hostId, [{ worktreeId: 'old', name: 'pre-reconnect' }]) + + // Reconnect refetch lands a fresh snapshot and writes it through. + const reconnected = [ + { worktreeId: 'old', name: 'post-reconnect' }, + { worktreeId: 'new', name: 'now-visible' } + ] + setCachedWorktrees(hostId, reconnected) + + // A fresh screen mount reads the cache — must see the connected set. + expect(getCachedWorktrees(hostId)).toEqual(reconnected) + }) +}) diff --git a/mobile/src/components/AccountUsage.tsx b/mobile/src/components/AccountUsage.tsx index 00b3e607487..713a2dd8a46 100644 --- a/mobile/src/components/AccountUsage.tsx +++ b/mobile/src/components/AccountUsage.tsx @@ -17,6 +17,7 @@ export { getActiveProviderRateLimits, getInactiveProviderUsage, getUsageBarState, + getWindowResetLabel, hasActiveProviderUsage, hasRenderableUsage } from './account-usage-state' @@ -28,12 +29,14 @@ export function UsageBar({ label, usedPercent, unavailable, - loading + loading, + resetText }: { label: string usedPercent: number | null unavailable: boolean loading?: boolean + resetText?: string | null }) { // Why: round then clamp so bar width, color, and label share one value (desktop parity). const used = usedPercent == null ? null : Math.max(0, Math.min(100, Math.round(usedPercent))) @@ -47,34 +50,48 @@ export function UsageBar({ ? colors.statusAmber : colors.statusGreen return ( - - {label} - - + + + {label} + + + + {loading ? ( + + ) : ( + {unavailable || used == null ? '—' : `${used}%`} + )} - {loading ? ( - - ) : ( - {unavailable || used == null ? '—' : `${used}%`} - )} + {resetText ? ( + + {resetText} + + ) : null} ) } const styles = StyleSheet.create({ + usageBarColumn: { + flex: 1, + gap: 2 + }, usageBar: { flexDirection: 'row', alignItems: 'center', - gap: spacing.xs, - flex: 1 + gap: spacing.xs }, usageLabel: { fontSize: typography.metaSize, @@ -100,5 +117,12 @@ const styles = StyleSheet.create({ }, usageSpinner: { width: 36 + }, + // Why: indented past the window label so the countdown aligns with the + // start of the track above it. + usageResetText: { + fontSize: typography.metaSize, + color: colors.textMuted, + marginLeft: 22 + spacing.xs } }) diff --git a/mobile/src/components/BottomDrawer.tsx b/mobile/src/components/BottomDrawer.tsx index 4e24e8c2e06..eb23e53a554 100644 --- a/mobile/src/components/BottomDrawer.tsx +++ b/mobile/src/components/BottomDrawer.tsx @@ -24,6 +24,7 @@ import Animated, { } from 'react-native-reanimated' import { colors, spacing } from '../theme/mobile-theme' import { resolveBottomDrawerMounted } from './bottom-drawer-mount-state' +import { useInsideBottomDrawerModalHost } from './bottom-drawer-modal-host' import { useResponsiveLayout } from '../layout/responsive-layout' const DISMISS_THRESHOLD = 80 @@ -107,6 +108,7 @@ function MountedBottomDrawer({ // center it horizontally. Vertical bottom-anchoring (and all the drag/keyboard // transforms below) is unchanged, so phone behavior stays identical. const { isWideLayout, modalMaxWidth } = useResponsiveLayout() + const insideModalHost = useInsideBottomDrawerModalHost() useEffect(() => { if (visible) { @@ -265,104 +267,114 @@ function MountedBottomDrawer({ return { opacity: progress.value * dragFade } }) - // Why: rendering through a full-screen Modal lifts the sheet into its own - // native window so it always covers the viewport — even when the drawer is - // mounted deep inside a ScrollView, where a plain absolute overlay anchors to - // the scrolled content and clips the sheet. The Modal stays mounted (visible) - // for the whole life of MountedBottomDrawer so the reanimated exit animation - // runs before the parent unmounts us; show/hide is driven by `progress`, so - // animationType stays "none". onRequestClose handles the Android back button. + // Why: the sheet renders through a full-screen native window (its own Modal + // below, or the shared BottomDrawerModalHost) so it always covers the viewport + // — even when mounted deep inside a ScrollView, where a plain absolute overlay + // anchors to the scrolled content and clips the sheet. Show/hide is driven by + // `progress` (animationType "none") so the reanimated exit animation runs before + // the parent unmounts us. + const overlay = ( + + + + + + + + + {!contentScrollable ? ( + <> + + + + + + {children} + + ) : dragContentToDismiss ? ( + <> + + + + + + + + + + {children} + + + + + + ) : ( + <> + + + + + + + {children} + + + )} + + + + + + ) + + // Why: inside a BottomDrawerModalHost the host owns the single native Modal; + // rendering our own would stack modals and reintroduce the iOS present/dismiss + // race the host exists to avoid. The host handles the Android back button. + if (insideModalHost) { + return overlay + } + return ( - - - - - - - - - {!contentScrollable ? ( - <> - - - - - - {children} - - ) : dragContentToDismiss ? ( - <> - - - - - - - - - - {children} - - - - - - ) : ( - <> - - - - - - - {children} - - - )} - - - - - + {overlay} ) } diff --git a/mobile/src/components/MobileAgentIcon.tsx b/mobile/src/components/MobileAgentIcon.tsx index ade35feda45..6c47fce7a5b 100644 --- a/mobile/src/components/MobileAgentIcon.tsx +++ b/mobile/src/components/MobileAgentIcon.tsx @@ -1,8 +1,11 @@ import { Image, StyleSheet, Text, View } from 'react-native' +import type { ImageSourcePropType } from 'react-native' import { Terminal } from 'lucide-react-native' +import type { TuiAgent } from '../../../src/shared/types' import Svg, { Defs, G, LinearGradient, Path, Stop } from 'react-native-svg' import { colors } from '../theme/mobile-theme' import { MOBILE_AGENT_CATALOG } from '../tasks/mobile-agent-catalog' +import { MOBILE_AGENT_ICON_ASSETS } from './mobile-agent-icon-assets' import { ClaudeIcon, OpenAIIcon } from './AgentIcons' // Why: agent branding should match the desktop/new-worktree picker everywhere @@ -49,6 +52,10 @@ function AiderIcon({ size = 16 }: { size?: number }) { ) } +function BundledIcon({ source, size = 16 }: { source: ImageSourcePropType; size?: number }) { + return +} + function FaviconIcon({ domain, size = 16 }: { domain: string; size?: number }) { return ( } + // Why: prefer the favicon bundled into the app over Google's favicon service, + // which is unreachable in some regions and offline (#8451). + const bundledIcon = MOBILE_AGENT_ICON_ASSETS[agentId as TuiAgent] + if (bundledIcon) { + return + } const agent = MOBILE_AGENT_CATALOG.find((entry) => entry.id === agentId) if (agent?.faviconDomain) { return diff --git a/mobile/src/components/MobilePRSidebar.tsx b/mobile/src/components/MobilePRSidebar.tsx index 42829df0310..f98854682c9 100644 --- a/mobile/src/components/MobilePRSidebar.tsx +++ b/mobile/src/components/MobilePRSidebar.tsx @@ -14,6 +14,7 @@ import { type MobilePrTitleAction } from '../session/use-mobile-pr-title-action' import { useMobilePrAiTriage, type MobilePrAiTriage } from '../session/use-mobile-pr-ai-triage' +import { usePRBotAuthorOverrides } from '../session/use-pr-bot-author-overrides' import { buildFixChecksPrompt, buildResolveConflictsPrompt } from '../session/pr-ai-triage-prompt' import { prSidebarRenderBranch } from './mobile-pr-sidebar-presentation' import { mobilePrSidebarStyles as styles } from './pr-sidebar/mobile-pr-sidebar-styles' @@ -89,6 +90,13 @@ export function MobilePRSidebar({ refetch }) const triage = useMobilePrAiTriage({ client, connState, worktreeId }) + // Keyed on the PR payload identity so overrides re-fetch with each PR refetch + // instead of staying a stale one-shot snapshot for the whole session. + const botAuthorOverrides = usePRBotAuthorOverrides( + client, + connState, + state.kind === 'ready' ? state.data.details : null + ) return ( @@ -134,7 +143,8 @@ function PrSidebarContent({ commentActions, titleAction, triage, - showOpenOnWeb + showOpenOnWeb, + botAuthorOverrides }: { branch: ReturnType state: PrSidebarState @@ -150,6 +160,7 @@ function PrSidebarContent({ titleAction: MobilePrTitleAction triage: MobilePrAiTriage showOpenOnWeb: boolean + botAuthorOverrides: ReadonlySet }) { if (branch === 'loading') { return ( @@ -215,6 +226,7 @@ function PrSidebarContent({ titleAction={titleAction} triage={triage} refetch={refetch} + botAuthorOverrides={botAuthorOverrides} showOpenOnWeb={showOpenOnWeb} /> ) @@ -231,7 +243,8 @@ function PrSidebarSections({ titleAction, triage, refetch, - showOpenOnWeb + showOpenOnWeb, + botAuthorOverrides }: { data: Extract['data'] client: RpcClient | null @@ -242,6 +255,7 @@ function PrSidebarSections({ triage: MobilePrAiTriage refetch: () => void showOpenOnWeb: boolean + botAuthorOverrides: ReadonlySet }) { const pr = data.pr // Bind the triage launchers to this PR's data; the prompt builders are pure so @@ -314,6 +328,7 @@ function PrSidebarSections({ prState={data.pr.state} prRepo={data.pr.prRepo ?? null} actions={commentActions} + botAuthorOverrides={botAuthorOverrides} /> ) diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index 961a62a7eaa..09a0914d4aa 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -10,21 +10,20 @@ import { ActivityIndicator, Keyboard } from 'react-native' -import { ChevronDown, ChevronUp, Check } from 'lucide-react-native' +import { ChevronDown, ChevronUp } from 'lucide-react-native' import type { RpcClient } from '../transport/rpc-client' -import type { RpcSuccess } from '../transport/types' +import type { RpcResponse, RpcSuccess } from '../transport/types' import { colors, spacing, radii, typography } from '../theme/mobile-theme' -import { BottomDrawer } from './BottomDrawer' +import { BottomDrawer, BOTTOM_DRAWER_HIDE_DURATION_MS } from './BottomDrawer' +import { BottomDrawerModalHost } from './bottom-drawer-modal-host' import { PickerListDrawer } from './PickerListDrawer' import { MobileAgentIcon } from './MobileAgentIcon' -import { MobileWorkspaceNameInput } from './MobileWorkspaceNameInput' import { getSuggestedCreatureName } from './worktree-name-suggestion' import { deriveWorkspaceSshGate, workspaceSshStatusLabel } from '../tasks/workspace-ssh-gate' -import { WORKTREE_CREATE_TIMEOUT_MS } from '../tasks/workspace-create-timeout' import { isSetupHookTrusted, normalizeSetupHookTrust, - trustedOrcaHooksWithSetupApproval, + persistSetupHookTrustApproval, wasSetupHookPreviouslyApproved, type SetupHookTrust } from '../tasks/setup-hook-trust' @@ -49,6 +48,24 @@ import { refreshMobileNewWorkspaceDialogSelectedRepo, resolveMobileNewWorkspaceDialogRepoId } from '../worktree/new-workspace-dialog-repo-selection' +import { createBlankWorkspace } from '../tasks/blank-workspace-create' +import { createWorkspaceFromComposerSource } from '../tasks/source-workspace-create' +import { MOBILE_TASKS_CAPABILITY } from '../tasks/mobile-tasks-capability' +import { normalizeWorkspaceAgent } from '../tasks/workspace-agent-selection' +import { + filterAvailableTaskProviders, + normalizeVisibleTaskProviders, + type TaskProvider +} from '../tasks/mobile-task-providers' +import { useMobileComposerSource } from '../tasks/use-mobile-composer-source' +import type { SmartModeAvailabilityInput } from '../tasks/mobile-smart-source-modes' +import { deriveRepoSlug, type PasteRepoCandidate } from '../tasks/smart-source-paste-intent' +import { shouldPreserveWorkspaceSourceOnRepoChange } from '../../../src/shared/new-workspace/workspace-source' +import { getComposerRepoWorktreeBranches } from '../../../src/shared/composer-branch-selection' +import { SmartWorkspaceSourceField } from './SmartWorkspaceSourceField' +import { SmartWorkspaceSourceDrawer } from './SmartWorkspaceSourceDrawer' +import { SmartWorkspaceAdvancedFields } from './SmartWorkspaceAdvancedFields' +import { SetupHookTrustDrawer, type SetupTrustPrompt } from './SetupHookTrustDrawer' type Repo = { id: string @@ -56,6 +73,9 @@ type Repo = { path: string badgeColor?: string connectionId?: string | null + kind?: 'git' | 'folder' + upstream?: { owner: string; repo: string } | null + gitRemoteIdentity?: { remoteUrl?: string; canonicalKey?: string } | null } type SetupDecision = 'inherit' | 'run' | 'skip' @@ -91,13 +111,11 @@ type CreateOptions = { approvedSetupContentHash?: string } -type SetupTrustPrompt = { - repoId: string - repoName: string - scriptContent: string - contentHash: string - previouslyApproved: boolean -} +type NewWorktreeDrawerView = 'form' | 'transition' | 'source' | 'repo' | 'agent' | 'trust' + +// Why: iOS cannot reliably present a second native modal until the first drawer's +// exit commits; one extra frame keeps transitions sequential on slower devices. +const NEW_WORKTREE_DRAWER_TRANSITION_MS = BOTTOM_DRAWER_HIDE_DURATION_MS + 16 function repoColor(name: string): string { const palette = ['#f97316', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', '#f59e0b', '#6366f1'] @@ -124,6 +142,7 @@ type Props = { // on the on-disk directory basename, so paths (not displayNames) are // what the suggestion logic must dedupe against. existingWorktreePaths?: readonly string[] + existingWorktrees?: readonly { repoId: string; branch: string }[] onCreated: (worktreeId: string, name: string) => void onClose: () => void } @@ -133,6 +152,7 @@ export function NewWorktreeModal({ client, hostId, existingWorktreePaths, + existingWorktrees, onCreated, onClose }: Props) { @@ -157,6 +177,7 @@ export function NewWorktreeModal({ client={client} hostId={hostId} existingWorktreePaths={existingWorktreePaths} + existingWorktrees={existingWorktrees} onCreated={onCreated} onClose={onClose} /> @@ -168,25 +189,28 @@ function NewWorktreeModalContent({ client, hostId, existingWorktreePaths, + existingWorktrees, onCreated, onClose }: Props) { const [initialRepos] = useState(() => (hostId ? (getCachedRepos(hostId) as Repo[] | null) : null)) const [repos, setRepos] = useState(initialRepos ?? []) const [selectedRepo, setSelectedRepo] = useState(null) - const [showRepoPicker, setShowRepoPicker] = useState(false) - const [nameAutoFocusEnabled, setNameAutoFocusEnabled] = useState(true) + const [drawerView, setDrawerView] = useState('form') + const drawerTransitionTimerRef = useRef | null>(null) + const createInFlightRef = useRef(false) + const setupTrustActionInFlightRef = useRef(false) const [selectedAgentState, setSelectedAgent] = useState(AGENT_OPTIONS[0]!) const [runtimeSettings, setRuntimeSettings] = useState(null) const [detectedAgentIdsState, setDetectedAgentIdsState] = useState( null ) const [agentOverriddenState, setAgentOverridden] = useState(false) - const [showAgentPicker, setShowAgentPicker] = useState(false) const [sshState, setSshState] = useState(null) const [sshConnectingTargetId, setSshConnectingTargetId] = useState(null) - const [name, setName] = useState('') const [note, setNote] = useState('') + const [availableProviders, setAvailableProviders] = useState([]) + const [tasksSupported, setTasksSupported] = useState(false) const [showAdvanced, setShowAdvanced] = useState(false) const [setupHookDetails, setSetupHookDetails] = useState(null) const [trustedOrcaHooks, setTrustedOrcaHooks] = useState({}) @@ -200,12 +224,40 @@ function NewWorktreeModalContent({ const [error, setError] = useState('') const [loading, setLoading] = useState(initialRepos == null) const lastVisitedRepo = useLastVisitedWorktreeRepoId(hostId, visible) + const selectedRepoWorktreeBranches = useMemo( + () => getComposerRepoWorktreeBranches(existingWorktrees ?? [], selectedRepo?.id ?? null), + [existingWorktrees, selectedRepo] + ) - // Why: matches the desktop UI — the input shows a generic "Workspace name" - // placeholder, not the suggested creature. The creature name is only used - // as a server-bound fallback when the user submits with a blank field, so - // it's recomputed lazily inside handleCreate() to stay fresh against - // existingWorktreePaths at submission time. + useEffect(() => { + return () => { + if (drawerTransitionTimerRef.current) { + clearTimeout(drawerTransitionTimerRef.current) + } + } + }, []) + + function transitionDrawer(nextView: Exclude): void { + if (drawerTransitionTimerRef.current) { + clearTimeout(drawerTransitionTimerRef.current) + } + setDrawerView('transition') + drawerTransitionTimerRef.current = setTimeout(() => { + drawerTransitionTimerRef.current = null + setDrawerView(nextView) + }, NEW_WORKTREE_DRAWER_TRANSITION_MS) + } + + // The Smart source picker owns the workspace name AND the linked-source + // selection: typing names the workspace and drives source search, and picking + // a source resolves the base/branch/push metadata (matching desktop). The + // creature-name fallback is only computed lazily at submit for a blank name. + const composer = useMobileComposerSource({ + client, + selectedRepoId: selectedRepo?.id ?? null, + worktreeBranches: selectedRepoWorktreeBranches, + onError: setError + }) const selectedRepoConnectionId = selectedRepo?.connectionId ?? null const sshGate = deriveWorkspaceSshGate({ @@ -242,6 +294,25 @@ function NewWorktreeModalContent({ } const selectedAgent = selectedAgentResolution.selectedAgent + const selectedRepoIsGit = selectedRepo ? selectedRepo.kind !== 'folder' : true + const sourceAvailability: SmartModeAvailabilityInput = { + textOnly: selectedRepo != null && !selectedRepoIsGit, + tasksSupported, + hasRepo: selectedRepo != null, + githubAvailable: availableProviders.includes('github'), + gitlabAvailable: availableProviders.includes('gitlab'), + linearAvailable: availableProviders.includes('linear') + } + const pasteRepos = useMemo( + () => + repos.map((repo) => ({ + id: repo.id, + displayName: repo.displayName, + slug: deriveRepoSlug(repo) + })), + [repos] + ) + useEffect(() => { if (!visible || !lastVisitedRepo.loaded || selectedRepo || repos.length === 0) { return @@ -298,27 +369,68 @@ function NewWorktreeModalContent({ }) void (async () => { - try { - const [settingsResponse, uiResponse] = await Promise.all([ - client.sendRequest('settings.get'), - client.sendRequest('ui.get') - ]) - if (stale) { - return - } - if (settingsResponse.ok) { - const result = (settingsResponse as RpcSuccess).result as { settings: RuntimeSettings } - setRuntimeSettings(result.settings) - } - if (uiResponse.ok) { - const result = (uiResponse as RpcSuccess).result as { - ui?: { trustedOrcaHooks?: PersistedTrustedOrcaHooks } - } - setTrustedOrcaHooks(result.ui?.trustedOrcaHooks ?? {}) - } - } catch { - // Non-critical; repo.list owns the visible loading state. + // Why: settle each RPC independently so a flaky availability probe (e.g. a + // linear.status timeout, which rejects rather than resolving {ok:false}) + // can't discard the already-resolved critical settings/ui results. + const probes = Promise.allSettled([ + client.sendRequest('status.get'), + client.sendRequest('preflight.check'), + client.sendRequest('linear.status') + ]) + const okResult = (entry: PromiseSettledResult): RpcSuccess | null => + entry.status === 'fulfilled' && entry.value.ok ? (entry.value as RpcSuccess) : null + // Why: hydrate settings/trust the moment their own RPCs settle — gating them + // on the probes (a first-open preflight.check can take seconds) widens the + // window where an already-trusted setup hook spuriously re-prompts on create. + const [settingsRes, uiRes] = await Promise.allSettled([ + client.sendRequest('settings.get'), + client.sendRequest('ui.get') + ]) + if (stale) { + return } + + const settingsResult = okResult(settingsRes) + const settingsValue = settingsResult + ? ( + settingsResult.result as { + settings: RuntimeSettings & { visibleTaskProviders?: unknown } + } + ).settings + : null + if (settingsValue) { + setRuntimeSettings(settingsValue) + } + const uiResult = okResult(uiRes) + if (uiResult) { + const ui = (uiResult.result as { ui?: { trustedOrcaHooks?: PersistedTrustedOrcaHooks } }).ui + setTrustedOrcaHooks(ui?.trustedOrcaHooks ?? {}) + } + + const [statusRes, preflightRes, linearRes] = await probes + if (stale) { + return + } + // Tasks is an additive RPC surface, so older paired desktops without the + // capability fall back to branch + blank sources only. + const statusResult = okResult(statusRes) + const capabilities = + (statusResult?.result as { capabilities?: string[] } | undefined)?.capabilities ?? [] + setTasksSupported(capabilities.includes(MOBILE_TASKS_CAPABILITY)) + const glabInstalled = + (okResult(preflightRes)?.result as { glab?: { installed?: boolean } } | undefined)?.glab + ?.installed === true + const linearConnected = + (okResult(linearRes)?.result as { connected?: boolean } | undefined)?.connected === true + const visibleProviders = normalizeVisibleTaskProviders(settingsValue?.visibleTaskProviders) + setAvailableProviders( + // Drop filterAvailableTaskProviders' forced 'github' fallback when the user + // hid GitHub; the Branch tab always guarantees at least one tab remains. + filterAvailableTaskProviders(visibleProviders, { + gitlabInstalled: glabInstalled, + linearConnected + }).filter((provider) => visibleProviders.includes(provider)) + ) })() return () => { stale = true @@ -486,31 +598,11 @@ function NewWorktreeModalContent({ } } - async function persistSetupHookTrust( - repoId: string, - contentHash: string, - alwaysTrust: boolean - ): Promise { - if (!client) { - return - } - const next = trustedOrcaHooksWithSetupApproval({ - trust: trustedOrcaHooks, - repoId, - contentHash, - alwaysTrust - }) - const response = await client.sendRequest('ui.set', { trustedOrcaHooks: next }) - if (!response.ok) { - throw new Error(response.error.message) - } - setTrustedOrcaHooks(next) - } - async function handleCreate(options: CreateOptions = {}) { - if (!client || !selectedRepo) { + if (!client || !selectedRepo || createInFlightRef.current) { return } + createInFlightRef.current = true setCreating(true) setError('') @@ -555,24 +647,9 @@ function NewWorktreeModalContent({ // server invent one. The pre-flight basename dedupe is only a hint; // the authoritative collision is checked server-side against git // branches/remotes/PRs, so we also retry-with-suffix on conflict. - const trimmedName = name.trim() + const trimmedName = composer.name.trim() const baseName = trimmedName || getSuggestedCreatureName(existingWorktreePaths ?? []) - // Why: mirrors src/renderer/src/store/slices/worktrees.ts - // (createWorktree retry loop). Server-side checks (Branch X already - // exists locally / on a remote / already has PR #N) can fire even - // after the pre-flight basename dedupe — branches outlive worktrees - // in git, and remote branches/PRs aren't visible from worktree.ps. - // Retry up to 25 times by appending -2, -3, ... before surfacing - // the error. The desktop applies this to user-typed names too, so - // mobile follows suit for parity. - const retryablePatterns = [ - /already exists locally/i, - /already exists on a remote/i, - /already has pr #\d+/i - ] - const candidateFor = (attempt: number): string => - attempt === 0 ? baseName : `${baseName}-${attempt + 1}` let setupDecision: SetupDecision = 'inherit' if (setupCommand) { if (options.setupOverride) { @@ -602,44 +679,46 @@ function NewWorktreeModalContent({ contentHash: setupTrust.contentHash, previouslyApproved: wasSetupHookPreviouslyApproved(trustedOrcaHooks, selectedRepo.id) }) + transitionDrawer('trust') return } - let lastError: string | null = null - for (let attempt = 0; attempt < 25; attempt += 1) { - const candidateName = candidateFor(attempt) - const params: Record = { - repo: `id:${selectedRepo.id}`, - startupCommand: command, - setupDecision, - name: candidateName - } - if (selectedAgent.id !== '__blank__') { - params.createdWithAgent = selectedAgent.id - } - if (note.trim()) { - params.comment = note.trim() - } - - const response = await client.sendRequest('worktree.create', params, { - timeoutMs: WORKTREE_CREATE_TIMEOUT_MS - }) - if (response.ok) { - const result = (response as RpcSuccess).result as { worktree: { id: string } } - onClose() - onCreated(result.worktree.id, candidateName) - return - } - - lastError = response.error.message - if (!retryablePatterns.some((p) => p.test(lastError ?? ''))) { - break - } + const createdWithAgentId = selectedAgent.id !== '__blank__' ? selectedAgent.id : undefined + const trimmedNote = note.trim() || undefined + const createSelection = composer.createSelection + const result = createSelection + ? await createWorkspaceFromComposerSource({ + client, + selection: createSelection, + targetRepoId: selectedRepo.id, + setupDecision, + agent: { + choice: normalizeWorkspaceAgent(selectedAgent.id) ?? 'blank', + startupCommand: command + }, + workspaceName: trimmedName || undefined, + note: trimmedNote, + nameIsAutoManaged: composer.isNameAutoManaged + }) + : await createBlankWorkspace({ + client, + repoId: selectedRepo.id, + baseName, + startupCommand: command, + createdWithAgentId, + comment: trimmedNote, + setupDecision + }) + if ('error' in result) { + setError(result.error) + return } - setError(lastError ?? 'Failed to create workspace') + onClose() + onCreated(result.worktreeId, result.name) } catch (e) { setError(e instanceof Error ? e.message : 'Failed to create workspace') } finally { + createInFlightRef.current = false setCreating(false) } } @@ -670,15 +749,90 @@ function NewWorktreeModalContent({ ) function prepareSelectionPickerOpen(): void { - // Why: picker taps can beat the delayed name-field focus; suppressing it - // prevents the keyboard from reopening under the picker drawer. - setNameAutoFocusEnabled(false) + // Why: picker taps can beat an open soft keyboard; dismissing it prevents the + // keyboard from reopening under the picker drawer. Keyboard.dismiss() } + function handleRepoSelected(repo: Repo): void { + const repoChanged = repo.id !== selectedRepo?.id + setSelectedRepo(repo) + // Branch and provider-backed sources are repo-scoped; Linear/Jira are global + // work context and survive choosing a different implementation repo. + if (repoChanged && !shouldPreserveWorkspaceSourceOnRepoChange(composer.linkedWorkItem)) { + composer.handleClearSmartNameSelection() + } + } + + async function approveSetupTrust(alwaysTrust: boolean): Promise { + if ( + !client || + !setupTrustPrompt || + setupTrustActionInFlightRef.current || + createInFlightRef.current + ) { + return + } + setupTrustActionInFlightRef.current = true + setCreating(true) + try { + const nextTrust = await persistSetupHookTrustApproval({ + client, + trust: trustedOrcaHooks, + repoId: setupTrustPrompt.repoId, + contentHash: setupTrustPrompt.contentHash, + alwaysTrust + }) + setTrustedOrcaHooks(nextTrust) + const approvedHash = setupTrustPrompt.contentHash + setSetupTrustPrompt(null) + transitionDrawer('form') + await handleCreate({ setupOverride: 'run', approvedSetupContentHash: approvedHash }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to trust setup script.') + } finally { + setupTrustActionInFlightRef.current = false + if (!createInFlightRef.current) { + setCreating(false) + } + } + } + + function closeSetupTrust(): void { + if (setupTrustActionInFlightRef.current || createInFlightRef.current) { + return + } + setSetupTrustPrompt(null) + transitionDrawer('form') + } + + function skipSetupTrust(): void { + if (setupTrustActionInFlightRef.current || createInFlightRef.current) { + return + } + closeSetupTrust() + void handleCreate({ setupOverride: 'skip' }) + } + return ( - <> - + // Why: hosting the form and every picker in one persistent native Modal makes + // form → repo/agent transitions in-window view swaps, avoiding the iOS + // dismiss-then-present race that left the dropdowns unresponsive. Native back + // closes the flow from the form, routes the trust prompt through its in-flight + // guard, and otherwise returns to the form from a picker. + { + if (drawerView === 'form') { + onClose() + } else if (drawerView === 'trust') { + closeSetupTrust() + } else { + transitionDrawer('form') + } + }} + > + Create Workspace @@ -702,7 +856,7 @@ function NewWorktreeModalContent({ style={styles.fieldButton} onPress={() => { prepareSelectionPickerOpen() - setShowRepoPicker(true) + transitionDrawer('repo') }} > {selectedRepo ? ( @@ -720,6 +874,18 @@ function NewWorktreeModalContent({ + setError('')} + onOpenDrawer={() => transitionDrawer('source')} + /> + + {composer.forkPushWarning ? ( + {composer.forkPushWarning} + ) : null} + {selectedRepoConnectionId ? ( SSH Connection @@ -763,28 +929,6 @@ function NewWorktreeModalContent({ ) : null} - - - Workspace Name [Optional] - - { - setName(t) - setError('') - }} - placeholderTextColor={colors.textMuted} - shouldAutoFocus={nameAutoFocusEnabled && visible && !loading && repos.length > 0} - returnKeyType="done" - onSubmitEditing={() => { - if (canCreate) { - void handleCreate() - } - }} - /> - - Agent { prepareSelectionPickerOpen() - setShowAgentPicker(true) + transitionDrawer('agent') }} > @@ -814,6 +958,11 @@ function NewWorktreeModalContent({ {showAdvanced && ( <> + + Note - {/* Sub-modals for pickers — rendered outside the main modal so they - layer on top and scroll without touch conflicts. */} + {/* Why: list drawers stay outside the form's ScrollView, and the transition + state lets each hosted overlay finish hiding before the next appears. */} + { + const nextRepo = repos.find((repo) => repo.id === repoId) + if (nextRepo) { + setSelectedRepo(nextRepo) + } + }} + onClose={() => transitionDrawer('form')} + /> + setSelectedRepo(item.repo)} - onClose={() => setShowRepoPicker(false)} + onSelect={(item) => handleRepoSelected(item.repo)} + onClose={() => transitionDrawer('form')} renderIcon={(item) => { return }} /> setShowAgentPicker(false)} + onClose={() => transitionDrawer('form')} renderIcon={(agent) => } /> - setSetupTrustPrompt(null)} - > - {setupTrustPrompt ? ( - - - - {setupTrustPrompt.previouslyApproved - ? `${setupTrustPrompt.repoName}'s setup script changed` - : `Run setup from ${setupTrustPrompt.repoName}?`} - - - This repository's orca.yaml runs before the workspace starts. Only run it if you - trust this repository. - - - - - - {setupTrustPrompt.previouslyApproved ? 'New setup script' : 'Setup script'} - - {setupTrustPrompt.scriptContent} - - - - - void (async () => { - try { - await persistSetupHookTrust( - setupTrustPrompt.repoId, - setupTrustPrompt.contentHash, - false - ) - const approvedHash = setupTrustPrompt.contentHash - setSetupTrustPrompt(null) - await handleCreate({ - setupOverride: 'run', - approvedSetupContentHash: approvedHash - }) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to trust setup script.') - } - })() - } - > - - Run hooks - - - - void (async () => { - try { - await persistSetupHookTrust( - setupTrustPrompt.repoId, - setupTrustPrompt.contentHash, - true - ) - const approvedHash = setupTrustPrompt.contentHash - setSetupTrustPrompt(null) - await handleCreate({ - setupOverride: 'run', - approvedSetupContentHash: approvedHash - }) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to trust setup script.') - } - })() - } - > - - Always trust and run - - - { - setSetupTrustPrompt(null) - void handleCreate({ setupOverride: 'skip' }) - }} - > - Don't run - - - - ) : null} - - + void approveSetupTrust(false)} + onAlwaysTrust={() => void approveSetupTrust(true)} + onDontRun={skipSetupTrust} + onClose={closeSetupTrust} + /> + ) } @@ -1164,6 +1245,12 @@ const styles = StyleSheet.create({ fontSize: 13, marginBottom: spacing.md }, + sourceWarning: { + marginTop: -spacing.sm, + marginBottom: spacing.md, + fontSize: 12, + color: colors.statusAmber + }, advancedToggle: { flexDirection: 'row', alignItems: 'center', @@ -1247,52 +1334,6 @@ const styles = StyleSheet.create({ fontFamily: typography.monoFamily, color: colors.textPrimary }, - trustHeader: { - paddingHorizontal: spacing.xs, - marginBottom: spacing.md - }, - trustScriptBox: { - backgroundColor: colors.bgRaised, - borderRadius: radii.input, - borderWidth: 1, - borderColor: colors.borderSubtle, - padding: spacing.md, - marginBottom: spacing.md - }, - trustScriptLabel: { - fontSize: 12, - fontWeight: '600', - color: colors.textSecondary, - marginBottom: spacing.sm - }, - trustScriptText: { - fontSize: 13, - fontFamily: typography.monoFamily, - color: colors.textPrimary - }, - trustActionGroup: { - backgroundColor: colors.bgPanel, - borderRadius: radii.input, - overflow: 'hidden' - }, - trustActionRow: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm, - paddingVertical: spacing.md, - paddingHorizontal: spacing.md - }, - trustActionText: { - flex: 1, - fontSize: typography.bodySize, - color: colors.textPrimary, - fontWeight: '500' - }, - trustActionSeparator: { - height: StyleSheet.hairlineWidth, - backgroundColor: colors.borderSubtle, - marginHorizontal: spacing.md - }, actions: { flexDirection: 'row', justifyContent: 'flex-end', diff --git a/mobile/src/components/NewWorktreeModalController.tsx b/mobile/src/components/NewWorktreeModalController.tsx index df422642951..9a062692d17 100644 --- a/mobile/src/components/NewWorktreeModalController.tsx +++ b/mobile/src/components/NewWorktreeModalController.tsx @@ -12,6 +12,7 @@ type Props = { client: RpcClient | null hostId?: string existingWorktreePaths?: readonly string[] + existingWorktrees?: readonly { repoId: string; branch: string }[] onVisibleChange?: (visible: boolean) => void onRouteVisibleChange: (visible: boolean) => void onCreated: (worktreeId: string, name: string) => void @@ -24,6 +25,7 @@ export const NewWorktreeModalController = forwardRef diff --git a/mobile/src/components/SetupHookTrustDrawer.tsx b/mobile/src/components/SetupHookTrustDrawer.tsx new file mode 100644 index 00000000000..6095aaa174f --- /dev/null +++ b/mobile/src/components/SetupHookTrustDrawer.tsx @@ -0,0 +1,137 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { Check } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { BottomDrawer } from './BottomDrawer' + +export type SetupTrustPrompt = { + repoId: string + repoName: string + scriptContent: string + contentHash: string + previouslyApproved: boolean +} + +type Props = { + visible: boolean + prompt: SetupTrustPrompt | null + busy: boolean + onRunOnce: () => void + onAlwaysTrust: () => void + onDontRun: () => void + onClose: () => void +} + +// The repo-owned orca.yaml setup-hook trust prompt, shown before a workspace +// create that would run an untrusted setup script. Extracted from NewWorktreeModal +// to keep that file focused; the async persist/create logic stays with the caller. +export function SetupHookTrustDrawer({ + visible, + prompt, + busy, + onRunOnce, + onAlwaysTrust, + onDontRun, + onClose +}: Props) { + return ( + + {prompt ? ( + + + + {prompt.previouslyApproved + ? `${prompt.repoName}'s setup script changed` + : `Run setup from ${prompt.repoName}?`} + + + This repository's orca.yaml runs before the workspace starts. Only run it if you trust + this repository. + + + + + + {prompt.previouslyApproved ? 'New setup script' : 'Setup script'} + + {prompt.scriptContent} + + + + + + Run hooks + + + + + Always trust and run + + + + Don't run + + + + ) : null} + + ) +} + +const styles = StyleSheet.create({ + title: { + fontSize: 15, + fontWeight: '600', + color: colors.textPrimary + }, + subtitle: { + fontSize: 13, + color: colors.textMuted, + marginTop: 2 + }, + trustHeader: { + paddingHorizontal: spacing.xs, + marginBottom: spacing.md + }, + trustScriptBox: { + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + borderWidth: 1, + borderColor: colors.borderSubtle, + padding: spacing.md, + marginBottom: spacing.md + }, + trustScriptLabel: { + fontSize: 12, + fontWeight: '600', + color: colors.textSecondary, + marginBottom: spacing.sm + }, + trustScriptText: { + fontSize: 13, + fontFamily: typography.monoFamily, + color: colors.textPrimary + }, + trustActionGroup: { + backgroundColor: colors.bgPanel, + borderRadius: radii.input, + overflow: 'hidden' + }, + trustActionRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + }, + trustActionText: { + flex: 1, + fontSize: typography.bodySize, + color: colors.textPrimary, + fontWeight: '500' + }, + trustActionSeparator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + } +}) diff --git a/mobile/src/components/SmartSourceModeIcon.tsx b/mobile/src/components/SmartSourceModeIcon.tsx new file mode 100644 index 00000000000..3d066369ed6 --- /dev/null +++ b/mobile/src/components/SmartSourceModeIcon.tsx @@ -0,0 +1,18 @@ +import { CaseSensitive, GitBranch, Sparkles } from 'lucide-react-native' +import type { SmartModeIcon } from '../tasks/mobile-smart-source-modes' +import { TaskProviderLogo } from './TaskProviderLogo' + +// Renders a Smart-mode tab icon: the inline brand SVGs for provider modes, +// lucide glyphs for the neutral modes. +export function SmartSourceModeIcon({ icon, color }: { icon: SmartModeIcon; color: string }) { + if (icon.type === 'provider') { + return + } + if (icon.name === 'sparkles') { + return + } + if (icon.name === 'git-branch') { + return + } + return +} diff --git a/mobile/src/components/SmartWorkspaceAdvancedFields.tsx b/mobile/src/components/SmartWorkspaceAdvancedFields.tsx new file mode 100644 index 00000000000..4af0ad682b5 --- /dev/null +++ b/mobile/src/components/SmartWorkspaceAdvancedFields.tsx @@ -0,0 +1,102 @@ +import { Platform, StyleSheet, Switch, Text, TextInput, View } from 'react-native' +import type { MobileComposerSource } from '../tasks/use-mobile-composer-source' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +type Props = { + composer: MobileComposerSource + selectedRepoIsGit: boolean +} + +// The Advanced-section source controls: the editable Name appears once a source +// pill is shown (the field itself is no longer the name input); the branch-name +// override and reuse toggle mirror the desktop composer's advanced branch fields. +export function SmartWorkspaceAdvancedFields({ composer, selectedRepoIsGit }: Props) { + const selection = composer.smartNameSelection + const showBranchOverride = selectedRepoIsGit && (!selection || selection.kind === 'branch') + return ( + <> + {selection ? ( + + Name + + + ) : null} + + {showBranchOverride ? ( + + Branch name + + + ) : null} + + {composer.reuseEligibleBranch ? ( + + + + Reuse branch “{composer.reuseEligibleBranch}” + + + + + ) : null} + + ) +} + +const styles = StyleSheet.create({ + field: { + marginBottom: spacing.md + }, + label: { + fontSize: 13, + fontWeight: '500', + color: colors.textSecondary, + marginBottom: spacing.xs + }, + input: { + backgroundColor: colors.bgRaised, + color: colors.textPrimary, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm, + fontSize: typography.bodySize, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + reuseRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: spacing.sm + }, + reuseLabel: { + flex: 1, + fontSize: 13, + color: colors.textSecondary + }, + reuseSwitch: { + transform: [{ scaleX: 0.7 }, { scaleY: 0.7 }] + } +}) diff --git a/mobile/src/components/SmartWorkspaceSourceDrawer.tsx b/mobile/src/components/SmartWorkspaceSourceDrawer.tsx new file mode 100644 index 00000000000..c5f1a6c47db --- /dev/null +++ b/mobile/src/components/SmartWorkspaceSourceDrawer.tsx @@ -0,0 +1,425 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { + ActivityIndicator, + FlatList, + Pressable, + StyleSheet, + Text, + TextInput, + View +} from 'react-native' +import type { RpcClient } from '../transport/rpc-client' +import type { SmartWorkspaceSourceRow as SourceRow } from '../../../src/shared/new-workspace/smart-workspace-source-results' +import { + MR_STATE_FILTER_OPTIONS, + resolveAvailableSmartModes, + resolveDefaultSmartMode, + SMART_MODE_OPTIONS, + type SmartModeAvailabilityInput, + type SmartModeOption +} from '../tasks/mobile-smart-source-modes' +import type { MrStateFilter, SmartNameMode } from '../tasks/mobile-composer-source-types' +import { + lookupGitHubItemByOwnerRepo, + type PasteRepoCandidate +} from '../tasks/smart-source-paste-intent' +import { useSmartWorkspaceSource } from '../tasks/use-smart-workspace-source' +import type { MobileComposerSource } from '../tasks/use-mobile-composer-source' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { BottomDrawer, BOTTOM_DRAWER_HIDE_DURATION_MS } from './BottomDrawer' +import { SmartSourceModeIcon } from './SmartSourceModeIcon' +import { SmartWorkspaceSourceRow } from './SmartWorkspaceSourceRow' + +type Props = { + visible: boolean + client: RpcClient | null + composer: MobileComposerSource + availability: SmartModeAvailabilityInput + repoId: string | null + repos: readonly PasteRepoCandidate[] + linearWorkspaceId?: string | null + sshReady: boolean + onRepoChange: (repoId: string) => void + onClose: () => void +} + +export function SmartWorkspaceSourceDrawer({ + visible, + client, + composer, + availability, + repoId, + repos, + linearWorkspaceId, + sshReady, + onRepoChange, + onClose +}: Props) { + const availableModes = useMemo(() => resolveAvailableSmartModes(availability), [availability]) + const [mode, setMode] = useState(() => resolveDefaultSmartMode(availability)) + const [mrStateFilter, setMrStateFilter] = useState('opened') + // Why: read latest availability inside the open effect without making it a + // reactive dep (the object is recreated each render), so re-seeding happens + // only on open, not on every availability recompute. + const availabilityRef = useRef(availability) + availabilityRef.current = availability + + // Reset to the default mode each time the drawer opens. + useEffect(() => { + if (visible) { + setMode(resolveDefaultSmartMode(availabilityRef.current)) + } + }, [visible]) + + // Snap the chosen mode back into the available set if availability changes. + const effectiveMode = availableModes.includes(mode) ? mode : (availableModes[0] ?? 'text') + + // Linear searches without a repo; every other provider/branch search needs a + // connected repo-backed target. + const searchEnabled = visible && (effectiveMode === 'linear' || sshReady) + + const { + rows, + loading, + error, + needsGitHubRemote, + emptyHint, + crossRepoPrompt, + dismissCrossRepoPrompt + } = useSmartWorkspaceSource({ + client, + enabled: searchEnabled, + mode: effectiveMode, + query: composer.name, + repoId, + githubAvailable: availability.githubAvailable, + gitlabAvailable: availability.gitlabAvailable, + linearAvailable: availability.linearAvailable, + mrStateFilter, + linearWorkspaceId, + repos + }) + + function closeSoon(): void { + setTimeout(onClose, BOTTOM_DRAWER_HIDE_DURATION_MS) + } + + function handleSelectRow(row: SourceRow): void { + switch (row.kind) { + case 'use-name': + composer.setName(row.name) + break + case 'create-branch': + composer.handleSmartCreateBranch(row.name) + break + case 'github': + composer.handleSmartGitHubItemSelect(row.item) + break + case 'gitlab': + composer.handleSmartGitLabItemSelect(row.item) + break + case 'branch': + composer.handleSmartBranchSelect(row.refName, row.localBranchName) + break + case 'linear': + composer.handleSmartLinearIssueSelect(row.issue) + break + } + onClose() + } + + async function handleAcceptCrossRepo(): Promise { + if (!client || !crossRepoPrompt) { + return + } + const { link, matchingRepo } = crossRepoPrompt + try { + const item = await lookupGitHubItemByOwnerRepo( + client, + matchingRepo.id, + link.slug, + link.number, + link.type + ) + if (item) { + onRepoChange(matchingRepo.id) + composer.handleSmartGitHubItemSelect(item) + onClose() + } + } catch { + dismissCrossRepoPrompt() + } + } + + const showEmpty = + !loading && !error && !needsGitHubRemote && effectiveMode !== 'text' && rows.length === 0 + + return ( + + + Name or 'Create From' + + Done + + + + + + + {SMART_MODE_OPTIONS.filter((option: SmartModeOption) => + availableModes.includes(option.id) + ).map((option) => { + const selected = option.id === effectiveMode + const tint = selected ? colors.textPrimary : colors.textSecondary + return ( + setMode(option.id)} + > + + + {option.label} + + + ) + })} + + + {effectiveMode === 'gitlab' ? ( + + {MR_STATE_FILTER_OPTIONS.map((option) => { + const selected = option.id === mrStateFilter + return ( + setMrStateFilter(option.id)} + > + + {option.label} + + + ) + })} + + ) : null} + + {crossRepoPrompt ? ( + + + This item lives in {crossRepoPrompt.link.slug.owner}/{crossRepoPrompt.link.slug.repo}. + + + + Cancel + + void handleAcceptCrossRepo()}> + + Switch to {crossRepoPrompt.matchingRepo.displayName} + + + + + ) : null} + + {!sshReady && effectiveMode !== 'text' && effectiveMode !== 'linear' ? ( + Connect the repository to search sources. + ) : needsGitHubRemote ? ( + + This SSH repo needs a GitHub remote to list issues and PRs. + + ) : error ? ( + {error} + ) : null} + + row.value} + style={styles.list} + keyboardShouldPersistTaps="handled" + nestedScrollEnabled + ListFooterComponent={ + loading ? ( + + + + ) : showEmpty ? ( + {emptyHint || 'No results found.'} + ) : null + } + renderItem={({ item }) => ( + handleSelectRow(item)} /> + )} + /> + + ) +} + +const styles = StyleSheet.create({ + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.xs, + paddingBottom: spacing.sm + }, + title: { + fontSize: 15, + fontWeight: '600', + color: colors.textPrimary + }, + done: { + fontSize: typography.bodySize, + fontWeight: '600', + color: colors.accentBlue + }, + search: { + backgroundColor: colors.bgRaised, + color: colors.textPrimary, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + fontSize: typography.bodySize, + borderWidth: 1, + borderColor: colors.borderSubtle, + marginBottom: spacing.sm + }, + tabRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.xs, + marginBottom: spacing.sm + }, + tab: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + paddingHorizontal: spacing.sm + 2, + paddingVertical: spacing.xs + 2, + borderRadius: radii.button, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + tabSelected: { + backgroundColor: colors.bgPanel, + borderColor: colors.textSecondary + }, + tabText: { + fontSize: 13, + color: colors.textSecondary + }, + tabTextSelected: { + color: colors.textPrimary, + fontWeight: '600' + }, + chipRow: { + flexDirection: 'row', + gap: spacing.xs, + marginBottom: spacing.sm + }, + chip: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs, + borderRadius: radii.button, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + chipSelected: { + backgroundColor: colors.bgPanel, + borderColor: colors.textSecondary + }, + chipText: { + fontSize: 12, + color: colors.textSecondary + }, + chipTextSelected: { + color: colors.textPrimary, + fontWeight: '600' + }, + crossRepo: { + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + borderWidth: 1, + borderColor: colors.borderSubtle, + padding: spacing.md, + marginBottom: spacing.sm, + gap: spacing.sm + }, + crossRepoText: { + fontSize: 13, + color: colors.textSecondary + }, + crossRepoActions: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: spacing.sm + }, + crossRepoDismiss: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs + 2, + borderRadius: radii.button, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + crossRepoDismissText: { + fontSize: 13, + color: colors.textSecondary + }, + crossRepoSwitch: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs + 2, + borderRadius: radii.button, + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.textSecondary + }, + crossRepoSwitchText: { + fontSize: 13, + fontWeight: '600', + color: colors.textPrimary + }, + notice: { + fontSize: 12, + color: colors.textMuted, + paddingHorizontal: spacing.xs, + paddingBottom: spacing.sm + }, + errorNotice: { + fontSize: 12, + color: colors.statusRed, + paddingHorizontal: spacing.xs, + paddingBottom: spacing.sm + }, + list: { + backgroundColor: colors.bgPanel, + borderRadius: radii.card, + overflow: 'hidden', + maxHeight: 420, + flexGrow: 0 + }, + loading: { + paddingVertical: spacing.lg, + alignItems: 'center' + }, + empty: { + paddingVertical: spacing.lg, + textAlign: 'center', + color: colors.textMuted, + fontSize: 13 + } +}) diff --git a/mobile/src/components/SmartWorkspaceSourceField.tsx b/mobile/src/components/SmartWorkspaceSourceField.tsx new file mode 100644 index 00000000000..eb08ddca66a --- /dev/null +++ b/mobile/src/components/SmartWorkspaceSourceField.tsx @@ -0,0 +1,145 @@ +import { Linking, Pressable, StyleSheet, Text, View } from 'react-native' +import { + CircleDot, + ExternalLink, + GitBranch, + GitMerge, + GitPullRequest, + X +} from 'lucide-react-native' +import type { SmartNameSelection } from '../tasks/mobile-composer-source-types' +import type { MobileComposerSource } from '../tasks/use-mobile-composer-source' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { TaskProviderLogo } from './TaskProviderLogo' + +type Props = { + composer: MobileComposerSource + label: string + disabled?: boolean + onBeforeOpen?: () => void + onOpenDrawer: () => void +} + +function SelectionIcon({ kind }: { kind: SmartNameSelection['kind'] }) { + if (kind === 'github-pr') { + return + } + if (kind === 'gitlab-mr') { + return + } + if (kind === 'github-issue' || kind === 'gitlab-issue') { + return + } + if (kind === 'branch') { + return + } + return +} + +export function SmartWorkspaceSourceField({ + composer, + label, + disabled, + onBeforeOpen, + onOpenDrawer +}: Props) { + const selection = composer.smartNameSelection + + function openDrawer(): void { + if (disabled) { + return + } + onBeforeOpen?.() + onOpenDrawer() + } + + return ( + + + {label} [Optional] + + {selection ? ( + + + + {selection.label} + + {selection.url ? ( + selection.url && void Linking.openURL(selection.url).catch(() => {})} + > + + + ) : null} + + + + + ) : ( + + + {composer.name || 'Type a name or search a source'} + + + )} + + ) +} + +const styles = StyleSheet.create({ + field: { + marginBottom: spacing.md + }, + label: { + fontSize: 13, + fontWeight: '500', + color: colors.textSecondary, + marginBottom: spacing.xs + }, + labelHint: { + fontWeight: '400', + color: colors.textMuted + }, + input: { + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + 2, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + disabled: { + opacity: 0.55 + }, + inputText: { + fontSize: typography.bodySize, + color: colors.textPrimary + }, + inputPlaceholder: { + color: colors.textMuted + }, + pill: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + pillLabel: { + flex: 1, + fontSize: typography.bodySize, + color: colors.textPrimary + } +}) diff --git a/mobile/src/components/SmartWorkspaceSourceRow.tsx b/mobile/src/components/SmartWorkspaceSourceRow.tsx new file mode 100644 index 00000000000..ee601c2ebde --- /dev/null +++ b/mobile/src/components/SmartWorkspaceSourceRow.tsx @@ -0,0 +1,134 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { CaseSensitive, GitBranch, Sparkles } from 'lucide-react-native' +import type { SmartWorkspaceSourceRow as SourceRow } from '../../../src/shared/new-workspace/smart-workspace-source-results' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { TaskProviderLogo } from './TaskProviderLogo' + +type Props = { + row: SourceRow + onPress: () => void +} + +type RowContent = { + icon: React.ReactNode + title: string + subtitle?: string + status?: string +} + +function resolveRowContent(row: SourceRow): RowContent { + switch (row.kind) { + case 'use-name': + return { + icon: , + title: `Use "${row.name}"`, + subtitle: 'Name this workspace' + } + case 'create-branch': + return { + icon: , + title: `Create branch "${row.name}"`, + subtitle: 'New branch' + } + case 'github': + return { + icon: , + title: row.item.title, + subtitle: `${row.item.type === 'pr' ? 'PR #' : 'Issue #'}${row.item.number}`, + status: row.item.state + } + case 'gitlab': + return { + icon: , + title: row.item.title, + subtitle: `${row.item.type === 'mr' ? 'MR !' : 'Issue #'}${row.item.number}`, + status: row.item.state + } + case 'branch': + return { + icon: , + title: row.localBranchName || row.refName, + subtitle: row.refName + } + case 'linear': + return { + icon: , + title: row.issue.title, + subtitle: `${row.issue.identifier} · ${row.issue.team?.key ?? 'Linear'}`, + status: row.issue.state?.name + } + default: + return { icon: , title: '' } + } +} + +export function SmartWorkspaceSourceRow({ row, onPress }: Props) { + const content = resolveRowContent(row) + return ( + [styles.row, pressed && styles.rowPressed]} + onPress={onPress} + > + {content.icon} + + + {content.title} + + {content.subtitle ? ( + + {content.subtitle} + + ) : null} + + {content.status ? ( + + + {content.status} + + + ) : null} + + ) +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + rowPressed: { + backgroundColor: colors.bgRaised + }, + icon: { + width: 18, + alignItems: 'center' + }, + copy: { + flex: 1, + minWidth: 0 + }, + title: { + fontSize: typography.bodySize, + color: colors.textPrimary + }, + subtitle: { + fontSize: 12, + color: colors.textMuted, + marginTop: 1 + }, + pill: { + backgroundColor: colors.bgRaised, + borderRadius: radii.button, + paddingHorizontal: spacing.sm, + paddingVertical: 2 + }, + pillText: { + fontSize: 11, + fontWeight: '600', + color: colors.textSecondary, + textTransform: 'capitalize' + } +}) diff --git a/mobile/src/components/account-usage-state.test.ts b/mobile/src/components/account-usage-state.test.ts index f567143584e..3e128b7b099 100644 --- a/mobile/src/components/account-usage-state.test.ts +++ b/mobile/src/components/account-usage-state.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' import { getInactiveProviderUsage, getUsageBarState, + getWindowResetLabel, hasActiveProviderUsage, hasRenderableUsage, type AccountsSnapshot, @@ -117,6 +118,65 @@ describe('getInactiveProviderUsage', () => { }) }) +describe('getWindowResetLabel', () => { + const now = 1_700_000_000_000 + const min = 60_000 + const hour = 60 * min + const day = 24 * hour + + function makeWindow(resetsAt: number | null): ProviderRateLimits['session'] { + return { usedPercent: 13, windowMinutes: 300, resetsAt, resetDescription: null } + } + + it('is null when there are no limits or the window has no reset timestamp', () => { + expect(getWindowResetLabel(null, 'session', now)).toBe(null) + expect(getWindowResetLabel(makeLimits({ status: 'ok' }), 'session', now)).toBe(null) + expect( + getWindowResetLabel(makeLimits({ status: 'ok', session: makeWindow(null) }), 'session', now) + ).toBe(null) + }) + + it('formats minutes, hours+minutes, and days+hours like the desktop tooltip', () => { + expect( + getWindowResetLabel(makeLimits({ session: makeWindow(now + 47 * min) }), 'session', now) + ).toBe('Resets in 47m') + expect( + getWindowResetLabel( + makeLimits({ session: makeWindow(now + 3 * hour + 54 * min) }), + 'session', + now + ) + ).toBe('Resets in 3h 54m') + expect( + getWindowResetLabel( + makeLimits({ weekly: makeWindow(now + 6 * day + 7 * hour) }), + 'weekly', + now + ) + ).toBe('Resets in 6d 7h') + }) + + it('formats exact hours and exact days without a zero remainder', () => { + expect( + getWindowResetLabel(makeLimits({ session: makeWindow(now + 2 * hour) }), 'session', now) + ).toBe('Resets in 2h') + expect( + getWindowResetLabel(makeLimits({ weekly: makeWindow(now + 7 * day) }), 'weekly', now) + ).toBe('Resets in 7d') + }) + + it('reports "Resets now" for a reset timestamp in the past', () => { + expect( + getWindowResetLabel(makeLimits({ session: makeWindow(now - min) }), 'session', now) + ).toBe('Resets now') + }) + + it('reads the requested window only', () => { + const limits = makeLimits({ session: makeWindow(now + hour) }) + expect(getWindowResetLabel(limits, 'weekly', now)).toBe(null) + }) +}) + describe('getUsageBarState', () => { it('keeps stale window data visible during a transient error', () => { const bar = getUsageBarState( diff --git a/mobile/src/components/account-usage-state.ts b/mobile/src/components/account-usage-state.ts index c2bee731cd0..fa5b4a516b3 100644 --- a/mobile/src/components/account-usage-state.ts +++ b/mobile/src/components/account-usage-state.ts @@ -5,6 +5,8 @@ // Pure state/selectors live here (no React Native imports) so they can be // unit-tested directly; AccountUsage.tsx re-exports them alongside the // UsageBar component. +import { formatResetCountdown } from '../../../src/shared/rate-limit-reset-format' + export type RateLimitWindow = { usedPercent: number windowMinutes: number @@ -117,6 +119,27 @@ export function getUsageBarState( } } +/** + * Reset countdown for one window, e.g. "Resets in 3h 54m" / "Resets now", + * or null when the window has no reset timestamp (so the UI degrades to + * today's bars-only layout). + * + * Why: shares formatResetCountdown with the desktop status-bar tooltip so the + * copy stays identical across surfaces. `now` is a parameter so the function + * stays pure and unit-testable. + */ +export function getWindowResetLabel( + limits: ProviderRateLimits | null, + windowKey: 'session' | 'weekly', + now: number +): string | null { + const resetsAt = limits?.[windowKey]?.resetsAt + if (resetsAt == null) { + return null + } + return formatResetCountdown(resetsAt - now) +} + // Why: the usage UI must render for the system-default login, not only for // Orca-managed accounts. Show a provider when it has at least one managed // account OR active rate-limit data for the system-default target. diff --git a/mobile/src/components/bottom-drawer-modal-host.tsx b/mobile/src/components/bottom-drawer-modal-host.tsx new file mode 100644 index 00000000000..b31a31136e0 --- /dev/null +++ b/mobile/src/components/bottom-drawer-modal-host.tsx @@ -0,0 +1,40 @@ +import { createContext, useContext, type ReactNode } from 'react' +import { Modal } from 'react-native' + +const BottomDrawerModalHostContext = createContext(false) + +/** True when a BottomDrawer is rendered inside a shared BottomDrawerModalHost and + * must therefore skip its own native Modal (the host owns the single Modal). */ +export function useInsideBottomDrawerModalHost(): boolean { + return useContext(BottomDrawerModalHostContext) +} + +type Props = { + visible: boolean + onRequestClose: () => void + children: ReactNode +} + +// Why: iOS cannot reliably dismiss one native modal and present another in the same +// beat. Flows that swap between sibling drawer modals (e.g. the Create Workspace form +// → its repository/agent pickers) dropped the incoming modal, leaving the sheet dead +// to taps. Hosting every drawer in ONE persistent native Modal makes those swaps +// in-window view changes instead, so no present/dismiss race can eat the transition. +export function BottomDrawerModalHost({ visible, onRequestClose, children }: Props) { + if (!visible) { + return null + } + return ( + + + {children} + + + ) +} diff --git a/mobile/src/components/mobile-agent-icon-assets.test.ts b/mobile/src/components/mobile-agent-icon-assets.test.ts new file mode 100644 index 00000000000..edbe71760c7 --- /dev/null +++ b/mobile/src/components/mobile-agent-icon-assets.test.ts @@ -0,0 +1,45 @@ +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import type { TuiAgent } from '../../../src/shared/types' +import { MOBILE_TUI_AGENT_FAVICON_DOMAINS } from '../tasks/mobile-tui-agents' + +// Agents that render a hand-authored glyph in MobileAgentIcon and therefore +// never reach the favicon/bundled-asset path. +const GLYPH_AGENT_IDS = new Set([ + 'claude', + 'claude-agent-teams', + 'codex', + 'pi', + 'omp', + 'aider' +]) + +const dirname = import.meta.dirname +const assetsDir = path.resolve(dirname, '../../../src/shared/agent-icons') +const assetsModuleSource = readFileSync(path.join(dirname, 'mobile-agent-icon-assets.ts'), 'utf8') + +// Why: these agents previously loaded from Google's favicon service, which is +// unreachable in some regions/offline (#8451). Every one that lacks a glyph +// must ship a bundled PNG so the icon renders without a network request. +const agentsNeedingBundledIcon = Object.keys(MOBILE_TUI_AGENT_FAVICON_DOMAINS).filter( + (id): id is TuiAgent => !GLYPH_AGENT_IDS.has(id as TuiAgent) +) + +describe('mobile bundled agent icons', () => { + it('ships a bundled PNG on disk for every favicon-path agent', () => { + for (const id of agentsNeedingBundledIcon) { + expect(existsSync(path.join(assetsDir, `${id}.png`)), `missing bundled icon for ${id}`).toBe( + true + ) + } + }) + + it('wires every favicon-path agent into the bundled asset map', () => { + for (const id of agentsNeedingBundledIcon) { + expect(assetsModuleSource, `${id} not mapped in mobile-agent-icon-assets.ts`).toContain( + `agent-icons/${id}.png` + ) + } + }) +}) diff --git a/mobile/src/components/mobile-agent-icon-assets.ts b/mobile/src/components/mobile-agent-icon-assets.ts new file mode 100644 index 00000000000..52615e89df8 --- /dev/null +++ b/mobile/src/components/mobile-agent-icon-assets.ts @@ -0,0 +1,41 @@ +import type { ImageSourcePropType } from 'react-native' +import type { TuiAgent } from '../../../src/shared/types' + +// Why: mobile previously rendered these agent icons from Google's favicon +// service, which is unreachable in some regions (e.g. mainland China) and +// offline, leaving broken images in the agent picker (#8451). Bundle each +// favicon so Metro packages it into the app and the icon renders without any +// network request. The PNGs are shared with desktop in src/shared/agent-icons +// (Metro watches src/shared via metro.config.js sharedRoot). Metro requires +// static literal require() paths, so keep this as an explicit map rather than +// building the path from the agent id. +export const MOBILE_AGENT_ICON_ASSETS: Partial> = { + openclaude: require('../../../src/shared/agent-icons/openclaude.png'), + grok: require('../../../src/shared/agent-icons/grok.png'), + copilot: require('../../../src/shared/agent-icons/copilot.png'), + opencode: require('../../../src/shared/agent-icons/opencode.png'), + 'mimo-code': require('../../../src/shared/agent-icons/mimo-code.png'), + ante: require('../../../src/shared/agent-icons/ante.png'), + gemini: require('../../../src/shared/agent-icons/gemini.png'), + antigravity: require('../../../src/shared/agent-icons/antigravity.png'), + goose: require('../../../src/shared/agent-icons/goose.png'), + amp: require('../../../src/shared/agent-icons/amp.png'), + kilo: require('../../../src/shared/agent-icons/kilo.png'), + kiro: require('../../../src/shared/agent-icons/kiro.png'), + crush: require('../../../src/shared/agent-icons/crush.png'), + aug: require('../../../src/shared/agent-icons/aug.png'), + autohand: require('../../../src/shared/agent-icons/autohand.png'), + cline: require('../../../src/shared/agent-icons/cline.png'), + codebuff: require('../../../src/shared/agent-icons/codebuff.png'), + 'command-code': require('../../../src/shared/agent-icons/command-code.png'), + continue: require('../../../src/shared/agent-icons/continue.png'), + cursor: require('../../../src/shared/agent-icons/cursor.png'), + droid: require('../../../src/shared/agent-icons/droid.png'), + kimi: require('../../../src/shared/agent-icons/kimi.png'), + 'mistral-vibe': require('../../../src/shared/agent-icons/mistral-vibe.png'), + 'qwen-code': require('../../../src/shared/agent-icons/qwen-code.png'), + rovo: require('../../../src/shared/agent-icons/rovo.png'), + hermes: require('../../../src/shared/agent-icons/hermes.png'), + devin: require('../../../src/shared/agent-icons/devin.png'), + openclaw: require('../../../src/shared/agent-icons/openclaw.png') +} diff --git a/mobile/src/components/pr-sidebar/PRCommentsSection.test.ts b/mobile/src/components/pr-sidebar/PRCommentsSection.test.ts new file mode 100644 index 00000000000..d174f455c67 --- /dev/null +++ b/mobile/src/components/pr-sidebar/PRCommentsSection.test.ts @@ -0,0 +1,131 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { GitHubWorkItemDetails, PRComment } from '../../../../src/shared/types' +import { PRCommentsSection } from './PRCommentsSection' + +vi.mock('react-native', () => ({ + ActivityIndicator: 'ActivityIndicator', + Pressable: 'Pressable', + Text: 'Text', + View: 'View' +})) + +vi.mock('lucide-react-native', () => ({ + ChevronDown: 'ChevronDown', + ChevronRight: 'ChevronRight' +})) + +vi.mock('../../session/pr-comment-actions', () => ({ + canAddRootComment: () => false +})) + +vi.mock('../../session/mobile-pr-sidebar-state', () => ({ + isPrSidebarDetailsPlaceholder: () => false +})) + +vi.mock('./PRSection', () => ({ PRSection: 'PRSection' })) +vi.mock('./CommentMarkdown', () => ({ CommentMarkdown: 'CommentMarkdown' })) +vi.mock('./PRCommentCard', () => ({ PRCommentCard: 'PRCommentCard' })) +vi.mock('./PRCommentComposer', () => ({ PRCommentComposer: 'PRCommentComposer' })) +vi.mock('./pr-comments-styles', () => ({ prCommentsStyles: {} })) +vi.mock('./mobile-pr-sidebar-styles', () => ({ mobilePrSidebarStyles: {} })) +vi.mock('../../theme/mobile-theme', () => ({ colors: { textSecondary: '#999' } })) + +function suppressReactTestRendererDeprecationWarning(): () => void { + const originalConsoleError = console.error + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + const firstArg = args[0] + if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { + return + } + originalConsoleError(...args) + }) + return () => consoleErrorSpy.mockRestore() +} + +function comment(id: number): PRComment { + return { + id, + author: 'octocat', + authorAvatarUrl: '', + body: '', + createdAt: '', + url: '' + } +} + +function detailsWithComments(count: number): GitHubWorkItemDetails { + return { + item: { id: 'pr:1', type: 'pr' }, + body: '', + comments: Array.from({ length: count }, (_, index) => comment(index + 1)) + } as GitHubWorkItemDetails +} + +async function renderComments(details: GitHubWorkItemDetails): Promise { + let renderer: ReactTestRenderer | null = null + const restoreConsoleError = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(PRCommentsSection, { details, prState: 'open' })) + }) + } finally { + restoreConsoleError() + } + if (!renderer) { + throw new Error('PRCommentsSection did not render') + } + return renderer +} + +function audienceTabs(renderer: ReactTestRenderer) { + return renderer.root + .findAllByType('Pressable') + .filter((node) => node.props.accessibilityState !== undefined) +} + +function showMoreButton(renderer: ReactTestRenderer) { + const button = renderer.root + .findAllByType('Pressable') + .find((node) => node.props.accessibilityState === undefined) + if (!button) { + throw new Error('Show more button not found') + } + return button +} + +async function press(node: { props: { onPress: () => void } }): Promise { + await act(async () => { + node.props.onPress() + }) +} + +describe('PRCommentsSection', () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + renderer?.unmount() + renderer = null + vi.restoreAllMocks() + }) + + it('resets pagination only when the user chooses a different audience filter', async () => { + renderer = await renderComments(detailsWithComments(25)) + expect(renderer.root.findAllByType('PRCommentCard')).toHaveLength(12) + + await press(showMoreButton(renderer)) + expect(renderer.root.findAllByType('PRCommentCard')).toHaveLength(24) + + // The second tab is Humans; moving from All to Humans resets the page limit. + await press(audienceTabs(renderer)[1]) + expect(renderer.root.findAllByType('PRCommentCard')).toHaveLength(12) + + await press(showMoreButton(renderer)) + expect(renderer.root.findAllByType('PRCommentCard')).toHaveLength(24) + + // Retapping the active tab used to leave the page size alone; retain that behavior. + await press(audienceTabs(renderer)[1]) + expect(renderer.root.findAllByType('PRCommentCard')).toHaveLength(24) + }) +}) diff --git a/mobile/src/components/pr-sidebar/PRCommentsSection.tsx b/mobile/src/components/pr-sidebar/PRCommentsSection.tsx index b6c0a1009d6..bb473307619 100644 --- a/mobile/src/components/pr-sidebar/PRCommentsSection.tsx +++ b/mobile/src/components/pr-sidebar/PRCommentsSection.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import { ActivityIndicator, Pressable, Text, View } from 'react-native' import { ChevronDown, ChevronRight } from 'lucide-react-native' import type { GitHubWorkItemDetails, PRState } from '../../../../src/shared/types' @@ -39,6 +39,9 @@ type Props = { // Interactive comment actions (reply/resolve/add/edit/delete). Absent (e.g. // non-PR) leaves the timeline read-only. actions?: MobilePrCommentActions + // Author logins manually marked as bots on desktop; keeps the Humans/Bots + // tabs classifying the same comments as the desktop panel. + botAuthorOverrides?: ReadonlySet } // Render comments in bounded pages — the whole sidebar is one ScrollView (can't @@ -49,7 +52,13 @@ const COMMENT_PAGE = 12 // PR body + full comment timeline, mirroring the desktop PR page: a Description // card, then a Comments section with an audience filter (PRs only), threaded // review comments, reactions, and collapsible resolved threads. -export function PRCommentsSection({ details, prState, prRepo, actions }: Props) { +export function PRCommentsSection({ + details, + prState, + prRepo, + actions, + botAuthorOverrides +}: Props) { // details is null while phase 2 (the heavy comments/body payload) is still loading. // A synthetic placeholder means phase 2 failed — do not paint that as empty success. const loadingDetails = details === null @@ -83,15 +92,27 @@ export function PRCommentsSection({ details, prState, prRepo, actions }: Props) const canComment = isPr && actions !== undefined && canAddRootComment(prState) const [filter, setFilter] = useState('all') - const counts = useMemo(() => getPRCommentAudienceCounts(comments), [comments]) - const visible = useMemo(() => filterPRCommentsByAudience(comments, filter), [comments, filter]) + const counts = useMemo( + () => getPRCommentAudienceCounts(comments, botAuthorOverrides), + [botAuthorOverrides, comments] + ) + const visible = useMemo( + () => filterPRCommentsByAudience(comments, filter, botAuthorOverrides), + [botAuthorOverrides, comments, filter] + ) const groups = useMemo(() => groupPRComments(visible), [visible]) - // Bounded render window; reset to the first page whenever the filtered set changes. + // Bounded render window; reset to the first page when the user selects another filter. const [limit, setLimit] = useState(COMMENT_PAGE) - useEffect(() => { + const selectFilter = (nextFilter: PRCommentAudienceFilter): void => { + if (nextFilter === filter) { + return + } + // Why: paging belongs to the filter-tab event, so reset it in the same batch + // instead of briefly rendering the new filter with the previous page size. setLimit(COMMENT_PAGE) - }, [filter]) + setFilter(nextFilter) + } const shownGroups = groups.slice(0, limit) const remaining = groups.length - shownGroups.length @@ -139,7 +160,7 @@ export function PRCommentsSection({ details, prState, prRepo, actions }: Props) setFilter(tab.value)} + onPress={() => selectFilter(tab.value)} accessibilityRole="button" accessibilityState={{ selected: active }} > diff --git a/mobile/src/components/pr-sidebar/pr-comment-audience.ts b/mobile/src/components/pr-sidebar/pr-comment-audience.ts index 309c548184d..71032a74f81 100644 --- a/mobile/src/components/pr-sidebar/pr-comment-audience.ts +++ b/mobile/src/components/pr-sidebar/pr-comment-audience.ts @@ -1,4 +1,10 @@ import type { PRComment } from '../../../../src/shared/types' +import { + createBotAuthorOverrideSet, + normalizePRCommentAuthorLogin +} from '../../../../src/shared/pr-bot-author-overrides' + +export { createBotAuthorOverrideSet } // Audience filtering for the PR comment timeline, ported from the desktop helper // (src/renderer/src/lib/pr-comment-audience.ts) minus its i18n wrapper so it stays @@ -43,12 +49,18 @@ const KNOWN_AUTOMATION_LOGIN_SUBSTRINGS = [ '-reviewer' ] -export function isBotPRComment(comment: PRComment): boolean { +export function isBotPRComment( + comment: PRComment, + botAuthorOverrides?: ReadonlySet +): boolean { + const author = comment.author.trim() + const normalized = normalizePRCommentAuthorLogin(author) + if (botAuthorOverrides?.has(normalized)) { + return true + } if (comment.isBot === true) { return true } - const author = comment.author.trim() - const normalized = author.toLowerCase() if (normalized.endsWith(BOT_LOGIN_SUFFIX)) { return true } @@ -59,9 +71,10 @@ export function isBotPRComment(comment: PRComment): boolean { } export function getPRCommentAudienceCounts( - comments: PRComment[] + comments: PRComment[], + botAuthorOverrides?: ReadonlySet ): Record { - const bot = comments.filter(isBotPRComment).length + const bot = comments.filter((comment) => isBotPRComment(comment, botAuthorOverrides)).length return { all: comments.length, human: comments.length - bot, @@ -71,13 +84,14 @@ export function getPRCommentAudienceCounts( export function filterPRCommentsByAudience( comments: PRComment[], - filter: PRCommentAudienceFilter + filter: PRCommentAudienceFilter, + botAuthorOverrides?: ReadonlySet ): PRComment[] { if (filter === 'bot') { - return comments.filter(isBotPRComment) + return comments.filter((comment) => isBotPRComment(comment, botAuthorOverrides)) } if (filter === 'human') { - return comments.filter((comment) => !isBotPRComment(comment)) + return comments.filter((comment) => !isBotPRComment(comment, botAuthorOverrides)) } return comments } diff --git a/mobile/src/components/pr-sidebar/pr-comment-presentation.test.ts b/mobile/src/components/pr-sidebar/pr-comment-presentation.test.ts index 2fd5eb437fb..e01fb4166dc 100644 --- a/mobile/src/components/pr-sidebar/pr-comment-presentation.test.ts +++ b/mobile/src/components/pr-sidebar/pr-comment-presentation.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { PRComment } from '../../../../src/shared/types' import { + createBotAuthorOverrideSet, filterPRCommentsByAudience, getPRCommentAudienceCounts, isBotPRComment @@ -38,6 +39,15 @@ describe('pr comment audience', () => { expect(filterPRCommentsByAudience(comments, 'human').map((c) => c.id)).toEqual([1, 3]) expect(filterPRCommentsByAudience(comments, 'all')).toHaveLength(3) }) + + it('classifies desktop-marked bot authors via overrides (desktop parity)', () => { + const overrides = createBotAuthorOverrideSet([' GretelFlux ']) + const comments = [comment({ id: 1, author: 'alice' }), comment({ id: 2, author: 'gretelflux' })] + expect(isBotPRComment(comments[1])).toBe(false) + expect(isBotPRComment(comments[1], overrides)).toBe(true) + expect(getPRCommentAudienceCounts(comments, overrides)).toEqual({ all: 2, human: 1, bot: 1 }) + expect(filterPRCommentsByAudience(comments, 'bot', overrides).map((c) => c.id)).toEqual([2]) + }) }) describe('pr comment groups', () => { diff --git a/mobile/src/files/MobileFileMarkdownPreview.test.ts b/mobile/src/files/MobileFileMarkdownPreview.test.ts new file mode 100644 index 00000000000..2afdb0b2ce9 --- /dev/null +++ b/mobile/src/files/MobileFileMarkdownPreview.test.ts @@ -0,0 +1,122 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MobileFileMarkdownPreview } from './MobileFileMarkdownPreview' + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + ScrollView: 'ScrollView', + View: 'View' +})) + +vi.mock('lucide-react-native', () => ({ + Code: 'Code', + Pencil: 'Pencil' +})) + +vi.mock('../components/MobileMarkdown', () => ({ + MobileMarkdown: 'MobileMarkdown' +})) + +vi.mock('./MobileFilePreviewSourceText', () => ({ + MobileFilePreviewSourceText: 'MobileFilePreviewSourceText', + MobileFilePreviewTruncatedNote: 'MobileFilePreviewTruncatedNote' +})) + +vi.mock('../theme/mobile-theme', () => ({ + colors: { textPrimary: '#fff', textSecondary: '#999' } +})) + +vi.mock('./mobile-file-preview-styles', () => ({ + filePreviewStyles: {} +})) + +type PreviewProps = Parameters[0] + +function suppressReactTestRendererDeprecationWarning(): () => void { + const originalConsoleError = console.error + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + const firstArg = args[0] + if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { + return + } + originalConsoleError(...args) + }) + return () => consoleErrorSpy.mockRestore() +} + +async function renderPreview(props: PreviewProps): Promise { + let renderer: ReactTestRenderer | null = null + const restoreConsoleError = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(MobileFileMarkdownPreview, props)) + }) + } finally { + restoreConsoleError() + } + if (!renderer) { + throw new Error('MobileFileMarkdownPreview did not render') + } + return renderer +} + +async function updatePreview(renderer: ReactTestRenderer, props: PreviewProps): Promise { + await act(async () => { + renderer.update(createElement(MobileFileMarkdownPreview, props)) + }) +} + +function modeToggle(renderer: ReactTestRenderer, label: string) { + const toggle = renderer.root + .findAllByType('Pressable') + .find((node) => node.props.accessibilityLabel === label) + if (!toggle) { + throw new Error(`Missing ${label} toggle`) + } + return toggle +} + +async function selectMode(renderer: ReactTestRenderer, label: string): Promise { + await act(async () => { + modeToggle(renderer, label).props.onPress() + }) +} + +function isSelected(renderer: ReactTestRenderer, label: string): boolean { + return modeToggle(renderer, label).props.accessibilityState.selected === true +} + +describe('MobileFileMarkdownPreview', () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + renderer?.unmount() + renderer = null + vi.restoreAllMocks() + }) + + it('resets the selected mode for a new file or line target without remounting the preview', async () => { + const baseProps: PreviewProps = { + relativePath: 'notes/first.md', + content: '# First', + truncated: false, + byteLength: 7 + } + renderer = await renderPreview(baseProps) + + expect(isSelected(renderer, 'View rendered Markdown preview')).toBe(true) + await selectMode(renderer, 'View Markdown source') + expect(isSelected(renderer, 'View Markdown source')).toBe(true) + + // Content updates alone preserve the user's explicitly selected mode. + await updatePreview(renderer, { ...baseProps, content: '# First updated' }) + expect(isSelected(renderer, 'View Markdown source')).toBe(true) + + await updatePreview(renderer, { ...baseProps, relativePath: 'notes/second.md' }) + expect(isSelected(renderer, 'View rendered Markdown preview')).toBe(true) + + await updatePreview(renderer, { ...baseProps, relativePath: 'notes/second.md', initialLine: 8 }) + expect(isSelected(renderer, 'View Markdown source')).toBe(true) + }) +}) diff --git a/mobile/src/files/MobileFileMarkdownPreview.tsx b/mobile/src/files/MobileFileMarkdownPreview.tsx index 0923cf1f9a0..9d53af3fe6b 100644 --- a/mobile/src/files/MobileFileMarkdownPreview.tsx +++ b/mobile/src/files/MobileFileMarkdownPreview.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useState } from 'react' import { Pressable, ScrollView, View } from 'react-native' import { Code, Pencil } from 'lucide-react-native' import { MobileMarkdown } from '../components/MobileMarkdown' @@ -25,9 +25,15 @@ export function MobileFileMarkdownPreview({ initialLine }: Props) { const [mode, setMode] = useState<'preview' | 'source'>(() => (initialLine ? 'source' : 'preview')) - useEffect(() => { + const [previousRelativePath, setPreviousRelativePath] = useState(relativePath) + const [previousInitialLine, setPreviousInitialLine] = useState(initialLine) + // Why: opening a different file or line target must switch modes before paint, + // never briefly retain the prior file's manually selected mode. + if (relativePath !== previousRelativePath || initialLine !== previousInitialLine) { + setPreviousRelativePath(relativePath) + setPreviousInitialLine(initialLine) setMode(initialLine ? 'source' : 'preview') - }, [initialLine, relativePath]) + } const previewSelected = mode === 'preview' const sourceSelected = mode === 'source' diff --git a/mobile/src/notifications/mobile-notifications.test.ts b/mobile/src/notifications/mobile-notifications.test.ts index 7ae4315086b..2d6cf868461 100644 --- a/mobile/src/notifications/mobile-notifications.test.ts +++ b/mobile/src/notifications/mobile-notifications.test.ts @@ -4,6 +4,7 @@ import { setScheduledNotificationsMaxForTests, subscribeToDesktopNotifications } from './mobile-notifications' +import AsyncStorage from '@react-native-async-storage/async-storage' import type { RpcClient } from '../transport/rpc-client' import { loadPushNotificationsEnabled } from '../storage/preferences' @@ -20,6 +21,16 @@ vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) +// Why: mobile-notifications now persists the catch-up watermark to +// AsyncStorage. The package isn't resolvable in the node test env (other +// mobile tests mock it the same way), so we provide a no-op mock. +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: vi.fn(async () => null), + setItem: vi.fn(async () => undefined) + } +})) + vi.mock('../storage/preferences', () => ({ loadPushNotificationsEnabled: vi.fn() })) @@ -316,3 +327,288 @@ describe('subscribeToDesktopNotifications', () => { } }) }) + +// Why: #8129 catch-up. On a reconnect the live stream re-emits `ready`; the +// client must fetch missed notifications from its watermark and push exactly +// the ones it had not yet delivered — never re-pushing an already-delivered id. +describe('subscribeToDesktopNotifications — reconnect catch-up', () => { + const AsyncStorageMock = vi.mocked(AsyncStorage) + + beforeEach(() => { + vi.clearAllMocks() + AsyncStorageMock.getItem.mockResolvedValue(null) + vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined) + }) + + function flushAsync(): Promise { + return new Promise((resolve) => { + setTimeout(resolve, 10) + }) + } + + function makeClient() { + let onData: ((data: unknown) => void) | null = null + const sentRequests: { method: string; params: unknown }[] = [] + const client = { + subscribe: vi.fn((_method: string, _params: unknown, cb: (data: unknown) => void) => { + onData = cb + return vi.fn() + }), + getState: vi.fn(() => 'connected'), + sendRequest: vi.fn( + async (method: string, _params: unknown = {}) => + ({ + ok: true, + result: method === 'notifications.getMissedSince' ? { notifications: [] } : undefined + }) as never + ) + } + // Why: onData is captured live via a getter (not destructured) because the + // subscribe mock assigns it asynchronously as a side effect of + // subscribeToDesktopNotifications calling client.subscribe. + return { + client: client as unknown as RpcClient, + get onData() { + return onData + }, + sentRequests + } + } + + it('does not fetch missed notifications on the first (cold-open) ready', async () => { + vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ + status: 'granted', + canAskAgain: true + } as never) + vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1') + + const sub = makeClient() + subscribeToDesktopNotifications(sub.client, 'host-1') + // First ready = cold open. + sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' }) + await flushAsync() + + expect(sub.client.sendRequest).not.toHaveBeenCalledWith( + 'notifications.getMissedSince', + expect.anything() + ) + }) + + it('fetches only notifications after the delivered watermark (idempotent catch-up)', async () => { + vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ + status: 'granted', + canAskAgain: true + } as never) + vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1') + + const sub = makeClient() + // The desktop honours the watermark: only seq 10 (agent:missed) is returned + // because seq 11 (agent:dup) was already delivered on the live stream and + // advanced lastDeliveredSeq to 11. So the replay never re-includes it. + sub.client.sendRequest = vi.fn(async (method: string) => { + if (method === 'notifications.getMissedSince') { + return { + ok: true, + result: { + notifications: [ + { + type: 'notification', + title: 'missed', + body: 'b', + notificationId: 'agent:missed', + notificationSeq: 10 + } + ] + } + } as never + } + return { ok: true, result: undefined } as never + }) + + subscribeToDesktopNotifications(sub.client, 'host-1') + // First ready = cold open (no fetch). + sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' }) + await flushAsync() + // Live stream already delivered agent:dup (seq 11) before reap. + sub.onData?.({ + type: 'notification', + title: 'dup', + body: 'b', + notificationId: 'agent:dup', + notificationSeq: 11 + }) + await flushAsync() + // Reconnect ready → fetchMissed sends the watermark (11). + sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' }) + await flushAsync() + await flushAsync() + + // The watermark passed to getMissedSince is the delivered seq. + const missedCall = vi + .mocked(sub.client.sendRequest) + .mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince') + expect(missedCall?.[1]).toEqual({ lastSeenSeq: 11 }) + // Only agent:missed was pushed; agent:dup appears exactly once (live only). + const scheduledIds = vi + .mocked(Notifications.scheduleNotificationAsync) + .mock.calls.map( + (call) => + (call[0] as { content: { data: { notificationId: string } } }).content.data.notificationId + ) + expect(scheduledIds).toEqual(['agent:dup', 'agent:missed']) + expect(scheduledIds.filter((id) => id === 'agent:dup')).toHaveLength(1) + }) + + it('drops an already-seen id if a replay re-includes it (defense-in-depth)', async () => { + vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ + status: 'granted', + canAskAgain: true + } as never) + vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s') + + const sub = makeClient() + // Simulate the bounded-buffer edge: the desktop returns seq 11 again + // (already delivered live) alongside a new seq 12. + sub.client.sendRequest = vi.fn(async (method: string) => { + if (method === 'notifications.getMissedSince') { + return { + ok: true, + result: { + notifications: [ + { + type: 'notification', + title: 'dup', + body: 'b', + notificationId: 'agent:dup', + notificationSeq: 11 + }, + { + type: 'notification', + title: 'new', + body: 'b', + notificationId: 'agent:new', + notificationSeq: 12 + } + ] + } + } as never + } + return { ok: true, result: undefined } as never + }) + + subscribeToDesktopNotifications(sub.client, 'host-1') + sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' }) + await flushAsync() + // Live stream delivered agent:dup (seq 11) before reap. + sub.onData?.({ + type: 'notification', + title: 'dup', + body: 'b', + notificationId: 'agent:dup', + notificationSeq: 11 + }) + await flushAsync() + // Reconnect replay re-includes seq 11 (must be dropped) + new seq 12. + sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' }) + await flushAsync() + await flushAsync() + + const scheduledIds = vi + .mocked(Notifications.scheduleNotificationAsync) + .mock.calls.map( + (call) => + (call[0] as { content: { data: { notificationId: string } } }).content.data.notificationId + ) + expect(scheduledIds).toEqual(['agent:dup', 'agent:new']) + expect(scheduledIds.filter((id) => id === 'agent:dup')).toHaveLength(1) + }) + + it('persists the highest delivered seq so a later reconnect resumes from it', async () => { + vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ + status: 'granted', + canAskAgain: true + } as never) + vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s') + + const sub = makeClient() + subscribeToDesktopNotifications(sub.client, 'host-1') + sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' }) + await flushAsync() + // Live stream delivers seq 5. + sub.onData?.({ + type: 'notification', + title: 't', + body: 'b', + notificationId: 'agent:live', + notificationSeq: 5 + }) + await flushAsync() + + expect(AsyncStorageMock.setItem).toHaveBeenCalledWith( + 'orca:mobileNotificationsLastSeq:host-1', + '5' + ) + }) + + // Why: a replay-ONLY delivery (nothing arrived live first) must still advance + // and persist the watermark. This is the exact case the seq/notificationSeq + // field mismatch broke — the desktop replay path returns `notificationSeq` + // (matching the live fan-out), so the client watermark moves and the next + // reconnect resumes from it instead of re-fetching from 0. + it('advances + persists the watermark from a replay-only delivery (#8129 field-mismatch regression)', async () => { + vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ + status: 'granted', + canAskAgain: true + } as never) + vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s') + + const sub = makeClient() + // Desktop replay returns events keyed by notificationSeq (the fixed shape). + sub.client.sendRequest = vi.fn(async (method: string) => { + if (method === 'notifications.getMissedSince') { + return { + ok: true, + result: { + notifications: [ + { + type: 'notification', + title: 'missed', + body: 'b', + notificationId: 'agent:missed', + notificationSeq: 8 + } + ] + } + } as never + } + return { ok: true, result: undefined } as never + }) + + subscribeToDesktopNotifications(sub.client, 'host-1') + sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' }) + await flushAsync() + // First reconnect → replay delivers seq 8 (no prior live delivery). + sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' }) + await flushAsync() + await flushAsync() + + // Watermark advanced to the replayed seq and was persisted. + expect(AsyncStorageMock.setItem).toHaveBeenCalledWith( + 'orca:mobileNotificationsLastSeq:host-1', + '8' + ) + + // Second reconnect resumes from the advanced watermark, not 0. + sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' }) + await flushAsync() + const missedCalls = vi + .mocked(sub.client.sendRequest) + .mock.calls.filter((c: unknown[]) => c[0] === 'notifications.getMissedSince') + expect(missedCalls.at(-1)?.[1]).toEqual({ lastSeenSeq: 8 }) + }) +}) diff --git a/mobile/src/notifications/mobile-notifications.ts b/mobile/src/notifications/mobile-notifications.ts index 9ab936f75c6..f91e5981366 100644 --- a/mobile/src/notifications/mobile-notifications.ts +++ b/mobile/src/notifications/mobile-notifications.ts @@ -3,6 +3,12 @@ import { Platform } from 'react-native' import type { RpcClient } from '../transport/rpc-client' import { loadPushNotificationsEnabled } from '../storage/preferences' import { buildLocalNotificationData, type DesktopNotificationSource } from './notification-routing' +import { + createSeenNotificationGuard, + loadLastSeenSeq, + saveLastSeenSeq, + seenKeyForEvent +} from './notification-reconnect-catchup' type NotificationEvent = { type: 'notification' @@ -11,11 +17,16 @@ type NotificationEvent = { body: string worktreeId?: string notificationId?: string + // Mirrors the desktop-assigned MobileNotificationEvent.notificationSeq used + // for reconnect catch-up (#8129). Optional because older runtimes / non- + // replay events may omit it. + notificationSeq?: number } type DismissNotificationEvent = { type: 'dismiss' notificationId: string + notificationSeq?: number } type SubscribeResult = { @@ -223,18 +234,105 @@ async function dismissLocalNotification( // Why: each host connection gets its own notification subscription. When the // connection drops, the unsubscribe function cleans up the streaming RPC. +// On reconnect the same subscribe stream is re-established by the RPC client; +// we use its `ready` event to trigger catch-up (#8129): fetch notifications +// dispatched while the socket was reaped, watermarked by the last seq we +// already delivered so the desktop never re-sends an already-pushed one. // Returns an unsubscribe function. export function subscribeToDesktopNotifications(client: RpcClient, hostId: string): () => void { configureNotificationChannel() let subscriptionId: string | null = null let disposed = false + // Highest seq delivered on the live stream or replay for this connection. + // Persisted per-host so a cold app start still resumes from the right cut. + let lastDeliveredSeq = 0 + // Why: per-connection dedup guard applied ONLY to the replay path + // (fetchMissed), never the live stream. The desktop already guarantees the + // replay cannot contain an event with seq <= lastDeliveredSeq (both live and + // replay advance the same watermark), so live + replay never overlap there. + // This set is defense-in-depth: if the desktop's bounded buffer evicted an + // old entry and a reconnect re-fetches across a boundary, an id delivered in + // the same connection isn't pushed twice. Bounded (RECENTLY_SEEN_CAP) so a + // long-lived session can't grow without limit. + const seenReplay = createSeenNotificationGuard() + + function deliverLive( + type: 'notification' | 'dismiss', + event: NotificationEvent | DismissNotificationEvent + ): Promise { + if (event.notificationSeq != null && event.notificationSeq > lastDeliveredSeq) { + lastDeliveredSeq = event.notificationSeq + void saveLastSeenSeq(hostId, lastDeliveredSeq) + } + // Why (#8129 dedup): mark the event seen on EVERY delivery path (live AND + // replay) so a replay that re-includes an id already pushed live in this + // connection is dropped instead of double-pushed. fetchMissed also + // pre-checks seenReplay, but without this the live path never populated it. + const key = seenKeyForEvent(event) + if (key) { + seenReplay.add(key) + } + if (type === 'notification') { + return showLocalNotification(event as NotificationEvent, hostId) + } + return dismissLocalNotification(event as DismissNotificationEvent, hostId) + } + + // Why: on a reconnect `ready` the desktop has already dispatched whatever we + // missed; ask for it from our persisted watermark. Because the desktop cuts + // by seq > lastSeenSeq this is idempotent — we only ever get events we have + // not delivered before. The seenReplay guard is a second layer so a replay + // that somehow re-includes an id already delivered this connection is + // dropped instead of double-pushed. + async function fetchMissed(): Promise { + if (disposed) { + return + } + const missed = await client + .sendRequest('notifications.getMissedSince', { lastSeenSeq: lastDeliveredSeq }) + .then((response) => { + if (!response.ok) { + return [] + } + const result = response.result as { notifications?: unknown[] } | undefined + return Array.isArray(result?.notifications) ? result.notifications : [] + }) + .catch(() => []) + for (const raw of missed) { + const event = raw as NotificationEvent | DismissNotificationEvent + const key = seenKeyForEvent(event) + if (key && seenReplay.has(key)) { + continue + } + if (key) { + seenReplay.add(key) + } + if (event.type === 'notification') { + await deliverLive('notification', event) + } else if (event.type === 'dismiss') { + await deliverLive('dismiss', event) + } + } + } + + // Why: lazily seed the watermark from durable storage on first use so we + // don't block subscribe() on an AsyncStorage read. The first `ready` (cold + // open) does NOT need catch-up — the live stream starts fresh; only + // subsequent reconnect `ready` events fetch missed notifications. + let watermarkLoaded = false + void loadLastSeenSeq(hostId).then((seq) => { + lastDeliveredSeq = Math.max(lastDeliveredSeq, seq) + watermarkLoaded = true + }) + function unsubscribeServer(id: string) { if (client.getState() === 'connected') { client.sendRequest('notifications.unsubscribe', { subscriptionId: id }).catch(() => {}) } } + let reconnectReadyCount = 0 const unsubscribeStream = client.subscribe('notifications.subscribe', {}, (data: unknown) => { const event = data as | NotificationEvent @@ -243,9 +341,18 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin | { type: 'end' } if (event.type === 'ready') { subscriptionId = (event as SubscribeResult).subscriptionId + reconnectReadyCount += 1 if (disposed) { unsubscribeServer(subscriptionId) unsubscribeStream() + return + } + // Why: first ready is the cold-open live stream — no catch-up needed. + // Every later ready is a reconnect; fetch what we missed from the + // watermark. Guard on watermarkLoaded so a fast reconnect doesn't + // fetch from a stale 0 watermark (which would re-push everything). + if (reconnectReadyCount > 1 && watermarkLoaded) { + void fetchMissed() } return } @@ -259,9 +366,9 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin return } if (event.type === 'notification') { - void showLocalNotification(event as NotificationEvent, hostId) + void deliverLive('notification', event as NotificationEvent) } else if (event.type === 'dismiss') { - void dismissLocalNotification(event as DismissNotificationEvent, hostId) + void deliverLive('dismiss', event as DismissNotificationEvent) } }) diff --git a/mobile/src/notifications/notification-reconnect-catchup.ts b/mobile/src/notifications/notification-reconnect-catchup.ts new file mode 100644 index 00000000000..d40af93a090 --- /dev/null +++ b/mobile/src/notifications/notification-reconnect-catchup.ts @@ -0,0 +1,97 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' + +// Why: the reconnect catch-up watermark + dedup helpers for #8129, extracted +// from mobile-notifications.ts so that file stays under its max-lines budget. +// The highest desktop notification seq this device has delivered is persisted +// per-host so it survives app restarts. On reconnect we send it to +// notifications.getMissedSince as the catch-up watermark — the desktop then +// returns only notifications dispatched after it, so we never re-push a +// notification we already delivered. The in-memory seen-set is a second guard +// against double-delivery for events that arrive on both the live stream and a +// replay (e.g. a brief liveness spell before a reap). +const LAST_SEQ_STORAGE_KEY_PREFIX = 'orca:mobileNotificationsLastSeq:' + +function lastSeqStorageKey(hostId: string): string { + return LAST_SEQ_STORAGE_KEY_PREFIX + encodeURIComponent(hostId) +} + +export async function loadLastSeenSeq(hostId: string): Promise { + try { + const raw = await AsyncStorage.getItem(lastSeqStorageKey(hostId)) + const parsed = raw == null ? 0 : Number(raw) + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0 + } catch { + return 0 + } +} + +export async function saveLastSeenSeq(hostId: string, seq: number): Promise { + if (!Number.isFinite(seq) || seq <= 0) { + return + } + try { + await AsyncStorage.setItem(lastSeqStorageKey(hostId), String(seq)) + } catch { + // Why: persisting the watermark is best-effort. If it fails (or lags), the + // stored value stays BELOW what we delivered, so a later cold start can + // re-fetch — and, once the in-memory seen-set is gone, re-show — an already + // delivered notification. That's the accepted at-least-once trade-off; + // within a live session the in-memory watermark is authoritative, so only + // post-restart reconnects are affected. + } +} + +// Why: bounded in-memory dedup window for notificationIds/dismiss ids observed +// on the current connection. The desktop already dedupes by seq on replay, but +// a socket that flickers background→foreground→background can deliver an event +// on the live stream and again in a replay; the seen-set guarantees each +// notificationId maps to at most one local push for the connection lifetime. +// Bounded so a long-lived session can't grow without limit — a 2x superset of +// the desktop's 256-entry replay buffer and the 256 scheduled-notification cap. +const RECENTLY_SEEN_CAP = 512 + +export function createSeenNotificationGuard(): { + has: (id: string) => boolean + add: (id: string) => void +} { + const seen = new Set() + return { + has(id: string): boolean { + return seen.has(id) + }, + add(id: string): void { + seen.add(id) + if (seen.size > RECENTLY_SEEN_CAP) { + // Why: insertion order; the oldest entries are first. Drop one to stay + // bounded without disturbing the more-recently-relevant keys. + const first = seen.values().next().value + if (first !== undefined) { + seen.delete(first) + } + } + } + } +} + +// Why: key for the replay dedup guard. Uses notificationId when present, but +// disambiguates by seq so a legitimate live re-delivery of the same id at a +// NEW seq (content refresh, allowed by the existing behaviour) is NOT treated +// as a duplicate, while a replay re-returning the SAME id+seq already delivered +// live is suppressed. Replay events always carry a seq (the desktop assigns +// one), so the guard is effective on the reconnect path. +export function seenKeyForEvent(event: { + notificationId?: string + notificationSeq?: number +}): string | null { + const id = event.notificationId + if (id != null && event.notificationSeq != null) { + return `id:${id}#${event.notificationSeq}` + } + if (id != null) { + return `id:${id}` + } + if (event.notificationSeq != null) { + return `seq:${event.notificationSeq}` + } + return null +} diff --git a/mobile/src/session/use-pr-bot-author-overrides.ts b/mobile/src/session/use-pr-bot-author-overrides.ts new file mode 100644 index 00000000000..4d9166c39af --- /dev/null +++ b/mobile/src/session/use-pr-bot-author-overrides.ts @@ -0,0 +1,61 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import type { ConnectionState } from '../transport/types' +import type { RpcSuccess } from '../transport/types' +import type { RpcClient } from '../transport/rpc-client' +import { createBotAuthorOverrideSet } from '../../../src/shared/pr-bot-author-overrides' + +// Fetches the desktop's manual bot-author overrides (GlobalSettings.prBotAuthorOverrides, +// marked from the desktop Comments panel) so the mobile Humans/Bots comment filter +// classifies the same authors as bots. There is no settings-change stream over the +// mobile RPC, so callers pass a refreshKey (e.g. the PR payload identity) to re-fetch +// alongside PR data instead of holding a one-shot snapshot for the whole session. +export function usePRBotAuthorOverrides( + client: RpcClient | null, + connState: ConnectionState, + refreshKey?: unknown +): ReadonlySet { + const [logins, setLogins] = useState([]) + const sourceClientRef = useRef(null) + + useEffect(() => { + if (!client || connState !== 'connected') { + sourceClientRef.current = null + setLogins([]) + return + } + if (sourceClientRef.current !== client) { + // Why: overrides belong to the connected runtime; never render a prior + // host's classification while the replacement host is still loading. + sourceClientRef.current = client + setLogins([]) + } + if (refreshKey == null) { + return + } + let stale = false + void client + .sendRequest('settings.get') + .then((response) => { + if (stale || !response.ok) { + return + } + const result = (response as RpcSuccess).result as { + settings?: { prBotAuthorOverrides?: unknown } + } | null + const overrides = result?.settings?.prBotAuthorOverrides + setLogins( + Array.isArray(overrides) + ? overrides.filter((login): login is string => typeof login === 'string') + : [] + ) + }) + .catch(() => { + // Best-effort: without the setting the heuristics still classify most bots. + }) + return () => { + stale = true + } + }, [client, connState, refreshKey]) + + return useMemo(() => createBotAuthorOverrideSet(logins), [logins]) +} diff --git a/mobile/src/source-control/mobile-pr-create.ts b/mobile/src/source-control/mobile-pr-create.ts index 675df20bedb..b9a3817f4d2 100644 --- a/mobile/src/source-control/mobile-pr-create.ts +++ b/mobile/src/source-control/mobile-pr-create.ts @@ -66,6 +66,8 @@ export function getMobilePrCreateBlockMessage(prefill: MobilePrPrefill): string return `A ${copy.reviewLabel} already exists for this branch.` case 'fork_head_unsupported': return `Creating a ${copy.reviewLabel} from this fork is not supported.` + case 'base_not_on_remote': + return `Push the base branch before creating a ${copy.reviewLabel}.` case 'needs_push': case null: case undefined: diff --git a/mobile/src/tasks/blank-workspace-create.test.ts b/mobile/src/tasks/blank-workspace-create.test.ts new file mode 100644 index 00000000000..1d35d29f758 --- /dev/null +++ b/mobile/src/tasks/blank-workspace-create.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { createBlankWorkspace } from './blank-workspace-create' + +type Call = { method: string; params: unknown } + +function fakeClient(script: (method: string, call: number) => unknown, calls: Call[]): RpcClient { + return { + sendRequest: async (method: string, params?: unknown) => { + calls.push({ method, params }) + const result = script(method, calls.length) + if (result instanceof Error) { + return { + id: '1', + ok: false, + error: { code: 'x', message: result.message }, + _meta: { runtimeId: 'r' } + } + } + return { id: '1', ok: true, result, _meta: { runtimeId: 'r' } } + } + } as unknown as RpcClient +} + +describe('createBlankWorkspace', () => { + it('assembles exactly the params the modal historically sent, omitting empty extras', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-1' } }), calls) + + const result = await createBlankWorkspace({ + client, + repoId: 'repo-1', + baseName: 'octopus', + startupCommand: undefined, + createdWithAgentId: undefined, + comment: undefined, + setupDecision: 'inherit' + }) + + expect(result).toEqual({ worktreeId: 'wt-1', name: 'octopus' }) + expect(calls).toHaveLength(1) + expect(calls[0]).toEqual({ + method: 'worktree.create', + params: { + repo: 'id:repo-1', + startupCommand: undefined, + setupDecision: 'inherit', + name: 'octopus' + } + }) + const params = calls[0]?.params as Record + expect('createdWithAgent' in params).toBe(false) + expect('comment' in params).toBe(false) + }) + + it('includes createdWithAgent and comment only when provided', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-2' } }), calls) + + await createBlankWorkspace({ + client, + repoId: 'repo-2', + baseName: 'manatee', + startupCommand: 'claude', + createdWithAgentId: 'claude', + comment: 'spike', + setupDecision: 'run' + }) + + expect(calls[0]?.params).toMatchObject({ + repo: 'id:repo-2', + name: 'manatee', + startupCommand: 'claude', + setupDecision: 'run', + createdWithAgent: 'claude', + comment: 'spike' + }) + }) + + it('retries with a numeric suffix on a branch-collision error', async () => { + const calls: Call[] = [] + const client = fakeClient((_method, call) => { + if (call === 1) { + return new Error('Branch "octopus" already exists locally. Pick a different branch name.') + } + return { worktree: { id: 'wt-3' } } + }, calls) + + const result = await createBlankWorkspace({ + client, + repoId: 'repo-1', + baseName: 'octopus', + startupCommand: undefined, + createdWithAgentId: undefined, + comment: undefined, + setupDecision: 'inherit' + }) + + expect(result).toEqual({ worktreeId: 'wt-3', name: 'octopus-2' }) + expect(calls).toHaveLength(2) + const retryParams = calls[1]?.params as Record + expect(retryParams.name).toBe('octopus-2') + }) + + it('retries on the bare older-runtime collision message', async () => { + const calls: Call[] = [] + const client = fakeClient((_method, call) => { + if (call === 1) { + return new Error('Branch "octopus" already exists.') + } + return { worktree: { id: 'wt-4' } } + }, calls) + + const result = await createBlankWorkspace({ + client, + repoId: 'repo-1', + baseName: 'octopus', + startupCommand: undefined, + createdWithAgentId: undefined, + comment: undefined, + setupDecision: 'inherit' + }) + + expect(result).toEqual({ worktreeId: 'wt-4', name: 'octopus-2' }) + expect(calls).toHaveLength(2) + }) + + it('surfaces a non-collision error without retrying', async () => { + const calls: Call[] = [] + const client = fakeClient(() => new Error('SSH connection is not available'), calls) + + const result = await createBlankWorkspace({ + client, + repoId: 'repo-1', + baseName: 'octopus', + startupCommand: undefined, + createdWithAgentId: undefined, + comment: undefined, + setupDecision: 'skip' + }) + + expect(result).toEqual({ error: 'SSH connection is not available' }) + expect(calls).toHaveLength(1) + }) +}) diff --git a/mobile/src/tasks/blank-workspace-create.ts b/mobile/src/tasks/blank-workspace-create.ts new file mode 100644 index 00000000000..014af558198 --- /dev/null +++ b/mobile/src/tasks/blank-workspace-create.ts @@ -0,0 +1,37 @@ +import type { TuiAgent } from '../../../src/shared/types' +import type { RpcClient } from '../transport/rpc-client' +import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry' +import type { WorkspaceCreateSetupDecision } from './workspace-create-params' + +// The blank/named create path, extracted from NewWorktreeModal so the modal keeps +// only the UI-coupled setup-trust flow. Assembles worktree.create params and +// applies the shared name-collision retry. +export async function createBlankWorkspace(args: { + client: RpcClient + repoId: string + baseName: string + startupCommand: string | undefined + createdWithAgentId: TuiAgent | undefined + comment: string | undefined + setupDecision: WorkspaceCreateSetupDecision +}): Promise { + return createWorktreeWithNameRetry({ + client: args.client, + baseName: args.baseName, + buildParams: (name) => { + const params: Record = { + repo: `id:${args.repoId}`, + startupCommand: args.startupCommand, + setupDecision: args.setupDecision, + name + } + if (args.createdWithAgentId) { + params.createdWithAgent = args.createdWithAgentId + } + if (args.comment) { + params.comment = args.comment + } + return params + } + }) +} diff --git a/mobile/src/tasks/composer-linked-work-item.test.ts b/mobile/src/tasks/composer-linked-work-item.test.ts new file mode 100644 index 00000000000..d08d1388a0e --- /dev/null +++ b/mobile/src/tasks/composer-linked-work-item.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from 'vitest' +import type { GitHubWorkItem, GitLabWorkItem, LinearIssue } from '../../../src/shared/types' +import { + buildGitHubLinkedWorkItem, + buildGitLabLinkedWorkItem, + buildLinearLinkedWorkItem, + buildSmartNameSelection, + resolveComposerBranchPick, + resolveComposerCreateSelection, + resolveWorkItemAutoName, + shouldApplyAutoName +} from './composer-linked-work-item' + +describe('linked work item builders', () => { + it('maps a GitHub PR into a linked work item', () => { + const linked = buildGitHubLinkedWorkItem({ + type: 'pr', + number: 42, + title: 'Fix bug', + url: 'https://github.com/o/r/pull/42', + repoId: 'repo-1' + }) + expect(linked).toMatchObject({ provider: 'github', type: 'pr', number: 42, repoId: 'repo-1' }) + }) + + it('maps a GitLab MR into a linked work item', () => { + const linked = buildGitLabLinkedWorkItem({ + type: 'mr', + number: 7, + title: 'Add feature', + url: 'https://gitlab.com/g/p/-/merge_requests/7', + repoId: 'repo-2' + }) + expect(linked).toMatchObject({ provider: 'gitlab', type: 'mr', number: 7, repoId: 'repo-2' }) + }) + + it('maps a Linear issue with identifier, workspace, and org key', () => { + const linked = buildLinearLinkedWorkItem({ + identifier: 'ENG-9', + title: 'Ship it', + url: 'https://linear.app/acme/issue/ENG-9', + workspaceId: 'ws-1' + }) + expect(linked).toMatchObject({ + provider: 'linear', + type: 'issue', + number: 0, + linearIdentifier: 'ENG-9', + linearWorkspaceId: 'ws-1', + linearOrganizationUrlKey: 'acme' + }) + }) +}) + +describe('shouldApplyAutoName', () => { + it('applies when the name is empty or the previous auto-name', () => { + expect(shouldApplyAutoName({ currentName: '', lastAutoName: '' })).toBe(true) + expect(shouldApplyAutoName({ currentName: 'fix-bug', lastAutoName: 'fix-bug' })).toBe(true) + }) + + it('applies when the name is a lookup query (URL / #N)', () => { + expect(shouldApplyAutoName({ currentName: '#42', lastAutoName: 'x' })).toBe(true) + expect(shouldApplyAutoName({ currentName: 'ENG-9', lastAutoName: 'x' })).toBe(false) + }) + + it('keeps a deliberately typed name', () => { + expect(shouldApplyAutoName({ currentName: 'my custom name', lastAutoName: 'other' })).toBe( + false + ) + }) +}) + +describe('resolveWorkItemAutoName', () => { + it('slugifies the title subject', () => { + expect( + resolveWorkItemAutoName({ + type: 'issue', + number: 3, + title: 'Fix the Login Bug', + provider: 'github' + }) + ).toBe('fix-the-login-bug') + }) +}) + +describe('buildSmartNameSelection', () => { + const base = (over: Record) => ({ + provider: 'github' as const, + type: 'pr' as const, + number: 12, + title: 'T', + url: 'u', + ...over + }) + + it('maps GitHub PR / issue kinds and numbers the label', () => { + expect(buildSmartNameSelection({ linkedWorkItem: base({}), baseBranch: undefined })).toEqual({ + kind: 'github-pr', + label: '#12 T', + url: 'u' + }) + expect( + buildSmartNameSelection({ linkedWorkItem: base({ type: 'issue' }), baseBranch: undefined }) + ).toMatchObject({ kind: 'github-issue' }) + }) + + it('maps GitLab MR / issue kinds', () => { + expect( + buildSmartNameSelection({ + linkedWorkItem: base({ provider: 'gitlab', type: 'mr' }), + baseBranch: undefined + }) + ).toMatchObject({ kind: 'gitlab-mr' }) + expect( + buildSmartNameSelection({ + linkedWorkItem: base({ provider: 'gitlab', type: 'issue' }), + baseBranch: undefined + }) + ).toMatchObject({ kind: 'gitlab-issue' }) + }) + + it('maps Linear with a bare title label', () => { + expect( + buildSmartNameSelection({ + linkedWorkItem: base({ provider: 'linear', type: 'issue', number: 0, title: 'ENG-9 Ship' }), + baseBranch: undefined + }) + ).toEqual({ kind: 'linear', label: 'ENG-9 Ship', url: 'u' }) + }) + + it('falls back to a branch pill', () => { + expect(buildSmartNameSelection({ linkedWorkItem: null, baseBranch: 'main' })).toEqual({ + kind: 'branch', + label: 'main' + }) + }) + + it('returns null when nothing is selected', () => { + expect(buildSmartNameSelection({ linkedWorkItem: null, baseBranch: undefined })).toBeNull() + }) +}) + +describe('resolveComposerCreateSelection', () => { + const baseCreateArgs = { + branch: null, + reuseEligibleBranch: null, + reuseSelectedBranch: false, + branchCreateIntent: false, + name: '' + } + + it('prefers a linked work item and passes resolved base fields', () => { + const selection = resolveComposerCreateSelection({ + ...baseCreateArgs, + linkedWorkItem: { + provider: 'github', + type: 'pr', + number: 5, + title: 'T', + url: 'u', + repoId: 'repo-1' + }, + base: { baseBranch: 'main', compareBaseRef: 'origin/main', branchNameOverride: 'pr-5' } + }) + expect(selection).toMatchObject({ + kind: 'work-item', + baseBranch: 'main', + compareBaseRef: 'origin/main', + branchNameOverride: 'pr-5' + }) + }) + + it('marks reuse when the eligible branch is toggled on', () => { + const selection = resolveComposerCreateSelection({ + ...baseCreateArgs, + linkedWorkItem: null, + base: { baseBranch: 'feature', branchNameOverride: 'feature' }, + branch: { refName: 'feature', localBranchName: 'feature' }, + reuseEligibleBranch: 'feature', + reuseSelectedBranch: true + }) + expect(selection).toEqual({ + kind: 'branch', + baseBranch: 'feature', + refName: 'feature', + localBranchName: 'feature', + reuse: true, + branchNameOverride: 'feature' + }) + }) + + it('returns a new-branch selection when create-branch intent is set', () => { + expect( + resolveComposerCreateSelection({ + ...baseCreateArgs, + linkedWorkItem: null, + base: {}, + branchCreateIntent: true, + name: 'feature/login' + }) + ).toEqual({ kind: 'new-branch', branchName: 'feature/login' }) + }) + + it('returns null with no work item, no branch base, and no intent', () => { + expect( + resolveComposerCreateSelection({ ...baseCreateArgs, linkedWorkItem: null, base: {} }) + ).toBeNull() + }) +}) + +describe('resolveComposerBranchPick', () => { + it('auto-names and enables reuse for an unused local branch', () => { + const pick = resolveComposerBranchPick({ + refName: 'feature', + localBranchName: 'feature', + currentName: '', + lastAutoName: '', + worktreeBranches: [] + }) + expect(pick.base).toEqual({ baseBranch: 'feature', branchNameOverride: 'feature' }) + expect(pick).toMatchObject({ + reuseEligibleBranch: 'feature', + reuseSelectedBranch: true, + name: 'feature' + }) + }) + + it('does not reuse a branch already checked out elsewhere', () => { + const pick = resolveComposerBranchPick({ + refName: 'feature', + localBranchName: 'feature', + currentName: '', + lastAutoName: '', + worktreeBranches: ['refs/heads/feature'] + }) + expect(pick.reuseEligibleBranch).toBeNull() + expect(pick.reuseSelectedBranch).toBe(false) + expect(pick.base.branchNameOverride).toBeUndefined() + }) +}) + +// Keep the exported type aliases referenced so the module surface stays covered. +export type _Ref = [GitHubWorkItem, GitLabWorkItem, LinearIssue] diff --git a/mobile/src/tasks/composer-linked-work-item.ts b/mobile/src/tasks/composer-linked-work-item.ts new file mode 100644 index 00000000000..ed2bc4dbf2d --- /dev/null +++ b/mobile/src/tasks/composer-linked-work-item.ts @@ -0,0 +1,153 @@ +import type { GitHubWorkItem, GitLabWorkItem, LinearIssue } from '../../../src/shared/types' +import { getLinearIssueWorkspaceName } from '../../../src/shared/workspace-name' +import { + buildGitHubWorkspaceSource, + buildGitLabWorkspaceSource, + buildLinearWorkspaceSource, + buildWorkspaceSourceSelection, + getWorkspaceSourceName, + shouldApplyWorkspaceSourceAutoName +} from '../../../src/shared/new-workspace/workspace-source' +import { resolveComposerBranchPick as resolveSharedComposerBranchPick } from '../../../src/shared/composer-branch-selection' +import type { + MobileComposerCreateSelection, + MobileLinkedWorkItem, + SmartNameSelection +} from './mobile-composer-source-types' +import type { WorkspaceCreateGitPushTarget } from './workspace-create-params' + +export function buildGitHubLinkedWorkItem(item: { + type: 'issue' | 'pr' + number: number + title: string + url: string + repoId: string +}): MobileLinkedWorkItem { + return buildGitHubWorkspaceSource(item) +} + +export function buildGitLabLinkedWorkItem(item: { + type: 'issue' | 'mr' + number: number + title: string + url: string + repoId: string +}): MobileLinkedWorkItem { + return buildGitLabWorkspaceSource(item) +} + +export function buildLinearLinkedWorkItem(issue: { + identifier: string + title: string + url: string + workspaceId?: string +}): MobileLinkedWorkItem { + return buildLinearWorkspaceSource(issue) +} + +// Faithful port of desktop applyLinkedWorkItem's name gate: the derived name +// replaces the current field only when it's empty, still the last auto-name, or +// a lookup query — never a name the user deliberately typed. +export function shouldApplyAutoName(args: { currentName: string; lastAutoName: string }): boolean { + return shouldApplyWorkspaceSourceAutoName(args) +} + +export function resolveWorkItemAutoName(item: { + type: 'issue' | 'pr' | 'mr' + number: number + title: string + provider: 'github' | 'gitlab' | 'linear' + linearIdentifier?: string +}): string { + return getWorkspaceSourceName({ ...item, url: '' }).seedName +} + +export function resolveLinearAutoName(issue: { identifier: string; title: string }): string { + return getLinearIssueWorkspaceName(issue) +} + +// Derives the pill descriptor from the linked item (or a plain branch base), +// mirroring desktop's smartNameSelection memo. +export function buildSmartNameSelection(args: { + linkedWorkItem: MobileLinkedWorkItem | null + baseBranch: string | undefined +}): SmartNameSelection | null { + return buildWorkspaceSourceSelection(args) as SmartNameSelection | null +} + +// Derives the create-time selection from composer state: a linked work item wins +// (carrying its resolved base/push fields), else a picked branch, else null (a +// name-only/blank create). +export function resolveComposerCreateSelection(args: { + linkedWorkItem: MobileLinkedWorkItem | null + base: { + baseBranch?: string + compareBaseRef?: string + pushTarget?: WorkspaceCreateGitPushTarget + branchNameOverride?: string + } + branch: { refName: string; localBranchName: string } | null + reuseEligibleBranch: string | null + reuseSelectedBranch: boolean + branchCreateIntent: boolean + name: string +}): MobileComposerCreateSelection | null { + const { linkedWorkItem, base, branch, reuseEligibleBranch, reuseSelectedBranch } = args + if (linkedWorkItem) { + return { + kind: 'work-item', + item: linkedWorkItem, + baseBranch: base.baseBranch, + compareBaseRef: base.compareBaseRef, + pushTarget: base.pushTarget, + branchNameOverride: base.branchNameOverride + } + } + if (branch && base.baseBranch) { + return { + kind: 'branch', + baseBranch: base.baseBranch, + refName: branch.refName, + localBranchName: branch.localBranchName, + reuse: reuseSelectedBranch && reuseEligibleBranch === branch.localBranchName, + branchNameOverride: base.branchNameOverride + } + } + if (args.branchCreateIntent && args.name.trim()) { + return { kind: 'new-branch', branchName: args.name.trim() } + } + return null +} + +export type ComposerBranchPick = { + base: { baseBranch: string; branchNameOverride?: string } + reuseEligibleBranch: string | null + reuseSelectedBranch: boolean + name?: string + lastAutoName?: string +} + +// Pure port of desktop handleSmartBranchSelect's derivation: base + reuse +// eligibility/default + the auto-name to apply, from the shared branch helpers. +export function resolveComposerBranchPick(args: { + refName: string + localBranchName: string + currentName: string + lastAutoName: string + worktreeBranches: readonly string[] +}): ComposerBranchPick { + const selection = resolveSharedComposerBranchPick(args) + return { + base: { + baseBranch: selection.baseBranch, + branchNameOverride: selection.branchNameOverride + }, + reuseEligibleBranch: selection.reuseEligibleBranch, + reuseSelectedBranch: selection.defaultReuse, + ...(selection.name !== undefined && selection.lastAutoName !== undefined + ? { name: selection.name, lastAutoName: selection.lastAutoName } + : {}) + } +} + +export type { GitHubWorkItem, GitLabWorkItem, LinearIssue } diff --git a/mobile/src/tasks/composer-source-base-resolve.ts b/mobile/src/tasks/composer-source-base-resolve.ts new file mode 100644 index 00000000000..73c7eaa1839 --- /dev/null +++ b/mobile/src/tasks/composer-source-base-resolve.ts @@ -0,0 +1,76 @@ +import type { RpcClient } from '../transport/rpc-client' +import type { RpcSuccess } from '../transport/types' +import type { GitHubPrStartPoint } from '../../../src/shared/types' + +// The resolved start point for a linked PR/MR: the base branch to create from +// plus the optional review-compare ref, push target, and exact branch name. +export type ComposerHostedBase = Pick< + GitHubPrStartPoint, + 'baseBranch' | 'compareBaseRef' | 'pushTarget' | 'branchNameOverride' | 'maintainerCanModify' +> + +type HostedBaseResult = ComposerHostedBase | { error: string } + +// Resolves a GitHub PR's base via worktree.resolvePrBase, mirroring desktop's +// select-time resolution. The runtime returns a soft { error } payload rather +// than an RPC error for provider failures. +export async function resolveComposerPrBase(args: { + client: RpcClient + repoId: string + prNumber: number + headRefName?: string + baseRefName?: string + isCrossRepository?: boolean +}): Promise { + const { client, repoId, prNumber, headRefName, baseRefName, isCrossRepository } = args + const response = await client.sendRequest( + 'worktree.resolvePrBase', + { + repo: `id:${repoId}`, + prNumber, + ...(headRefName ? { headRefName } : {}), + ...(baseRefName ? { baseRefName } : {}), + ...(isCrossRepository !== undefined ? { isCrossRepository } : {}) + }, + { timeoutMs: 30_000 } + ) + if (!response.ok) { + throw new Error(response.error.message) + } + const result = (response as RpcSuccess).result as GitHubPrStartPoint | { error: string } + if ('error' in result) { + throw new Error(result.error) + } + return result +} + +// Resolves a GitLab MR's base via worktree.resolveMrBase. +export async function resolveComposerMrBase(args: { + client: RpcClient + repoId: string + mrIid: number + sourceBranch?: string + targetBranch?: string + isCrossRepository?: boolean +}): Promise { + const { client, repoId, mrIid, sourceBranch, targetBranch, isCrossRepository } = args + const response = await client.sendRequest( + 'worktree.resolveMrBase', + { + repo: `id:${repoId}`, + mrIid, + ...(sourceBranch ? { sourceBranch } : {}), + ...(targetBranch ? { targetBranch } : {}), + ...(isCrossRepository !== undefined ? { isCrossRepository } : {}) + }, + { timeoutMs: 30_000 } + ) + if (!response.ok) { + throw new Error(response.error.message) + } + const result = (response as RpcSuccess).result as HostedBaseResult + if ('error' in result) { + throw new Error(result.error) + } + return result +} diff --git a/mobile/src/tasks/mobile-composer-source-types.ts b/mobile/src/tasks/mobile-composer-source-types.ts new file mode 100644 index 00000000000..d36215189af --- /dev/null +++ b/mobile/src/tasks/mobile-composer-source-types.ts @@ -0,0 +1,64 @@ +import type { SmartNameMode } from '../../../src/shared/new-workspace/smart-workspace-source-results' +import type { + WorkspaceSourceLinkedItem, + WorkspaceSourceSelection +} from '../../../src/shared/new-workspace/workspace-source' +import type { WorkspaceCreateGitPushTarget } from './workspace-create-params' + +export type { SmartNameMode } + +export type ComposerBaseState = { + baseBranch?: string + compareBaseRef?: string + pushTarget?: WorkspaceCreateGitPushTarget + branchNameOverride?: string +} + +// Mirrors the desktop composer's `linkedWorkItem` (a FolderWorkspaceLinkedTask +// superset): the one work item a Smart selection pins the workspace to. Linear +// items carry the workspace/org routing the runtime needs to relink the issue. +export type MobileLinkedWorkItem = Omit & { + provider: Exclude +} + +export type SmartNameSelectionKind = + | 'github-pr' + | 'github-issue' + | 'gitlab-mr' + | 'gitlab-issue' + | 'branch' + | 'linear' + +// The pill descriptor the field renders once a source is selected. Same shape +// as desktop's `SmartWorkspaceNameSelection`. +export type SmartNameSelection = Omit & { + kind: SmartNameSelectionKind +} + +// GitLab MR-state filter chips, mirroring desktop's getMrStateFilters(). Default +// is 'opened' (Open). +export type MrStateFilter = 'opened' | 'merged' | 'closed' | 'all' + +// The resolved selection the create flow consumes. Work-item selections carry +// the base/push fields the composer resolved at select time; branch selections +// carry the ref + reuse intent. +export type MobileComposerCreateSelection = + | { + kind: 'work-item' + item: MobileLinkedWorkItem + baseBranch?: string + compareBaseRef?: string + pushTarget?: WorkspaceCreateGitPushTarget + branchNameOverride?: string + } + | { + kind: 'branch' + baseBranch: string + refName: string + localBranchName: string + reuse: boolean + branchNameOverride?: string + } + // A brand-new branch created by name (no ref picked); the branch is created off + // the repo's default base and the typed name is kept verbatim as the branch. + | { kind: 'new-branch'; branchName: string } diff --git a/mobile/src/tasks/mobile-smart-source-modes.test.ts b/mobile/src/tasks/mobile-smart-source-modes.test.ts new file mode 100644 index 00000000000..dc726569095 --- /dev/null +++ b/mobile/src/tasks/mobile-smart-source-modes.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import { + DEFAULT_MR_STATE_FILTER, + MR_STATE_FILTER_OPTIONS, + normalizeSmartMode, + resolveAvailableSmartModes, + resolveDefaultSmartMode +} from './mobile-smart-source-modes' + +const fullyAvailable = { + textOnly: false, + tasksSupported: true, + hasRepo: true, + githubAvailable: true, + gitlabAvailable: true, + linearAvailable: true +} + +describe('resolveAvailableSmartModes', () => { + it('lists every mode in desktop order when all are available', () => { + expect(resolveAvailableSmartModes(fullyAvailable)).toEqual([ + 'smart', + 'github', + 'linear', + 'gitlab', + 'branches', + 'text' + ]) + }) + + it('collapses to Name for a non-git (text-only) repo', () => { + expect(resolveAvailableSmartModes({ ...fullyAvailable, textOnly: true })).toEqual(['text']) + }) + + it('drops provider + smart modes when the tasks RPC surface is missing', () => { + expect(resolveAvailableSmartModes({ ...fullyAvailable, tasksSupported: false })).toEqual([ + 'branches', + 'text' + ]) + }) + + it('gates provider tabs on their availability and a selected repo', () => { + expect( + resolveAvailableSmartModes({ + ...fullyAvailable, + githubAvailable: false, + gitlabAvailable: false + }) + ).toEqual(['smart', 'linear', 'branches', 'text']) + expect(resolveAvailableSmartModes({ ...fullyAvailable, hasRepo: false })).toEqual([ + 'smart', + 'linear', + 'text' + ]) + }) +}) + +describe('resolveDefaultSmartMode', () => { + it('defaults to smart for a git repo with search', () => { + expect(resolveDefaultSmartMode(fullyAvailable)).toBe('smart') + }) + + it('defaults to text for a non-git repo', () => { + expect(resolveDefaultSmartMode({ ...fullyAvailable, textOnly: true })).toBe('text') + }) + + it('defaults to branches for a git repo without the tasks surface', () => { + expect(resolveDefaultSmartMode({ ...fullyAvailable, tasksSupported: false })).toBe('branches') + }) +}) + +describe('normalizeSmartMode', () => { + it('keeps a valid mode and snaps an unavailable one back to default', () => { + expect(normalizeSmartMode('gitlab', fullyAvailable)).toBe('gitlab') + expect(normalizeSmartMode('gitlab', { ...fullyAvailable, gitlabAvailable: false })).toBe( + 'smart' + ) + }) +}) + +describe('MR state filters', () => { + it('exposes Open/Merged/Closed/All with an Open default', () => { + expect(MR_STATE_FILTER_OPTIONS.map((o) => o.id)).toEqual(['opened', 'merged', 'closed', 'all']) + expect(DEFAULT_MR_STATE_FILTER).toBe('opened') + }) +}) diff --git a/mobile/src/tasks/mobile-smart-source-modes.ts b/mobile/src/tasks/mobile-smart-source-modes.ts new file mode 100644 index 00000000000..911219d28c4 --- /dev/null +++ b/mobile/src/tasks/mobile-smart-source-modes.ts @@ -0,0 +1,93 @@ +import type { MrStateFilter, SmartNameMode } from './mobile-composer-source-types' + +// Icon each tab renders: lucide glyphs for the neutral modes, the inline brand +// SVGs (TaskProviderLogo) for the provider modes since lucide dropped its brand +// icons. +export type SmartModeIcon = + | { type: 'lucide'; name: 'sparkles' | 'git-branch' | 'case-sensitive' } + | { type: 'provider'; provider: 'github' | 'gitlab' | 'linear' } + +export type SmartModeOption = { + id: SmartNameMode + label: string + icon: SmartModeIcon +} + +// Order + labels + icons mirror desktop getSmartWorkspaceNameModes(): +// Smart · GitHub · Linear · GitLab · Branch · Name. +export const SMART_MODE_OPTIONS: readonly SmartModeOption[] = [ + { id: 'smart', label: 'Smart', icon: { type: 'lucide', name: 'sparkles' } }, + { id: 'github', label: 'GitHub', icon: { type: 'provider', provider: 'github' } }, + { id: 'linear', label: 'Linear', icon: { type: 'provider', provider: 'linear' } }, + { id: 'gitlab', label: 'GitLab', icon: { type: 'provider', provider: 'gitlab' } }, + { id: 'branches', label: 'Branch', icon: { type: 'lucide', name: 'git-branch' } }, + { id: 'text', label: 'Name', icon: { type: 'lucide', name: 'case-sensitive' } } +] + +export type SmartModeAvailabilityInput = { + textOnly: boolean + tasksSupported: boolean + hasRepo: boolean + githubAvailable: boolean + gitlabAvailable: boolean + linearAvailable: boolean +} + +// Faithful port of the desktop availableModes filter. Non-git repos collapse to +// the Name tab; provider tabs gate on availability + a selected repo + the tasks +// RPC surface; branches only need a git repo (new-branch-by-name works without +// the search capability). +export function resolveAvailableSmartModes(input: SmartModeAvailabilityInput): SmartNameMode[] { + if (input.textOnly) { + return ['text'] + } + return SMART_MODE_OPTIONS.filter((option) => { + switch (option.id) { + case 'smart': + return input.tasksSupported + case 'github': + return input.tasksSupported && input.hasRepo && input.githubAvailable + case 'gitlab': + return input.tasksSupported && input.hasRepo && input.gitlabAvailable + case 'linear': + return input.tasksSupported && input.linearAvailable + case 'branches': + return input.hasRepo + case 'text': + return true + } + }).map((option) => option.id) +} + +// Default mode when the picker opens: 'smart' for a git repo when search is +// available, else the first available mode (branches for git without tasks, +// 'text' for non-git). +export function resolveDefaultSmartMode(input: SmartModeAvailabilityInput): SmartNameMode { + const available = resolveAvailableSmartModes(input) + if (available.includes('smart')) { + return 'smart' + } + return available[0] ?? 'text' +} + +// Keeps a chosen mode valid as availability changes (e.g. the repo switches to a +// non-git folder), mirroring desktop's snap-to-available effect. +export function normalizeSmartMode( + mode: SmartNameMode, + input: SmartModeAvailabilityInput +): SmartNameMode { + const available = resolveAvailableSmartModes(input) + return available.includes(mode) ? mode : resolveDefaultSmartMode(input) +} + +export type MrStateFilterOption = { id: MrStateFilter; label: string } + +// Desktop getMrStateFilters(): Open · Merged · Closed · All, default 'opened'. +export const MR_STATE_FILTER_OPTIONS: readonly MrStateFilterOption[] = [ + { id: 'opened', label: 'Open' }, + { id: 'merged', label: 'Merged' }, + { id: 'closed', label: 'Closed' }, + { id: 'all', label: 'All' } +] + +export const DEFAULT_MR_STATE_FILTER: MrStateFilter = 'opened' diff --git a/mobile/src/tasks/mobile-tasks-capability.ts b/mobile/src/tasks/mobile-tasks-capability.ts new file mode 100644 index 00000000000..f8498bfd968 --- /dev/null +++ b/mobile/src/tasks/mobile-tasks-capability.ts @@ -0,0 +1,5 @@ +// Runtime capability the desktop advertises when it supports the mobile Tasks RPC +// surface (github/gitlab/linear work items + repo.searchRefs). Older paired +// desktops omit it, so mobile must degrade to blank/new-branch sources only. +// Mirrors the 'mobile.tasks.v1' entry in src/shared/protocol-version.ts. +export const MOBILE_TASKS_CAPABILITY = 'mobile.tasks.v1' diff --git a/mobile/src/tasks/setup-hook-trust.test.ts b/mobile/src/tasks/setup-hook-trust.test.ts index b09bdd0d4b3..0e2d91bb7ac 100644 --- a/mobile/src/tasks/setup-hook-trust.test.ts +++ b/mobile/src/tasks/setup-hook-trust.test.ts @@ -2,10 +2,12 @@ import { describe, expect, it } from 'vitest' import { isSetupHookTrusted, normalizeSetupHookTrust, + persistSetupHookTrustApproval, trustedOrcaHooksWithSetupApproval, wasSetupHookPreviouslyApproved } from './setup-hook-trust' import type { PersistedTrustedOrcaHooks } from '../../../src/shared/types' +import type { RpcClient } from '../transport/rpc-client' describe('setup hook trust', () => { it('trusts a setup script only when the approved hash matches', () => { @@ -71,6 +73,27 @@ describe('setup hook trust', () => { }) }) + it('persists and returns the approved trust state', async () => { + let persisted: unknown + const client = { + sendRequest: async (_method: string, params: unknown) => { + persisted = params + return { ok: true, result: null } + } + } as unknown as RpcClient + + const next = await persistSetupHookTrustApproval({ + client, + trust: {}, + repoId: 'repo-1', + contentHash: 'setup-hash', + alwaysTrust: false + }) + + expect(persisted).toEqual({ trustedOrcaHooks: next }) + expect(isSetupHookTrusted(next, 'repo-1', 'setup-hash')).toBe(true) + }) + it('detects previous setup approval and ignores incomplete trust payloads', () => { expect( wasSetupHookPreviouslyApproved( diff --git a/mobile/src/tasks/setup-hook-trust.ts b/mobile/src/tasks/setup-hook-trust.ts index 847420f1e86..04c52152f45 100644 --- a/mobile/src/tasks/setup-hook-trust.ts +++ b/mobile/src/tasks/setup-hook-trust.ts @@ -1,4 +1,5 @@ import type { PersistedTrustedOrcaHooks } from '../../../src/shared/types' +import type { RpcClient } from '../transport/rpc-client' export type SetupHookTrust = { contentHash: string @@ -36,6 +37,21 @@ export function trustedOrcaHooksWithSetupApproval(args: { return { ...args.trust, [args.repoId]: nextRepo } } +export async function persistSetupHookTrustApproval(args: { + client: RpcClient + trust: PersistedTrustedOrcaHooks + repoId: string + contentHash: string + alwaysTrust: boolean +}): Promise { + const next = trustedOrcaHooksWithSetupApproval(args) + const response = await args.client.sendRequest('ui.set', { trustedOrcaHooks: next }) + if (!response.ok) { + throw new Error(response.error.message) + } + return next +} + export function normalizeSetupHookTrust( setupTrust: SetupHookTrust | null | undefined ): SetupHookTrust | null { diff --git a/mobile/src/tasks/smart-source-fan-out.test.ts b/mobile/src/tasks/smart-source-fan-out.test.ts new file mode 100644 index 00000000000..cffdc287e22 --- /dev/null +++ b/mobile/src/tasks/smart-source-fan-out.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { fanOutSmartSearch } from './smart-source-fan-out' + +type Call = { method: string; params: Record } + +function fakeClient(byMethod: Record, calls: Call[]): RpcClient { + return { + sendRequest: async (method: string, params?: unknown) => { + calls.push({ method, params: (params ?? {}) as Record }) + const result = byMethod[method] + if (result instanceof Error) { + return { + id: '1', + ok: false, + error: { code: 'x', message: result.message }, + _meta: { runtimeId: 'r' } + } + } + return { id: '1', ok: true, result: result ?? { items: [] }, _meta: { runtimeId: 'r' } } + } + } as unknown as RpcClient +} + +const smartArgs = { + mode: 'smart' as const, + query: 'bug', + repoId: 'repo-1', + githubAvailable: true, + gitlabAvailable: true, + linearAvailable: true, + mrStateFilter: 'opened' as const, + linearWorkspaceId: null +} + +describe('fanOutSmartSearch', () => { + it('fans out to every provider in smart mode and stamps repoId', async () => { + const calls: Call[] = [] + const client = fakeClient( + { + 'github.listWorkItems': { items: [{ id: 'g1', type: 'issue', number: 1, title: 'A' }] }, + 'gitlab.listWorkItems': { items: [{ id: 'gl1', type: 'mr', number: 2, title: 'B' }] }, + 'linear.searchIssues': { items: [{ id: 'l1', identifier: 'ENG-1', title: 'C' }] }, + 'repo.searchRefs': { refDetails: [{ refName: 'main', localBranchName: 'main' }] } + }, + calls + ) + const result = await fanOutSmartSearch({ client, ...smartArgs }) + expect(calls.map((c) => c.method).sort()).toEqual([ + 'github.listWorkItems', + 'gitlab.listWorkItems', + 'linear.searchIssues', + 'repo.searchRefs' + ]) + expect(result.githubItems[0]).toMatchObject({ number: 1, repoId: 'repo-1' }) + expect(result.gitlabItems[0]).toMatchObject({ number: 2, repoId: 'repo-1' }) + expect(result.linearIssues[0]).toMatchObject({ identifier: 'ENG-1' }) + expect(result.branches).toEqual([{ refName: 'main', localBranchName: 'main' }]) + expect(result.error).toBe('') + }) + + it('swallows a single provider failure in smart mode (best-effort)', async () => { + const calls: Call[] = [] + const client = fakeClient( + { + 'github.listWorkItems': new Error('gh down'), + 'gitlab.listWorkItems': { items: [{ id: 'gl1', type: 'mr', number: 2, title: 'B' }] }, + 'linear.searchIssues': { items: [] }, + 'repo.searchRefs': { refDetails: [] } + }, + calls + ) + const result = await fanOutSmartSearch({ client, ...smartArgs }) + expect(result.error).toBe('') + expect(result.gitlabItems).toHaveLength(1) + }) + + it('surfaces the error for a single-provider mode', async () => { + const calls: Call[] = [] + const client = fakeClient({ 'gitlab.listWorkItems': new Error('gl boom') }, calls) + const result = await fanOutSmartSearch({ ...smartArgs, mode: 'gitlab', client }) + expect(calls.map((c) => c.method)).toEqual(['gitlab.listWorkItems']) + expect(result.error).toBe('gl boom') + }) + + it('only searches branches in smart mode when the query is non-empty', async () => { + const calls: Call[] = [] + const client = fakeClient({}, calls) + await fanOutSmartSearch({ ...smartArgs, query: '', client }) + expect(calls.map((c) => c.method)).not.toContain('repo.searchRefs') + }) + + it('skips GitHub in smart mode when GitHub is unavailable', async () => { + const calls: Call[] = [] + const client = fakeClient({}, calls) + await fanOutSmartSearch({ ...smartArgs, githubAvailable: false, client }) + expect(calls.map((c) => c.method)).not.toContain('github.listWorkItems') + }) + + it('does not send oversized source queries to any provider', async () => { + const calls: Call[] = [] + const client = fakeClient({}, calls) + const result = await fanOutSmartSearch({ ...smartArgs, query: 'x'.repeat(2049), client }) + expect(calls).toEqual([]) + expect(result).toMatchObject({ + githubItems: [], + gitlabItems: [], + linearIssues: [], + branches: [] + }) + }) +}) diff --git a/mobile/src/tasks/smart-source-fan-out.ts b/mobile/src/tasks/smart-source-fan-out.ts new file mode 100644 index 00000000000..e2217597ab9 --- /dev/null +++ b/mobile/src/tasks/smart-source-fan-out.ts @@ -0,0 +1,141 @@ +import type { + BaseRefSearchResult, + GitHubWorkItem, + GitLabWorkItem, + LinearIssue +} from '../../../src/shared/types' +import { + isSmartWorkspaceSourceQueryWithinLimit, + type SmartNameMode +} from '../../../src/shared/new-workspace/smart-workspace-source-results' +import type { RpcClient } from '../transport/rpc-client' +import { isGitHubWorkItemsSshRemoteRequiredError } from './mobile-work-items' +import type { MrStateFilter } from './mobile-composer-source-types' +import { + searchBranches, + searchGitHubItems, + searchGitLabItems, + searchLinearIssues +} from './smart-source-search-requests' + +export type SmartFanOutResult = { + githubItems: GitHubWorkItem[] + gitlabItems: GitLabWorkItem[] + linearIssues: LinearIssue[] + branches: BaseRefSearchResult[] + needsGitHubRemote: boolean + error: string +} + +const EMPTY: Omit = { + githubItems: [], + gitlabItems: [], + linearIssues: [], + branches: [] +} + +function shouldSearchGitHub(mode: SmartNameMode, githubAvailable: boolean): boolean { + return githubAvailable && (mode === 'smart' || mode === 'github') +} + +function shouldSearchGitLab(mode: SmartNameMode, gitlabAvailable: boolean): boolean { + return gitlabAvailable && (mode === 'smart' || mode === 'gitlab') +} + +function shouldSearchLinear(mode: SmartNameMode, linearAvailable: boolean): boolean { + return linearAvailable && (mode === 'smart' || mode === 'linear') +} + +function shouldSearchBranches(mode: SmartNameMode, query: string): boolean { + return mode === 'branches' || (mode === 'smart' && query.trim().length > 0) +} + +type FanOutArgs = { + client: RpcClient + mode: SmartNameMode + query: string + repoId: string | null + githubAvailable: boolean + gitlabAvailable: boolean + linearAvailable: boolean + mrStateFilter: MrStateFilter + linearWorkspaceId: string | null | undefined +} + +// Runs every provider search the active mode needs, concurrently. Smart mode is +// best-effort (a single provider failure never blocks the others); single-provider +// modes surface the failure. No cross-provider ranking/dedup — the shared row +// builder concatenates in provider order. +export async function fanOutSmartSearch(args: FanOutArgs): Promise { + if (!isSmartWorkspaceSourceQueryWithinLimit(args.query)) { + // Why: the source limit is an outbound-request boundary, not only a render + // limit; pasted payloads must never fan out to provider CLIs or SSH hosts. + return { ...EMPTY, needsGitHubRemote: false, error: '' } + } + const { + client, + mode, + query, + repoId, + githubAvailable, + gitlabAvailable, + linearAvailable, + mrStateFilter + } = args + const isSmart = mode === 'smart' + const tasks = { + github: + shouldSearchGitHub(mode, githubAvailable) && repoId + ? searchGitHubItems(client, repoId, query) + : null, + gitlab: + shouldSearchGitLab(mode, gitlabAvailable) && repoId + ? searchGitLabItems(client, repoId, query, mrStateFilter) + : null, + linear: shouldSearchLinear(mode, linearAvailable) + ? searchLinearIssues(client, query, args.linearWorkspaceId) + : null, + branches: + shouldSearchBranches(mode, query) && repoId ? searchBranches(client, repoId, query) : null + } + const [github, gitlab, linear, branches] = await Promise.allSettled([ + tasks.github ?? Promise.resolve([]), + tasks.gitlab ?? Promise.resolve([]), + tasks.linear ?? Promise.resolve([]), + tasks.branches ?? Promise.resolve([]) + ]) + + let needsGitHubRemote = false + let error = '' + const fail = (reason: unknown) => { + if (!isSmart) { + error = reason instanceof Error ? reason.message : 'Search failed' + } + } + if (github.status === 'rejected') { + if (isGitHubWorkItemsSshRemoteRequiredError(github.reason)) { + needsGitHubRemote = true + } else { + fail(github.reason) + } + } + if (gitlab.status === 'rejected') { + fail(gitlab.reason) + } + if (linear.status === 'rejected') { + fail(linear.reason) + } + if (branches.status === 'rejected') { + fail(branches.reason) + } + + return { + ...EMPTY, + githubItems: github.status === 'fulfilled' ? github.value : [], + gitlabItems: gitlab.status === 'fulfilled' ? gitlab.value : [], + linearIssues: linear.status === 'fulfilled' ? linear.value : [], + branches: branches.status === 'fulfilled' ? branches.value : [], + needsGitHubRemote, + error + } +} diff --git a/mobile/src/tasks/smart-source-paste-intent.test.ts b/mobile/src/tasks/smart-source-paste-intent.test.ts new file mode 100644 index 00000000000..f7c724e7637 --- /dev/null +++ b/mobile/src/tasks/smart-source-paste-intent.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest' +import { + deriveRepoSlug, + findRepoMatchingSlug, + findRepoMatchingSlugForPaste, + resolvePasteIntent, + type PasteRepoCandidate +} from './smart-source-paste-intent' +import type { RpcClient } from '../transport/rpc-client' + +describe('resolvePasteIntent', () => { + it('classifies a GitHub issue/PR URL as a github-link', () => { + expect(resolvePasteIntent('https://github.com/acme/widgets/pull/12')).toEqual({ + kind: 'github-link', + link: { slug: { owner: 'acme', repo: 'widgets' }, number: 12, type: 'pr' } + }) + }) + + it('classifies a bare #number as a github-number', () => { + expect(resolvePasteIntent('#42')).toEqual({ kind: 'github-number', number: 42 }) + }) + + it('classifies a GitLab MR URL as a gitlab-link', () => { + const intent = resolvePasteIntent('https://gitlab.com/group/proj/-/merge_requests/8') + expect(intent?.kind).toBe('gitlab-link') + if (intent?.kind === 'gitlab-link') { + expect(intent.link).toMatchObject({ number: 8, type: 'mr' }) + } + }) + + it('returns null for plain search text', () => { + expect(resolvePasteIntent('login bug')).toBeNull() + }) + + it('rejects oversized paste intents before any exact lookup', () => { + expect( + resolvePasteIntent(`https://github.com/acme/widgets/issues/12/${'x'.repeat(2048)}`) + ).toBeNull() + }) +}) + +describe('deriveRepoSlug', () => { + it('prefers the upstream identity', () => { + expect(deriveRepoSlug({ upstream: { owner: 'up', repo: 'stream' } })).toEqual({ + owner: 'up', + repo: 'stream' + }) + }) + + it('parses an SSH remote URL', () => { + expect( + deriveRepoSlug({ gitRemoteIdentity: { remoteUrl: 'git@github.com:acme/widgets.git' } }) + ).toEqual({ owner: 'acme', repo: 'widgets' }) + }) + + it('parses an HTTPS remote URL', () => { + expect( + deriveRepoSlug({ gitRemoteIdentity: { remoteUrl: 'https://github.com/acme/widgets' } }) + ).toEqual({ owner: 'acme', repo: 'widgets' }) + }) + + it('returns null when no slug can be derived', () => { + expect(deriveRepoSlug({})).toBeNull() + }) +}) + +describe('findRepoMatchingSlug', () => { + const repos: PasteRepoCandidate[] = [ + { id: 'a', displayName: 'A', slug: { owner: 'acme', repo: 'widgets' } }, + { id: 'b', displayName: 'B', slug: null } + ] + + it('matches case-insensitively', () => { + expect(findRepoMatchingSlug(repos, { owner: 'Acme', repo: 'Widgets' })?.id).toBe('a') + }) + + it('returns null when no repo matches', () => { + expect(findRepoMatchingSlug(repos, { owner: 'other', repo: 'thing' })).toBeNull() + }) + + it('falls back to the host-aware repo slug RPC for SSH and enterprise repos', async () => { + const calls: string[] = [] + const client = { + sendRequest: async (_method: string, params: unknown) => { + const repo = (params as { repo: string }).repo + calls.push(repo) + return { + ok: true, + result: repo === 'id:b' ? { owner: 'enterprise', repo: 'widgets' } : null + } + } + } as unknown as RpcClient + await expect( + findRepoMatchingSlugForPaste( + client, + repos, + { owner: 'enterprise', repo: 'widgets' }, + new Map() + ) + ).resolves.toMatchObject({ id: 'b' }) + expect(calls).toEqual(['id:a', 'id:b']) + }) + + it('keeps local matching usable when an older desktop lacks the repo slug RPC', async () => { + let calls = 0 + const client = { + sendRequest: async () => { + calls += 1 + return { + ok: false, + error: { code: 'method_not_found', message: 'Unknown method: github.repoSlug' } + } + } + } as unknown as RpcClient + const cache = new Map() + + await expect( + findRepoMatchingSlugForPaste(client, repos, { owner: 'enterprise', repo: 'widgets' }, cache) + ).resolves.toBeNull() + await expect( + findRepoMatchingSlugForPaste(client, repos, { owner: 'enterprise', repo: 'other' }, cache) + ).resolves.toBeNull() + expect(calls).toBe(1) + }) + + it('keeps local matching usable when the optional repo slug lookup rejects', async () => { + const client = { + sendRequest: async () => { + throw new Error('connection closed') + } + } as unknown as RpcClient + + await expect( + findRepoMatchingSlugForPaste( + client, + repos, + { owner: 'enterprise', repo: 'widgets' }, + new Map() + ) + ).resolves.toBeNull() + }) +}) diff --git a/mobile/src/tasks/smart-source-paste-intent.ts b/mobile/src/tasks/smart-source-paste-intent.ts new file mode 100644 index 00000000000..72c23a3737f --- /dev/null +++ b/mobile/src/tasks/smart-source-paste-intent.ts @@ -0,0 +1,178 @@ +import type { GitHubWorkItem, GitLabWorkItem } from '../../../src/shared/types' +import { + normalizeGitHubLinkQuery, + parseGitHubIssueOrPRLink, + type GitHubIssueOrPRLink, + type RepoSlug +} from '../../../src/shared/new-workspace/github-links' +import { parseGitLabIssueOrMRLink } from '../../../src/shared/new-workspace/gitlab-links' +import { isSmartWorkspaceSourceQueryWithinLimit } from '../../../src/shared/new-workspace/smart-workspace-source-results' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcSuccess } from '../transport/types' + +// A repo the picker can switch to for a cross-repo GitHub paste. Slug is derived +// best-effort from the repo's remote metadata. +export type PasteRepoCandidate = { + id: string + displayName: string + slug: RepoSlug | null +} + +export type GitHubPasteIntent = + | { kind: 'github-link'; link: GitHubIssueOrPRLink } + | { kind: 'github-number'; number: number } + +export type GitLabPasteIntent = { + kind: 'gitlab-link' + link: NonNullable> +} + +export type PasteIntent = GitHubPasteIntent | GitLabPasteIntent | null + +// Pure: classify pasted text into a work-item lookup intent. A slug-bearing +// GitHub URL becomes 'github-link'; a bare "#123"/number becomes 'github-number'; +// a GitLab issue/MR URL becomes 'gitlab-link'. +export function resolvePasteIntent(query: string): PasteIntent { + if (!isSmartWorkspaceSourceQueryWithinLimit(query)) { + return null + } + const trimmed = query.trim() + if (!trimmed) { + return null + } + const ghLink = parseGitHubIssueOrPRLink(trimmed) + if (ghLink) { + return { kind: 'github-link', link: ghLink } + } + const normalizedGh = normalizeGitHubLinkQuery(trimmed) + if (normalizedGh.directNumber !== null && !/^https?:\/\//i.test(trimmed)) { + return { kind: 'github-number', number: normalizedGh.directNumber } + } + const glLink = parseGitLabIssueOrMRLink(trimmed) + if (glLink) { + return { kind: 'gitlab-link', link: glLink } + } + return null +} + +// Pure: derive an owner/repo slug from a repo's remote metadata so a pasted +// cross-repo URL can be matched to a locally known repo. +export function deriveRepoSlug(repo: { + upstream?: { owner: string; repo: string } | null + gitRemoteIdentity?: { remoteUrl?: string; canonicalKey?: string } | null +}): RepoSlug | null { + if (repo.upstream?.owner && repo.upstream.repo) { + return { owner: repo.upstream.owner, repo: repo.upstream.repo } + } + const source = repo.gitRemoteIdentity?.remoteUrl ?? repo.gitRemoteIdentity?.canonicalKey ?? '' + const match = /(?:github\.com[/:]|^)([^/\s:]+)\/([^/\s]+?)(?:\.git)?$/i.exec(source) + if (match) { + return { owner: match[1], repo: match[2] } + } + return null +} + +function slugsEqual(a: RepoSlug | null, b: RepoSlug | null): boolean { + if (!a || !b) { + return false + } + return ( + a.owner.toLowerCase() === b.owner.toLowerCase() && a.repo.toLowerCase() === b.repo.toLowerCase() + ) +} + +export function findRepoMatchingSlug( + repos: readonly PasteRepoCandidate[], + slug: RepoSlug +): PasteRepoCandidate | null { + return repos.find((repo) => slugsEqual(repo.slug, slug)) ?? null +} + +export async function findRepoMatchingSlugForPaste( + client: RpcClient, + repos: readonly PasteRepoCandidate[], + slug: RepoSlug, + cache: Map +): Promise { + const projected = findRepoMatchingSlug(repos, slug) + if (projected) { + return projected + } + // Why: projected remote metadata is incomplete for SSH and GitHub Enterprise; + // ask each repo's owning runtime instead of assuming github.com URL syntax. + for (const repo of repos) { + let resolved = cache.get(repo.id) + if (!cache.has(repo.id)) { + try { + const response = await client.sendRequest('github.repoSlug', { repo: `id:${repo.id}` }) + if (!response.ok && response.error.code === 'method_not_found') { + // Why: RPC availability is host-wide; avoid repeating an unsupported + // probe for every repo or on the next paste attempt. + repos.forEach((candidate) => cache.set(candidate.id, null)) + return null + } + resolved = response.ok ? ((response as RpcSuccess).result as RepoSlug | null) : null + } catch { + resolved = null + } + cache.set(repo.id, resolved ?? null) + } + if (slugsEqual(resolved ?? null, slug)) { + return repo + } + } + return null +} + +export async function lookupGitHubItemByNumber( + client: RpcClient, + repoId: string, + number: number +): Promise { + const response = await client.sendRequest('github.workItem', { repo: `id:${repoId}`, number }) + if (!response.ok) { + throw new Error(response.error.message) + } + const item = (response as RpcSuccess).result as GitHubWorkItem | null + return item ? { ...item, repoId } : null +} + +export async function lookupGitHubItemByOwnerRepo( + client: RpcClient, + repoId: string, + slug: RepoSlug, + number: number, + type: 'issue' | 'pr' +): Promise { + const response = await client.sendRequest('github.workItemByOwnerRepo', { + repo: `id:${repoId}`, + owner: slug.owner, + ownerRepo: slug.repo, + number, + type + }) + if (!response.ok) { + throw new Error(response.error.message) + } + const item = (response as RpcSuccess).result as GitHubWorkItem | null + return item ? { ...item, repoId } : null +} + +export async function lookupGitLabItemByPath( + client: RpcClient, + repoId: string, + link: NonNullable> +): Promise { + const response = await client.sendRequest('gitlab.workItemByPath', { + repo: `id:${repoId}`, + host: link.slug.host, + path: link.slug.path, + iid: link.number, + type: link.type + }) + if (!response.ok) { + throw new Error(response.error.message) + } + const item = (response as RpcSuccess).result as GitLabWorkItem | null + return item ? { ...item, repoId } : null +} diff --git a/mobile/src/tasks/smart-source-search-requests.test.ts b/mobile/src/tasks/smart-source-search-requests.test.ts new file mode 100644 index 00000000000..ecf029feb70 --- /dev/null +++ b/mobile/src/tasks/smart-source-search-requests.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { scopeGitHubQuery, searchLinearIssues } from './smart-source-search-requests' + +type Call = { method: string; params: Record } + +function fakeClient(result: unknown, calls: Call[]): RpcClient { + return { + sendRequest: async (method: string, params?: unknown) => { + calls.push({ method, params: (params ?? {}) as Record }) + return { id: '1', ok: true, result, _meta: { runtimeId: 'r' } } + } + } as unknown as RpcClient +} + +describe('scopeGitHubQuery', () => { + it('passes the raw trimmed query so BOTH issues and PRs are returned', () => { + // Empty stays empty (runtime lists recent issues + PRs); no forced is:issue. + expect(scopeGitHubQuery('')).toBe('') + expect(scopeGitHubQuery(' login bug ')).toBe('login bug') + }) + + it('preserves an explicit is:pr / is:issue scope the user typed', () => { + expect(scopeGitHubQuery('is:pr auth')).toBe('is:pr auth') + expect(scopeGitHubQuery('is:issue auth')).toBe('is:issue auth') + }) +}) + +describe('searchLinearIssues', () => { + it('lists assigned issues for an empty query (desktop default)', async () => { + const calls: Call[] = [] + const client = fakeClient({ items: [] }, calls) + await searchLinearIssues(client, '', null) + expect(calls[0]!.method).toBe('linear.listIssues') + expect(calls[0]!.params).toMatchObject({ filter: 'assigned' }) + }) + + it('searches when a query is present', async () => { + const calls: Call[] = [] + const client = fakeClient({ items: [] }, calls) + await searchLinearIssues(client, 'bug', 'ws-1') + expect(calls[0]!.method).toBe('linear.searchIssues') + expect(calls[0]!.params).toMatchObject({ query: 'bug', workspaceId: 'ws-1' }) + }) +}) diff --git a/mobile/src/tasks/smart-source-search-requests.ts b/mobile/src/tasks/smart-source-search-requests.ts new file mode 100644 index 00000000000..5875cd4d642 --- /dev/null +++ b/mobile/src/tasks/smart-source-search-requests.ts @@ -0,0 +1,119 @@ +import type { + BaseRefSearchResult, + GitHubWorkItem, + GitLabWorkItem, + LinearIssue +} from '../../../src/shared/types' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcSuccess } from '../transport/types' +import { extractLinearIssueReadItems } from './linear-mobile-issue-read' +import { PER_REPO_FETCH_LIMIT } from './mobile-work-items' +import type { MrStateFilter } from './mobile-composer-source-types' + +const GITLAB_PER_PAGE = 50 +const LINEAR_LIMIT = 50 +const BRANCH_LIMIT = 20 + +// Why: the desktop Smart picker returns BOTH issues and PRs — the runtime's +// parseTaskQuery defaults scope 'all', and an empty query lists recent items of +// both types. So pass the raw trimmed query straight through (an explicit +// `is:pr`/`is:issue` the user typed is honored by the runtime); empty stays empty +// so the runtime lists recent issues + PRs. +export function scopeGitHubQuery(query: string): string { + return query.trim() +} + +export async function searchGitHubItems( + client: RpcClient, + repoId: string, + query: string +): Promise { + const response = await client.sendRequest('github.listWorkItems', { + repo: `id:${repoId}`, + limit: PER_REPO_FETCH_LIMIT, + query: scopeGitHubQuery(query) + }) + if (!response.ok) { + throw new Error(response.error.message) + } + const envelope = (response as RpcSuccess).result as { items: GitHubWorkItem[] } + // Stamp repoId so the shared row builder + create flow can attribute each item + // to the searched repo (the runtime omits it, like the desktop fetcher). + return (envelope.items ?? []).map((item) => ({ ...item, repoId })) +} + +export async function searchGitLabItems( + client: RpcClient, + repoId: string, + query: string, + state: MrStateFilter +): Promise { + const response = await client.sendRequest('gitlab.listWorkItems', { + repo: `id:${repoId}`, + state, + page: 1, + perPage: GITLAB_PER_PAGE, + query: query.trim() || undefined + }) + if (!response.ok) { + throw new Error(response.error.message) + } + const envelope = (response as RpcSuccess).result as { + items: GitLabWorkItem[] + error?: { type?: string; message: string } + } + if (envelope.error?.type && envelope.error.type !== 'not_found') { + throw new Error(envelope.error.message) + } + return (envelope.items ?? []).map((item) => ({ ...item, repoId })) +} + +export async function searchLinearIssues( + client: RpcClient, + query: string, + linearWorkspaceId: string | null | undefined +): Promise { + const trimmed = query.trim() + const response = trimmed + ? await client.sendRequest('linear.searchIssues', { + query: trimmed, + limit: LINEAR_LIMIT, + workspaceId: linearWorkspaceId ?? undefined + }) + : await client.sendRequest('linear.listIssues', { + // Empty query lists the viewer's assigned issues, matching desktop's + // Smart picker default (SmartWorkspaceNameField uses listLinearIssues('assigned')). + filter: 'assigned', + limit: LINEAR_LIMIT, + workspaceId: linearWorkspaceId ?? undefined + }) + if (!response.ok) { + throw new Error(response.error.message) + } + // extractLinearIssueReadItems yields the mobile issue-read shape; the fields the + // row builder/create flow read (id/identifier/title/url/state/team) are a subset. + return extractLinearIssueReadItems((response as RpcSuccess).result) as unknown as LinearIssue[] +} + +export async function searchBranches( + client: RpcClient, + repoId: string, + query: string +): Promise { + const response = await client.sendRequest( + 'repo.searchRefs', + { repo: `id:${repoId}`, query: query.trim(), limit: BRANCH_LIMIT }, + { timeoutMs: 30_000 } + ) + if (!response.ok) { + throw new Error(response.error.message) + } + const result = (response as RpcSuccess).result as { + refDetails?: BaseRefSearchResult[] + refs?: string[] + } + return ( + result.refDetails ?? + (result.refs ?? []).map((refName) => ({ refName, localBranchName: refName })) + ) +} diff --git a/mobile/src/tasks/source-workspace-create.test.ts b/mobile/src/tasks/source-workspace-create.test.ts new file mode 100644 index 00000000000..d23e0c66317 --- /dev/null +++ b/mobile/src/tasks/source-workspace-create.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { createWorkspaceFromComposerSource } from './source-workspace-create' +import type { MobileComposerCreateSelection } from './mobile-composer-source-types' + +type Call = { method: string; params: Record } + +function fakeClient(handle: (method: string, call: number) => unknown, calls: Call[]): RpcClient { + return { + sendRequest: async (method: string, params?: unknown) => { + calls.push({ method, params: (params ?? {}) as Record }) + const result = handle(method, calls.length) + if (result instanceof Error) { + return { + id: '1', + ok: false, + error: { code: 'x', message: result.message }, + _meta: { runtimeId: 'r' } + } + } + return { id: '1', ok: true, result, _meta: { runtimeId: 'r' } } + } + } as unknown as RpcClient +} + +const agent = { choice: 'blank' as const, startupCommand: undefined } + +const baseArgs = { + targetRepoId: 'repo-1', + setupDecision: 'inherit' as const, + agent, + workspaceName: undefined, + note: undefined +} + +describe('createWorkspaceFromComposerSource', () => { + it('creates a GitHub issue workspace linking the issue to its own repo', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-1' } }), calls) + const selection: MobileComposerCreateSelection = { + kind: 'work-item', + item: { + provider: 'github', + type: 'issue', + number: 7, + title: 'Bug', + url: 'u', + repoId: 'repo-9' + } + } + // The composer supplies the title-derived name as workspaceName; with none, + // buildTaskWorkspaceCreateParams falls back to the "-" slug. + const result = await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect(result).toEqual({ worktreeId: 'wt-1', name: 'issue-7' }) + expect(calls).toHaveLength(1) + expect(calls[0]!.method).toBe('worktree.create') + expect(calls[0]!.params).toMatchObject({ + repo: 'id:repo-9', + linkedIssue: 7, + displayName: 'Bug' + }) + }) + + it('passes composer-resolved PR base fields straight through (no re-resolve)', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-2' } }), calls) + const selection: MobileComposerCreateSelection = { + kind: 'work-item', + item: { + provider: 'github', + type: 'pr', + number: 3, + title: 'Feat', + url: 'u', + repoId: 'repo-1' + }, + baseBranch: 'main', + compareBaseRef: 'origin/main', + pushTarget: { remoteName: 'origin', branchName: 'feat-3' }, + branchNameOverride: 'feat-3' + } + await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect(calls.map((c) => c.method)).toEqual(['worktree.create']) + expect(calls[0]!.params).toMatchObject({ + linkedPR: 3, + baseBranch: 'main', + compareBaseRef: 'origin/main', + branchNameOverride: 'feat-3', + pushTarget: { remoteName: 'origin', branchName: 'feat-3' } + }) + }) + + it('resolves a PR base as a fallback when the selection carries none', async () => { + const calls: Call[] = [] + const client = fakeClient( + (method) => + method === 'worktree.resolvePrBase' + ? { baseBranch: 'develop' } + : { worktree: { id: 'wt-3' } }, + calls + ) + const selection: MobileComposerCreateSelection = { + kind: 'work-item', + item: { provider: 'github', type: 'pr', number: 4, title: 'X', url: 'u', repoId: 'repo-1' } + } + await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect(calls.map((c) => c.method)).toEqual(['worktree.resolvePrBase', 'worktree.create']) + expect(calls[1]!.params).toMatchObject({ baseBranch: 'develop', linkedPR: 4 }) + }) + + it('creates a Linear workspace with workspace + org routing', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-4' } }), calls) + const selection: MobileComposerCreateSelection = { + kind: 'work-item', + item: { + provider: 'linear', + type: 'issue', + number: 0, + title: 'Ship it', + url: 'https://linear.app/acme/issue/ENG-9', + linearIdentifier: 'ENG-9', + linearWorkspaceId: 'ws-1', + linearOrganizationUrlKey: 'acme' + } + } + await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect(calls[0]!.params).toMatchObject({ + repo: 'id:repo-1', + linkedLinearIssue: 'ENG-9', + linkedLinearIssueWorkspaceId: 'ws-1', + linkedLinearIssueOrganizationUrlKey: 'acme' + }) + }) + + it('reuses an existing branch with a single attempt (no suffix retry)', async () => { + const calls: Call[] = [] + const client = fakeClient(() => new Error('Branch "feature" already exists.'), calls) + const selection: MobileComposerCreateSelection = { + kind: 'branch', + baseBranch: 'feature', + refName: 'feature', + localBranchName: 'feature', + reuse: true, + branchNameOverride: 'feature' + } + const result = await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect('error' in result).toBe(true) + expect(calls).toHaveLength(1) + expect(calls[0]!.params).toMatchObject({ + baseBranch: 'feature', + branchNameOverride: 'feature' + }) + }) + + it('creates a brand-new branch by name, keeping a slashy name as the branch', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-nb' } }), calls) + const selection: MobileComposerCreateSelection = { + kind: 'new-branch', + branchName: 'feature/login' + } + const result = await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect(result).toEqual({ worktreeId: 'wt-nb', name: 'feature/login' }) + expect(calls[0]!.params).toMatchObject({ + repo: 'id:repo-1', + name: 'feature/login', + branchNameOverride: 'feature/login' + }) + }) + + it('suppresses displayName when the name is user-edited (not auto-managed)', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-dn' } }), calls) + const selection: MobileComposerCreateSelection = { + kind: 'work-item', + item: { + provider: 'github', + type: 'issue', + number: 7, + title: 'Bug', + url: 'u', + repoId: 'repo-1' + } + } + await createWorkspaceFromComposerSource({ + client, + selection, + ...baseArgs, + workspaceName: 'my-name', + nameIsAutoManaged: false + }) + expect(calls[0]!.params.displayName).toBeUndefined() + expect(calls[0]!.params).toMatchObject({ name: 'my-name', linkedIssue: 7 }) + }) + + it('creates a new branch off a ref, bumping the branch on collision', async () => { + const calls: Call[] = [] + const client = fakeClient( + (_m, n) => (n === 1 ? new Error('already exists locally') : { worktree: { id: 'wt-5' } }), + calls + ) + const selection: MobileComposerCreateSelection = { + kind: 'branch', + baseBranch: 'main', + refName: 'main', + localBranchName: 'topic', + reuse: false, + branchNameOverride: 'topic' + } + const result = await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect(result).toEqual({ worktreeId: 'wt-5', name: 'topic-2' }) + expect(calls).toHaveLength(2) + expect(calls[1]!.params).toMatchObject({ + baseBranch: 'main', + branchNameOverride: 'topic-2', + name: 'topic-2' + }) + }) +}) diff --git a/mobile/src/tasks/source-workspace-create.ts b/mobile/src/tasks/source-workspace-create.ts new file mode 100644 index 00000000000..d16cc174142 --- /dev/null +++ b/mobile/src/tasks/source-workspace-create.ts @@ -0,0 +1,250 @@ +import type { RpcClient } from '../transport/rpc-client' +import { resolveComposerMrBase, resolveComposerPrBase } from './composer-source-base-resolve' +import type { + MobileComposerCreateSelection, + MobileLinkedWorkItem +} from './mobile-composer-source-types' +import { resolveMobileWorkspaceCreateName } from './mobile-workspace-name' +import type { WorkspaceAgentChoice } from './workspace-agent-selection' +import { + buildTaskWorkspaceCreateParams, + type WorkspaceCreateSetupDecision, + type WorkspaceCreateTaskItem +} from './workspace-create-params' +import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry' + +// The agent bundle the modal already resolved: the choice drives +// buildTaskWorkspaceCreateParams for work-item sources; the explicit launch +// command is used for branch sources (which have no work-item URL to seed the draft). +export type WorkspaceCreateAgentBundle = { + choice: WorkspaceAgentChoice + startupCommand: string | undefined +} + +export type CreateWorkspaceFromComposerArgs = { + client: RpcClient + selection: MobileComposerCreateSelection + targetRepoId: string + setupDecision: WorkspaceCreateSetupDecision + agent: WorkspaceCreateAgentBundle + workspaceName: string | undefined + note: string | undefined + nameIsAutoManaged?: boolean +} + +export async function createWorkspaceFromComposerSource( + args: CreateWorkspaceFromComposerArgs +): Promise { + if (args.selection.kind === 'branch') { + return createBranchWorkspace({ ...args, selection: args.selection }) + } + if (args.selection.kind === 'new-branch') { + return createNewBranchWorkspace({ ...args, selection: args.selection }) + } + return createWorkItemWorkspace({ ...args, selection: args.selection }) +} + +function toTaskItem(item: MobileLinkedWorkItem, targetRepoId: string): WorkspaceCreateTaskItem { + if (item.provider === 'github') { + return { + provider: 'github', + source: { + type: item.type === 'pr' ? 'pr' : 'issue', + repoId: item.repoId ?? targetRepoId, + number: item.number, + title: item.title, + url: item.url + } + } + } + if (item.provider === 'gitlab') { + return { + provider: 'gitlab', + source: { + type: item.type === 'mr' ? 'mr' : 'issue', + repoId: item.repoId ?? targetRepoId, + number: item.number, + title: item.title, + url: item.url + } + } + } + return { + provider: 'linear', + source: { + identifier: item.linearIdentifier ?? '', + title: item.title, + url: item.url, + ...(item.linearWorkspaceId ? { workspaceId: item.linearWorkspaceId } : {}), + ...(item.linearOrganizationUrlKey + ? { organizationUrlKey: item.linearOrganizationUrlKey } + : {}) + } + } +} + +async function createWorkItemWorkspace(args: { + client: RpcClient + selection: Extract + targetRepoId: string + setupDecision: WorkspaceCreateSetupDecision + agent: WorkspaceCreateAgentBundle + workspaceName: string | undefined + note: string | undefined + nameIsAutoManaged?: boolean +}): Promise { + const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args + const item = selection.item + const taskItem = toTaskItem(item, targetRepoId) + + // The composer resolves PR/MR base at select time; only re-resolve as a + // fallback when a linked PR/MR reached create without one. + let baseBranch = selection.baseBranch + let compareBaseRef = selection.compareBaseRef + let pushTarget = selection.pushTarget + let branchNameOverride = selection.branchNameOverride + if (!baseBranch && item.provider !== 'linear' && (item.type === 'pr' || item.type === 'mr')) { + const repoId = item.repoId ?? targetRepoId + const resolved = + item.type === 'pr' + ? await resolveComposerPrBase({ client, repoId, prNumber: item.number }).catch(() => null) + : await resolveComposerMrBase({ client, repoId, mrIid: item.number }).catch(() => null) + if (resolved) { + baseBranch = resolved.baseBranch + compareBaseRef = resolved.compareBaseRef + pushTarget = resolved.pushTarget + branchNameOverride = resolved.branchNameOverride ?? branchNameOverride + } + } + + const params = buildTaskWorkspaceCreateParams({ + item: taskItem, + targetRepoId, + setupDecision, + agent: agent.choice, + workspaceName, + note, + baseBranch, + compareBaseRef, + branchNameOverride, + pushTarget, + nameIsAutoManaged: args.nameIsAutoManaged + }) + // buildTaskWorkspaceCreateParams computes the name; reuse it as the retry base + // so collisions still append -2, -3, ... like the blank path does. + const baseName = String(params.name) + return createWorktreeWithNameRetry({ + client, + baseName, + buildParams: (name) => ({ ...params, name }) + }) +} + +async function createBranchWorkspace(args: { + client: RpcClient + selection: Extract + targetRepoId: string + setupDecision: WorkspaceCreateSetupDecision + agent: WorkspaceCreateAgentBundle + workspaceName: string | undefined + note: string | undefined +}): Promise { + const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args + const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice + const comment = note?.trim() + const applyCommon = (params: Record): Record => { + if (createdWithAgentId) { + params.createdWithAgent = createdWithAgentId + } + if (comment) { + params.comment = comment + } + return params + } + + if (selection.reuse) { + // Reusing a fixed existing branch: branchNameOverride is pinned to the reused + // branch, so a branch collision can't be cleared by suffixing the display + // name — fail fast instead of burning the retry budget. + const baseName = resolveMobileWorkspaceCreateName({ + draft: workspaceName, + fallback: selection.localBranchName + }) + return createWorktreeWithNameRetry({ + client, + baseName, + maxAttempts: 1, + buildParams: (name) => + applyCommon({ + repo: `id:${targetRepoId}`, + name, + setupDecision, + baseBranch: selection.refName, + branchNameOverride: selection.localBranchName, + startupCommand: agent.startupCommand + }) + }) + } + + // New branch off the selected ref. The retry base is the branch name so a + // collision bumps the branch itself. + const baseName = resolveMobileWorkspaceCreateName({ + draft: workspaceName, + fallback: selection.branchNameOverride || selection.localBranchName + }) + return createWorktreeWithNameRetry({ + client, + baseName, + buildParams: (candidate) => { + const params: Record = { + repo: `id:${targetRepoId}`, + name: candidate, + setupDecision, + baseBranch: selection.baseBranch, + startupCommand: agent.startupCommand + } + if (selection.branchNameOverride) { + params.branchNameOverride = candidate + } + return applyCommon(params) + } + }) +} + +async function createNewBranchWorkspace(args: { + client: RpcClient + selection: Extract + targetRepoId: string + setupDecision: WorkspaceCreateSetupDecision + agent: WorkspaceCreateAgentBundle + workspaceName: string | undefined + note: string | undefined +}): Promise { + const { client, selection, targetRepoId, setupDecision, agent, note } = args + const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice + const comment = note?.trim() + // A brand-new branch off the repo's default base. The typed name is kept as the + // git branch (via branchNameOverride) so a slash like `feature/login` survives; + // the runtime sanitizes the worktree folder from the same name. The retry base is + // the branch name so a collision bumps the branch (and folder) together. + return createWorktreeWithNameRetry({ + client, + baseName: selection.branchName, + buildParams: (candidate) => { + const params: Record = { + repo: `id:${targetRepoId}`, + name: candidate, + setupDecision, + branchNameOverride: candidate, + startupCommand: agent.startupCommand + } + if (createdWithAgentId) { + params.createdWithAgent = createdWithAgentId + } + if (comment) { + params.comment = comment + } + return params + } + }) +} diff --git a/mobile/src/tasks/use-mobile-composer-source.ts b/mobile/src/tasks/use-mobile-composer-source.ts new file mode 100644 index 00000000000..6785e2fe30b --- /dev/null +++ b/mobile/src/tasks/use-mobile-composer-source.ts @@ -0,0 +1,330 @@ +import { useCallback, useMemo, useRef, useState } from 'react' +import type { GitHubWorkItem, GitLabWorkItem, LinearIssue } from '../../../src/shared/types' +import { resolveComposerManualBranchNameChange } from '../../../src/shared/composer-branch-selection' +import { resolveGitHubWorkItemIdentity } from '../../../src/shared/new-workspace/github-work-item-identity' +import { getForkPushWarning } from '../../../src/shared/new-workspace/fork-push-warning' +import type { RpcClient } from '../transport/rpc-client' +import { + buildGitHubLinkedWorkItem, + buildGitLabLinkedWorkItem, + buildLinearLinkedWorkItem, + buildSmartNameSelection, + resolveComposerBranchPick, + resolveComposerCreateSelection, + resolveLinearAutoName, + resolveWorkItemAutoName, + shouldApplyAutoName +} from './composer-linked-work-item' +import { + resolveComposerMrBase, + resolveComposerPrBase, + type ComposerHostedBase +} from './composer-source-base-resolve' +import type { + ComposerBaseState, + MobileComposerCreateSelection, + MobileLinkedWorkItem, + SmartNameSelection +} from './mobile-composer-source-types' +const EMPTY_BASE: ComposerBaseState = {} + +export type UseMobileComposerSourceArgs = { + client: RpcClient | null + selectedRepoId: string | null + worktreeBranches?: readonly string[] + onError?: (message: string) => void +} + +export function useMobileComposerSource(args: UseMobileComposerSourceArgs) { + const { client, selectedRepoId, worktreeBranches = [], onError } = args + const [name, setNameState] = useState('') + const [linkedWorkItem, setLinkedWorkItem] = useState(null) + const [base, setBase] = useState(EMPTY_BASE) + const [reuseEligibleBranch, setReuseEligibleBranch] = useState(null) + const [reuseSelectedBranch, setReuseSelectedBranch] = useState(false) + const [forkPushWarning, setForkPushWarning] = useState(null) + const [resolvingBase, setResolvingBase] = useState(false) + // Set when the "Create branch " row is picked, so the typed name (which + // may contain slashes) is kept verbatim as the git branch (folder is sanitized). + const [branchCreateIntent, setBranchCreateIntent] = useState(false) + + const lastAutoNameRef = useRef('') + const branchSelectionRef = useRef<{ refName: string; localBranchName: string } | null>(null) + // Guards async base resolution: only the latest selection applies its result. + const resolveTokenRef = useRef(0) + + const setName = useCallback((value: string) => setNameState(value), []) + + const applyAutoName = useCallback((suggested: string, currentName: string) => { + if (suggested && shouldApplyAutoName({ currentName, lastAutoName: lastAutoNameRef.current })) { + setNameState(suggested) + lastAutoNameRef.current = suggested + } + }, []) + + const clearBaseAndBranch = useCallback(() => { + branchSelectionRef.current = null + setBranchCreateIntent(false) + setBase(EMPTY_BASE) + setReuseEligibleBranch(null) + setReuseSelectedBranch(false) + setForkPushWarning(null) + // Why: a superseding selection bumps the resolve token, so an in-flight base + // resolve's token-gated finally can no longer clear this — reset it here so + // resolvingBase never sticks true after switching sources. + setResolvingBase(false) + }, []) + + // Applies an async PR/MR base resolution guarded by the current token so only + // the latest selection wins; failures clear the base and surface the error. + const runBaseResolve = useCallback( + (token: number, resolve: Promise) => { + setResolvingBase(true) + void resolve + .then((result) => { + if (resolveTokenRef.current !== token) { + return + } + setBase({ + baseBranch: result.baseBranch, + compareBaseRef: result.compareBaseRef, + pushTarget: result.pushTarget, + branchNameOverride: result.branchNameOverride + }) + setForkPushWarning(getForkPushWarning(result)) + }) + .catch((error: unknown) => { + if (resolveTokenRef.current !== token) { + return + } + setBase(EMPTY_BASE) + onError?.(error instanceof Error ? error.message : 'Failed to resolve base branch.') + }) + .finally(() => { + if (resolveTokenRef.current === token) { + setResolvingBase(false) + } + }) + }, + [onError] + ) + + const handleSmartGitHubItemSelect = useCallback( + (item: GitHubWorkItem) => { + const token = (resolveTokenRef.current += 1) + const identity = resolveGitHubWorkItemIdentity(item) + // Resolve the PR base against the item's OWN repo — a cross-repo accept + // switches repos then selects synchronously, so selectedRepoId is stale. + const repoId = item.repoId || selectedRepoId + setLinkedWorkItem( + buildGitHubLinkedWorkItem({ + type: identity.type, + number: identity.number, + title: item.title, + url: item.url, + repoId: item.repoId + }) + ) + applyAutoName( + resolveWorkItemAutoName({ ...identity, title: item.title, provider: 'github' }), + name + ) + clearBaseAndBranch() + if (identity.type !== 'pr' || !client || !repoId) { + return + } + runBaseResolve( + token, + resolveComposerPrBase({ + client, + repoId, + prNumber: identity.number, + ...(item.branchName ? { headRefName: item.branchName } : {}), + ...(item.baseRefName ? { baseRefName: item.baseRefName } : {}), + ...(item.isCrossRepository !== undefined + ? { isCrossRepository: item.isCrossRepository } + : {}) + }) + ) + }, + [applyAutoName, clearBaseAndBranch, client, name, runBaseResolve, selectedRepoId] + ) + + const handleSmartGitLabItemSelect = useCallback( + (item: GitLabWorkItem) => { + const token = (resolveTokenRef.current += 1) + // Resolve the MR base against the item's OWN repo (see the GitHub handler). + const repoId = item.repoId || selectedRepoId + setLinkedWorkItem( + buildGitLabLinkedWorkItem({ + type: item.type, + number: item.number, + title: item.title, + url: item.url, + repoId: item.repoId + }) + ) + applyAutoName( + resolveWorkItemAutoName({ + type: item.type, + number: item.number, + title: item.title, + provider: 'gitlab' + }), + name + ) + clearBaseAndBranch() + if (item.type !== 'mr' || !client || !repoId) { + return + } + runBaseResolve( + token, + resolveComposerMrBase({ + client, + repoId, + mrIid: item.number, + ...(item.branchName ? { sourceBranch: item.branchName } : {}), + ...(item.baseRefName ? { targetBranch: item.baseRefName } : {}), + ...(item.isCrossRepository !== undefined + ? { isCrossRepository: item.isCrossRepository } + : {}) + }) + ) + }, + [applyAutoName, clearBaseAndBranch, client, name, runBaseResolve, selectedRepoId] + ) + + const handleSmartLinearIssueSelect = useCallback( + (issue: LinearIssue) => { + resolveTokenRef.current += 1 + setLinkedWorkItem(buildLinearLinkedWorkItem(issue)) + const suggested = resolveLinearAutoName(issue) + const identifierTyped = name.trim().toLowerCase() === issue.identifier.toLowerCase() + if ( + suggested && + (identifierTyped || + shouldApplyAutoName({ currentName: name, lastAutoName: lastAutoNameRef.current })) + ) { + setNameState(suggested) + lastAutoNameRef.current = suggested + } + clearBaseAndBranch() + }, + [clearBaseAndBranch, name] + ) + + const handleSmartBranchSelect = useCallback( + (refName: string, localBranchName: string) => { + resolveTokenRef.current += 1 + setLinkedWorkItem(null) + setForkPushWarning(null) + setBranchCreateIntent(false) + setResolvingBase(false) + const pick = resolveComposerBranchPick({ + refName, + localBranchName, + currentName: name, + lastAutoName: lastAutoNameRef.current, + worktreeBranches + }) + setReuseEligibleBranch(pick.reuseEligibleBranch) + setReuseSelectedBranch(pick.reuseSelectedBranch) + setBase(pick.base) + branchSelectionRef.current = { refName, localBranchName } + if (pick.name !== undefined) { + setNameState(pick.name) + lastAutoNameRef.current = pick.lastAutoName ?? '' + } + }, + [name, worktreeBranches] + ) + + // Picking "Create branch ": name the workspace and mark a new-branch + // intent so the typed (possibly slashy) name is kept verbatim as the git branch. + const handleSmartCreateBranch = useCallback( + (branchName: string) => { + resolveTokenRef.current += 1 + setLinkedWorkItem(null) + clearBaseAndBranch() + setNameState(branchName) + lastAutoNameRef.current = branchName + setBranchCreateIntent(true) + }, + [clearBaseAndBranch] + ) + + const handleClearSmartNameSelection = useCallback(() => { + resolveTokenRef.current += 1 + setLinkedWorkItem(null) + clearBaseAndBranch() + setResolvingBase(false) + if (name === lastAutoNameRef.current) { + setNameState('') + lastAutoNameRef.current = '' + } + }, [clearBaseAndBranch, name]) + + const handleBranchNameOverrideChange = useCallback( + (value: string) => { + const next = resolveComposerManualBranchNameChange({ + value, + pushTarget: base.pushTarget, + forkPushWarning + }) + setBase({ + ...base, + branchNameOverride: next.branchNameOverride, + pushTarget: next.pushTarget + }) + setForkPushWarning(next.forkPushWarning) + }, + [base, forkPushWarning] + ) + + const smartNameSelection = useMemo( + () => buildSmartNameSelection({ linkedWorkItem, baseBranch: base.baseBranch }), + [base.baseBranch, linkedWorkItem] + ) + + const createSelection = useMemo( + () => + resolveComposerCreateSelection({ + linkedWorkItem, + base, + branch: branchSelectionRef.current, + reuseEligibleBranch, + reuseSelectedBranch, + branchCreateIntent, + name + }), + [base, branchCreateIntent, linkedWorkItem, name, reuseEligibleBranch, reuseSelectedBranch] + ) + + // Auto-managed until the user edits the name away from the last derived value; + // desktop suppresses the workspace displayName once the name is user-edited. + const isNameAutoManaged = !name.trim() || name === lastAutoNameRef.current + + return { + name, + setName, + linkedWorkItem, + branchNameOverride: base.branchNameOverride, + handleBranchNameOverrideChange, + reuseEligibleBranch, + reuseSelectedBranch, + setReuseSelectedBranch, + forkPushWarning, + resolvingBase, + isNameAutoManaged, + smartNameSelection, + createSelection, + handleSmartGitHubItemSelect, + handleSmartGitLabItemSelect, + handleSmartLinearIssueSelect, + handleSmartBranchSelect, + handleSmartCreateBranch, + handleClearSmartNameSelection + } +} + +export type MobileComposerSource = ReturnType diff --git a/mobile/src/tasks/use-smart-workspace-source.ts b/mobile/src/tasks/use-smart-workspace-source.ts new file mode 100644 index 00000000000..1d0c88dafd5 --- /dev/null +++ b/mobile/src/tasks/use-smart-workspace-source.ts @@ -0,0 +1,233 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { GitHubWorkItem, GitLabWorkItem } from '../../../src/shared/types' +import { + buildSmartWorkspaceSourceRows, + getSmartWorkspaceEmptyHint, + type SmartNameMode, + type SmartWorkspaceSourceRow +} from '../../../src/shared/new-workspace/smart-workspace-source-results' +import type { RpcClient } from '../transport/rpc-client' +import { fanOutSmartSearch, type SmartFanOutResult } from './smart-source-fan-out' +import type { MrStateFilter } from './mobile-composer-source-types' +import { + findRepoMatchingSlugForPaste, + lookupGitHubItemByNumber, + lookupGitHubItemByOwnerRepo, + lookupGitLabItemByPath, + resolvePasteIntent, + type PasteRepoCandidate +} from './smart-source-paste-intent' + +const DEBOUNCE_MS = 200 +const RESULT_LIMIT = 36 + +export type SmartCrossRepoPrompt = { + link: { slug: { owner: string; repo: string }; number: number; type: 'issue' | 'pr' } + matchingRepo: PasteRepoCandidate +} + +export type UseSmartWorkspaceSourceArgs = { + client: RpcClient | null + enabled: boolean + mode: SmartNameMode + query: string + repoId: string | null + githubAvailable: boolean + gitlabAvailable: boolean + linearAvailable: boolean + mrStateFilter: MrStateFilter + linearWorkspaceId?: string | null + repos: readonly PasteRepoCandidate[] +} + +const EMPTY_FAN: SmartFanOutResult = { + githubItems: [], + gitlabItems: [], + linearIssues: [], + branches: [], + needsGitHubRemote: false, + error: '' +} + +type PasteResolved = { github: GitHubWorkItem | null; gitlab: GitLabWorkItem | null } + +export function useSmartWorkspaceSource(args: UseSmartWorkspaceSourceArgs) { + const { + client, + enabled, + mode, + query, + repoId, + githubAvailable, + gitlabAvailable, + linearAvailable, + mrStateFilter, + linearWorkspaceId, + repos + } = args + const [fan, setFan] = useState(EMPTY_FAN) + const [paste, setPaste] = useState({ github: null, gitlab: null }) + const [loading, setLoading] = useState(false) + const [crossRepoPrompt, setCrossRepoPrompt] = useState(null) + // Why: preserve results across keystrokes (debounce) but drop them the moment + // the mode/repo changes so one provider's rows never render under another tab. + const scopeRef = useRef('') + const dismissedPasteRef = useRef('') + const repoSlugCacheRef = useRef>(new Map()) + + useEffect(() => { + if (!client || !enabled || mode === 'text') { + setFan(EMPTY_FAN) + setPaste({ github: null, gitlab: null }) + setLoading(false) + setCrossRepoPrompt(null) + return + } + const scope = `${mode}:${repoId ?? ''}` + const scopeChanged = scopeRef.current !== scope + scopeRef.current = scope + if (scopeChanged) { + setFan(EMPTY_FAN) + setPaste({ github: null, gitlab: null }) + setCrossRepoPrompt(null) + } + setLoading(true) + let stale = false + const timer = setTimeout(() => { + void runSmartSearch({ + client, + mode, + query, + repoId, + githubAvailable, + gitlabAvailable, + linearAvailable, + mrStateFilter, + linearWorkspaceId, + repos, + dismissedPasteRef, + repoSlugCache: repoSlugCacheRef.current + }) + .then((result) => { + if (stale) { + return + } + setFan(result.fan) + setPaste(result.paste) + setCrossRepoPrompt(result.crossRepoPrompt) + setLoading(false) + }) + .catch(() => { + if (!stale) { + setLoading(false) + } + }) + }, DEBOUNCE_MS) + return () => { + stale = true + clearTimeout(timer) + } + }, [ + client, + enabled, + mode, + query, + repoId, + githubAvailable, + gitlabAvailable, + linearAvailable, + mrStateFilter, + linearWorkspaceId, + repos + ]) + + const rows = useMemo( + () => + buildSmartWorkspaceSourceRows({ + branches: fan.branches, + githubItems: paste.github ? [paste.github] : fan.githubItems, + gitlabAvailable, + gitlabItems: paste.gitlab ? [paste.gitlab] : fan.gitlabItems, + linearAvailable, + linearIssues: fan.linearIssues, + mode, + resultLimit: RESULT_LIMIT, + value: query + }), + [fan, gitlabAvailable, linearAvailable, mode, paste, query] + ) + + const dismissCrossRepoPrompt = useCallback(() => { + dismissedPasteRef.current = query.trim() + setCrossRepoPrompt(null) + }, [query]) + + return { + rows, + loading, + error: fan.error, + needsGitHubRemote: fan.needsGitHubRemote, + emptyHint: getSmartWorkspaceEmptyHint(mode), + crossRepoPrompt, + dismissCrossRepoPrompt + } +} + +async function runSmartSearch(args: { + client: RpcClient + mode: SmartNameMode + query: string + repoId: string | null + githubAvailable: boolean + gitlabAvailable: boolean + linearAvailable: boolean + mrStateFilter: MrStateFilter + linearWorkspaceId: string | null | undefined + repos: readonly PasteRepoCandidate[] + dismissedPasteRef: { current: string } + repoSlugCache: Map +}): Promise<{ + fan: SmartFanOutResult + paste: PasteResolved + crossRepoPrompt: SmartCrossRepoPrompt | null +}> { + const { client, mode, query, repoId, repos, dismissedPasteRef, repoSlugCache } = args + const fan = await fanOutSmartSearch(args) + const paste: PasteResolved = { github: null, gitlab: null } + let crossRepoPrompt: SmartCrossRepoPrompt | null = null + + const intent = + mode === 'branches' || dismissedPasteRef.current === query.trim() + ? null + : resolvePasteIntent(query) + if (intent && repoId) { + try { + if (intent.kind === 'github-number') { + paste.github = await lookupGitHubItemByNumber(client, repoId, intent.number) + } else if (intent.kind === 'github-link') { + const matchingRepo = await findRepoMatchingSlugForPaste( + client, + repos, + intent.link.slug, + repoSlugCache + ) + if (matchingRepo && matchingRepo.id !== repoId) { + crossRepoPrompt = { link: intent.link, matchingRepo } + } else { + paste.github = await lookupGitHubItemByOwnerRepo( + client, + repoId, + intent.link.slug, + intent.link.number, + intent.link.type + ) + } + } else if (intent.kind === 'gitlab-link') { + paste.gitlab = await lookupGitLabItemByPath(client, repoId, intent.link) + } + } catch { + // Best-effort paste resolution; fall back to the fan-out results. + } + } + return { fan, paste, crossRepoPrompt } +} diff --git a/mobile/src/tasks/work-item-lookup-text.test.ts b/mobile/src/tasks/work-item-lookup-text.test.ts new file mode 100644 index 00000000000..bd37f46d606 --- /dev/null +++ b/mobile/src/tasks/work-item-lookup-text.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { isWorkItemLookupText } from './work-item-lookup-text' + +describe('isWorkItemLookupText', () => { + it('treats references as lookup text, not names', () => { + expect(isWorkItemLookupText('#42')).toBe(true) + expect(isWorkItemLookupText('https://github.com/o/r/issues/1')).toBe(true) + expect(isWorkItemLookupText('https://gitlab.com/g/p/-/merge_requests/2')).toBe(true) + expect(isWorkItemLookupText('https://linear.app/acme/issue/ENG-9')).toBe(true) + expect(isWorkItemLookupText('ENG-9')).toBe(false) + }) + + it('treats plain names as non-lookup text', () => { + expect(isWorkItemLookupText('')).toBe(false) + expect(isWorkItemLookupText('fix the login bug')).toBe(false) + expect(isWorkItemLookupText('https://linear.app/acme/project/mobile')).toBe(false) + }) +}) diff --git a/mobile/src/tasks/work-item-lookup-text.ts b/mobile/src/tasks/work-item-lookup-text.ts new file mode 100644 index 00000000000..b130292e8d3 --- /dev/null +++ b/mobile/src/tasks/work-item-lookup-text.ts @@ -0,0 +1 @@ +export * from '../../../src/shared/new-workspace/work-item-lookup-text' diff --git a/mobile/src/tasks/workspace-create-params.test.ts b/mobile/src/tasks/workspace-create-params.test.ts index f15c7afce30..e53c7965116 100644 --- a/mobile/src/tasks/workspace-create-params.test.ts +++ b/mobile/src/tasks/workspace-create-params.test.ts @@ -144,7 +144,7 @@ describe('task workspace create params', () => { ).toMatchObject({ repo: 'id:repo-linear', name: 'eng-42', - displayName: 'Ship Linear parity', + displayName: 'ENG-42 Ship Linear parity', linkedLinearIssue: 'ENG-42', startupDraft: 'https://linear.app/acme/issue/ENG-42/ship-linear-parity', createdWithAgent: 'grok' diff --git a/mobile/src/tasks/workspace-create-params.ts b/mobile/src/tasks/workspace-create-params.ts index ec945a11828..c6667470279 100644 --- a/mobile/src/tasks/workspace-create-params.ts +++ b/mobile/src/tasks/workspace-create-params.ts @@ -1,19 +1,16 @@ -import type { TuiAgent } from '../../../src/shared/types' +import type { + CreateSparseCheckoutRequest, + GitPushTarget, + SetupDecision, + TuiAgent +} from '../../../src/shared/types' +import { getWorkspaceSourceName } from '../../../src/shared/new-workspace/workspace-source' import { resolveMobileWorkspaceCreateName } from './mobile-workspace-name' import type { WorkspaceAgentChoice } from './workspace-agent-selection' -export type WorkspaceCreateSetupDecision = 'inherit' | 'run' | 'skip' - -export type WorkspaceCreateSparseCheckout = { - directories: string[] - presetId?: string -} - -export type WorkspaceCreateGitPushTarget = { - remoteName: string - branchName: string - remoteUrl?: string -} +export type WorkspaceCreateSetupDecision = SetupDecision +export type WorkspaceCreateSparseCheckout = CreateSparseCheckoutRequest +export type WorkspaceCreateGitPushTarget = GitPushTarget export type WorkspaceCreateHostedStartPoint = { baseBranch: string @@ -48,6 +45,8 @@ type WorkspaceCreateLinearItem = { identifier: string title: string url: string + workspaceId?: string + organizationUrlKey?: string } } @@ -66,9 +65,12 @@ export function buildTaskWorkspaceCreateParams(args: { workspaceName?: string note?: string baseBranch?: string + compareBaseRef?: string branchNameOverride?: string + pushTarget?: WorkspaceCreateGitPushTarget sparseCheckout?: WorkspaceCreateSparseCheckout hostedStartPoint?: WorkspaceCreateHostedStartPoint + nameIsAutoManaged?: boolean }): WorkspaceCreateParams { const { item, @@ -78,22 +80,41 @@ export function buildTaskWorkspaceCreateParams(args: { workspaceName, note, baseBranch, + compareBaseRef, branchNameOverride, + pushTarget, sparseCheckout, - hostedStartPoint + hostedStartPoint, + nameIsAutoManaged = true } = args const shouldLaunchAgent = agent !== 'blank' const createdWithAgent = shouldLaunchAgent ? (agent as TuiAgent) : undefined const comment = note?.trim() const selectedBaseBranch = baseBranch || hostedStartPoint?.baseBranch + const selectedPushTarget = pushTarget ?? hostedStartPoint?.pushTarget + // Why: desktop only sends displayName while the name is still auto-derived; a + // user-edited name suppresses it so the runtime keeps the user's chosen name. + const sourceName = + item.provider === 'linear' + ? getWorkspaceSourceName({ + provider: 'linear', + type: 'issue', + number: 0, + title: item.source.title, + url: item.source.url, + linearIdentifier: item.source.identifier + }) + : getWorkspaceSourceName({ provider: item.provider, ...item.source }) + const displayName = nameIsAutoManaged ? { displayName: sourceName.displayName } : {} const common = { setupDecision, activate: true, ...(shouldLaunchAgent ? { startupDraft: item.source.url } : {}), ...(createdWithAgent ? { createdWithAgent } : {}), ...(selectedBaseBranch ? { baseBranch: selectedBaseBranch } : {}), + ...(compareBaseRef ? { compareBaseRef } : {}), ...(branchNameOverride ? { branchNameOverride } : {}), - ...(hostedStartPoint?.pushTarget ? { pushTarget: hostedStartPoint.pushTarget } : {}), + ...(selectedPushTarget ? { pushTarget: selectedPushTarget } : {}), ...(sparseCheckout ? { sparseCheckout } : {}), ...(comment ? { comment } : {}) } @@ -103,7 +124,7 @@ export function buildTaskWorkspaceCreateParams(args: { return { repo: `id:${item.source.repoId}`, name: resolveMobileWorkspaceCreateName({ draft: workspaceName, fallback }), - displayName: item.source.title, + ...displayName, ...common, ...(item.source.type === 'issue' ? { linkedIssue: item.source.number } @@ -116,7 +137,7 @@ export function buildTaskWorkspaceCreateParams(args: { return { repo: `id:${item.source.repoId}`, name: resolveMobileWorkspaceCreateName({ draft: workspaceName, fallback }), - displayName: item.source.title, + ...displayName, ...common, ...(item.source.type === 'issue' ? { linkedGitLabIssue: item.source.number } @@ -130,8 +151,12 @@ export function buildTaskWorkspaceCreateParams(args: { draft: workspaceName, fallback: item.source.identifier.toLowerCase() }), - displayName: item.source.title, + ...displayName, linkedLinearIssue: item.source.identifier, + ...(item.source.workspaceId ? { linkedLinearIssueWorkspaceId: item.source.workspaceId } : {}), + ...(item.source.organizationUrlKey + ? { linkedLinearIssueOrganizationUrlKey: item.source.organizationUrlKey } + : {}), ...common } } diff --git a/mobile/src/tasks/worktree-create-retry.ts b/mobile/src/tasks/worktree-create-retry.ts new file mode 100644 index 00000000000..4eb749f5c23 --- /dev/null +++ b/mobile/src/tasks/worktree-create-retry.ts @@ -0,0 +1,46 @@ +import type { RpcClient } from '../transport/rpc-client' +import type { RpcSuccess } from '../transport/types' +import { + CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS, + getClientWorktreeCreateCandidate, + isRetryableWorktreeCreateConflict +} from '../../../src/shared/new-workspace/worktree-create-retry-policy' +import { WORKTREE_CREATE_TIMEOUT_MS } from './workspace-create-timeout' + +// Why: server-side collision checks (branch already exists locally / on a remote +// / already has PR #N) can fire even after a pre-flight basename dedupe — +// branches outlive worktrees in git, and remote branches/PRs aren't visible from +// worktree.ps. Retry by appending -2, -3, ... mirroring the desktop createWorktree +// loop in src/renderer/src/store/slices/worktrees.ts. +export type WorktreeCreateResult = { worktreeId: string; name: string } | { error: string } + +// Creates a worktree, retrying with a numeric suffix on a name-collision error. +// buildParams receives the candidate name so callers can assemble source-specific +// params (linked issue/PR, base branch, etc.) around it. Callers that can't clear +// a collision by re-suffixing (e.g. reusing a fixed existing branch) pass +// maxAttempts: 1 to fail fast instead of burning the full retry budget. +export async function createWorktreeWithNameRetry(args: { + client: RpcClient + baseName: string + buildParams: (name: string) => Record + maxAttempts?: number +}): Promise { + const { client, baseName, buildParams } = args + const maxAttempts = args.maxAttempts ?? CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS + let lastError: string | null = null + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const candidateName = getClientWorktreeCreateCandidate(baseName, attempt) + const response = await client.sendRequest('worktree.create', buildParams(candidateName), { + timeoutMs: WORKTREE_CREATE_TIMEOUT_MS + }) + if (response.ok) { + const result = (response as RpcSuccess).result as { worktree: { id: string } } + return { worktreeId: result.worktree.id, name: candidateName } + } + lastError = response.error.message + if (!isRetryableWorktreeCreateConflict(lastError ?? '')) { + break + } + } + return { error: lastError ?? 'Failed to create workspace' } +} diff --git a/mobile/src/terminal/terminal-foreground-recovery.test.ts b/mobile/src/terminal/terminal-foreground-recovery.test.ts index 6aeceaaa14a..74d567ff62a 100644 --- a/mobile/src/terminal/terminal-foreground-recovery.test.ts +++ b/mobile/src/terminal/terminal-foreground-recovery.test.ts @@ -69,7 +69,7 @@ describe('terminal foreground recovery', () => { const recovered = recoverActiveTerminalAfterForeground(harness) - expect(recovered).toBe(true) + expect(recovered).toBe('recovered') expect(harness.unsubscribeTerminal).toHaveBeenCalledWith('term-1') expect(harness.initializedHandlesRef.current.has('term-1')).toBe(false) expect(harness.schedule).toHaveBeenCalledWith( @@ -90,7 +90,7 @@ describe('terminal foreground recovery', () => { const recovered = recoverActiveTerminalAfterForeground(harness) - expect(recovered).toBe(true) + expect(recovered).toBe('recovered') expect(harness.initializedHandlesRef.current.has('term-1')).toBe(false) expect(harness.initializedHandlesRef.current.has('term-2')).toBe(false) expect(harness.unsubscribeTerminal).toHaveBeenCalledWith('term-1') @@ -102,11 +102,37 @@ describe('terminal foreground recovery', () => { const recovered = recoverActiveTerminalAfterForeground(harness) - expect(recovered).toBe(false) + expect(recovered).toBe('skipped') expect(harness.unsubscribeTerminal).not.toHaveBeenCalled() expect(harness.schedule).not.toHaveBeenCalled() }) + it('defers without touching state when the socket is not connected yet', () => { + const harness = createHarness() + harness.connStateRef.current = 'reconnecting' + + const recovered = recoverActiveTerminalAfterForeground(harness) + + expect(recovered).toBe('deferred') + // Why this matters: the deferred retry needs the initialized flags intact + // so the post-reconnect recovery can still force a scrollback replay. + expect(harness.initializedHandlesRef.current.has('term-1')).toBe(true) + expect(harness.unsubscribeTerminal).not.toHaveBeenCalled() + expect(harness.schedule).not.toHaveBeenCalled() + }) + + it('replays scrollback when re-run after the deferred reconnect completes', () => { + const harness = createHarness() + harness.connStateRef.current = 'reconnecting' + + expect(recoverActiveTerminalAfterForeground(harness)).toBe('deferred') + + harness.connStateRef.current = 'connected' + expect(recoverActiveTerminalAfterForeground(harness)).toBe('recovered') + harness.runScheduled() + expect(harness.subscribeToTerminal).toHaveBeenCalledWith('term-1') + }) + it('does not replay a stale terminal if focus changes before the delayed subscribe', () => { const harness = createHarness() @@ -134,4 +160,17 @@ describe('terminal foreground recovery', () => { expect(readinessInvalidation).toBeGreaterThanOrEqual(0) expect(replay).toBeGreaterThan(readinessInvalidation) }) + + it('re-runs a deferred recovery once the session screen reconnects', () => { + // Why: resume usually lands mid-reconnect; the session screen must retry + // recovery on the connState→connected transition or blanked panes stay + // stale until a manual tab switch. + expect(sessionSource).toContain("pendingForegroundRecoveryRef.current = outcome === 'deferred'") + const reconnectRetry = sliceSessionSource( + "if (connState !== 'connected' || !pendingForegroundRecoveryRef.current)", + 'recoverActiveTerminalAfterForeground({' + ) + expect(reconnectRetry).toContain('pendingForegroundRecoveryRef.current = false') + expect(reconnectRetry).toContain("AppState.currentState !== 'active'") + }) }) diff --git a/mobile/src/terminal/terminal-foreground-recovery.ts b/mobile/src/terminal/terminal-foreground-recovery.ts index d3d5132c8c7..7abfe48b8c9 100644 --- a/mobile/src/terminal/terminal-foreground-recovery.ts +++ b/mobile/src/terminal/terminal-foreground-recovery.ts @@ -4,6 +4,11 @@ import type { TerminalWebViewHandle } from './TerminalWebView' export const TERMINAL_FOREGROUND_RECOVERY_DELAY_MS = 120 +// 'deferred' = the socket wasn't connected at the foreground edge (it usually +// dies after ~60-80s of background); the caller must re-run recovery once the +// connection is back or a blanked WKWebView stays stale until a tab switch. +export type TerminalForegroundRecoveryOutcome = 'recovered' | 'deferred' | 'skipped' + type TerminalForegroundRecoveryOptions = { activeHandleRef: RefObject terminalRefs: RefObject> @@ -36,15 +41,15 @@ export function recoverActiveTerminalAfterForeground({ subscribeToTerminal, schedule, delayMs = TERMINAL_FOREGROUND_RECOVERY_DELAY_MS -}: TerminalForegroundRecoveryOptions): boolean { +}: TerminalForegroundRecoveryOptions): TerminalForegroundRecoveryOutcome { if (connStateRef.current !== 'connected') { - return false + return 'deferred' } const initializedMountedHandles = Array.from(initializedHandlesRef.current).filter((handle) => terminalRefs.current.has(handle) ) if (initializedMountedHandles.length === 0) { - return false + return 'skipped' } const handle = activeHandleRef.current const shouldRecoverActive = @@ -57,7 +62,7 @@ export function recoverActiveTerminalAfterForeground({ } if (!shouldRecoverActive || !handle) { - return true + return 'recovered' } unsubscribeTerminal(handle) @@ -70,5 +75,5 @@ export function recoverActiveTerminalAfterForeground({ } subscribeToTerminal(handle) }, delayMs) - return true + return 'recovered' } diff --git a/mobile/src/terminal/terminal-viewport-refit-state.ts b/mobile/src/terminal/terminal-viewport-refit-state.ts index 78da5c2fe06..e5787139fd9 100644 --- a/mobile/src/terminal/terminal-viewport-refit-state.ts +++ b/mobile/src/terminal/terminal-viewport-refit-state.ts @@ -24,6 +24,20 @@ export function isTerminalUpdateViewportApplied(response: RpcResponse): boolean return (response.result as { applied?: unknown }).applied === true } +// Why: re-fit when the flex-bounded frame height settles, but never while the +// keyboard is visible so an IME window resize (Android adjustResize) can't +// reflow the PTY per keystroke — it re-fits again once the keyboard closes. +export function shouldRefitOnFrameHeightChange(state: { + previousHeight: number + nextHeight: number + keyboardVisible: boolean +}): boolean { + if (state.keyboardVisible) { + return false + } + return state.previousHeight !== state.nextHeight +} + export function isTerminalViewportRefitTargetCurrent( state: TerminalViewportRefitTargetState ): boolean { diff --git a/mobile/src/terminal/terminal-viewport-refit.test.ts b/mobile/src/terminal/terminal-viewport-refit.test.ts index b658dad437a..54798a517e0 100644 --- a/mobile/src/terminal/terminal-viewport-refit.test.ts +++ b/mobile/src/terminal/terminal-viewport-refit.test.ts @@ -4,7 +4,8 @@ import type { RpcResponse } from '../transport/types' import { isTerminalUpdateViewportApplied, isTerminalUpdateViewportUpdated, - isTerminalViewportRefitTargetCurrent + isTerminalViewportRefitTargetCurrent, + shouldRefitOnFrameHeightChange } from './terminal-viewport-refit-state' const hookSource = readFileSync(new URL('./terminal-viewport-refit.ts', import.meta.url), 'utf8') @@ -48,11 +49,59 @@ describe('terminal viewport refit', () => { expect(textScaleEffect).toContain('[textScale, viewportMeasuredRef, scheduleViewportRefit]') }) + it('refits on a frame-height change only while the keyboard is closed', () => { + // The dock settling after a new agent terminal's first fit changes the frame + // height; refitting then stops the PTY over-fitting behind the dock. An IME + // that resizes the window (Android) must not reflow the PTY while typing. + // Height settled, keyboard closed → refit. + expect( + shouldRefitOnFrameHeightChange({ + previousHeight: 600, + nextHeight: 520, + keyboardVisible: false + }) + ).toBe(true) + // Unchanged height → no refit (don't churn on unrelated re-layouts). + expect( + shouldRefitOnFrameHeightChange({ + previousHeight: 520, + nextHeight: 520, + keyboardVisible: false + }) + ).toBe(false) + // Height changed while the keyboard is up → skip (never reflow while typing). + expect( + shouldRefitOnFrameHeightChange({ + previousHeight: 600, + nextHeight: 320, + keyboardVisible: true + }) + ).toBe(false) + }) + + it('routes the height effect through the keyboard-guarded decision helper', () => { + const start = hookSource.indexOf('const prevFrameHeightRef = useRef(terminalFrameHeight)') + expect(start).toBeGreaterThanOrEqual(0) + const heightEffect = hookSource.slice(start, start + 500) + expect(heightEffect).toContain('shouldRefitOnFrameHeightChange({') + expect(heightEffect).toContain('keyboardVisible: keyboardVisibleRef.current') + expect(heightEffect).toContain('viewportMeasuredRef.current = false') + expect(heightEffect).toContain('scheduleViewportRefit()') + }) + it('is wired into the session screen', () => { expect(sessionSource).toContain('useTerminalViewportRefit({') expect(sessionSource).toContain('tabStripVisible: terminals.length > 1') expect(sessionSource).toContain('textScale: terminalTextScale') expect(sessionSource).toContain('connState,') + // The session must feed the frame height and the keyboard-visible ref, or the + // guarded height effect never sees the dock settle / keyboard state. + expect(sessionSource).toContain('terminalFrameHeight,') + expect(sessionSource).toContain('keyboardVisibleRef,') + expect(sessionSource).toContain('keyboardVisibleRef.current = true') + expect(sessionSource).toContain( + 'setTerminalFrameHeight((prev) => (prev === nextHeight ? prev : nextHeight))' + ) }) it('forces a refit on iOS foreground and connection recovery', () => { diff --git a/mobile/src/terminal/terminal-viewport-refit.ts b/mobile/src/terminal/terminal-viewport-refit.ts index 44c6a9b7e03..e8b6aafd876 100644 --- a/mobile/src/terminal/terminal-viewport-refit.ts +++ b/mobile/src/terminal/terminal-viewport-refit.ts @@ -7,7 +7,8 @@ import { shouldRecoverTerminalOnAppStateChange } from './terminal-foreground-rec import { isTerminalUpdateViewportApplied, isTerminalUpdateViewportUpdated, - isTerminalViewportRefitTargetCurrent + isTerminalViewportRefitTargetCurrent, + shouldRefitOnFrameHeightChange } from './terminal-viewport-refit-state' export type TerminalViewportDims = { cols: number; rows: number } @@ -32,6 +33,12 @@ type TerminalViewportRefitOptions = { // tab-strip change. Carries that measured width so those resizes re-fit the PTY; // the 150ms debounce coalesces the stream of drag widths into one settle-time refit. terminalFrameWidth: number + // Why: the frame height settles when the accessory/live-input dock lays out + // after a new agent terminal's first fit; carrying it re-fits the PTY so its + // rows stop overflowing behind the dock. See shouldRefitOnFrameHeightChange. + terminalFrameHeight: number + // Why: gate the height refit so a keyboard-driven resize never reflows the PTY. + keyboardVisibleRef: RefObject unsubscribeTerminal: (handle: string) => void subscribeToTerminal: (handle: string) => void } @@ -56,6 +63,8 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions): tabStripVisible, textScale, terminalFrameWidth, + terminalFrameHeight, + keyboardVisibleRef, unsubscribeTerminal, subscribeToTerminal } = options @@ -226,6 +235,27 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions): scheduleViewportRefit() }, [terminalFrameWidth, viewportMeasuredRef, scheduleViewportRefit]) + // Why: re-fit when the frame height settles after a new agent terminal's + // first fit so its rows stop overflowing behind the dock; the keyboard guard + // keeps an IME resize from reflowing the PTY. The refit's row-count guard + // makes a same-row height change a no-op. + const prevFrameHeightRef = useRef(terminalFrameHeight) + useEffect(() => { + const previousHeight = prevFrameHeightRef.current + prevFrameHeightRef.current = terminalFrameHeight + if ( + !shouldRefitOnFrameHeightChange({ + previousHeight, + nextHeight: terminalFrameHeight, + keyboardVisible: keyboardVisibleRef.current + }) + ) { + return + } + viewportMeasuredRef.current = false + scheduleViewportRefit() + }, [terminalFrameHeight, keyboardVisibleRef, viewportMeasuredRef, scheduleViewportRefit]) + useEffect(() => { if (Platform.OS !== 'ios') { return diff --git a/mobile/src/transport/client-context.test.ts b/mobile/src/transport/client-context.test.ts index e9a298b6a74..b2ed60e74e4 100644 --- a/mobile/src/transport/client-context.test.ts +++ b/mobile/src/transport/client-context.test.ts @@ -119,6 +119,7 @@ async function renderHarness(hostId: string): Promise { } beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true connectMock.mockReset() loadHostsMock.mockReset() }) @@ -156,4 +157,76 @@ describe('useHostClient', () => { harness.unmount() }) + + it('does not open a client after the host is closed during an in-flight lookup', async () => { + let resolveHosts: ((hosts: (typeof HOST)[]) => void) | null = null + const hostLookup = new Promise<(typeof HOST)[]>((resolve) => { + resolveHosts = resolve + }) + const fake = makeFakeClient('connected') + connectMock.mockReturnValue(fake) + loadHostsMock.mockReturnValue(hostLookup) + + let closeHost: ((hostId: string) => void) | null = null + let renderer: ReactTestRenderer | null = null + function Probe(): null { + closeHost = useCloseHost() + useHostClient(HOST.id) + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + act(() => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + }) + } finally { + restore() + } + expect(loadHostsMock).toHaveBeenCalledOnce() + if (!closeHost || !resolveHosts || !renderer) { + throw new Error('pending-open harness did not initialize') + } + + act(() => closeHost?.(HOST.id)) + await act(async () => { + resolveHosts?.([HOST]) + await hostLookup + }) + + expect(connectMock).not.toHaveBeenCalled() + expect(fake.closeMock).not.toHaveBeenCalled() + act(() => renderer.unmount()) + }) + + it('does not open a client after provider unmount during an in-flight lookup', async () => { + let resolveHosts: ((hosts: (typeof HOST)[]) => void) | null = null + const hostLookup = new Promise<(typeof HOST)[]>((resolve) => { + resolveHosts = resolve + }) + connectMock.mockReturnValue(makeFakeClient('connected')) + loadHostsMock.mockReturnValue(hostLookup) + + let renderer: ReactTestRenderer | null = null + function Probe(): null { + useHostClient(HOST.id) + return null + } + const restore = suppressReactTestRendererDeprecationWarning() + try { + act(() => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + }) + } finally { + restore() + } + expect(loadHostsMock).toHaveBeenCalledOnce() + act(() => renderer?.unmount()) + await act(async () => { + resolveHosts?.([HOST]) + await hostLookup + }) + + expect(connectMock).not.toHaveBeenCalled() + }) }) diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx index 41f1b5092e8..df54f143528 100644 --- a/mobile/src/transport/client-context.tsx +++ b/mobile/src/transport/client-context.tsx @@ -22,6 +22,7 @@ import { import { connect, type RpcClient } from './rpc-client' import { connectionLogStore } from './connection-log-buffer' import { subscribeConnectionRevivalTriggers } from './connection-revival-triggers' +import { HostClientOpenRegistry } from './host-client-open-registry' import { loadHosts } from './host-store' import type { ConnectionState, HostProfile } from './types' @@ -66,7 +67,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { // Pending opens (avoid two acquire() callers in the same render racing the // host lookup). Keyed by hostId, value is a sentinel resolved when the // entry materialises. - const pendingOpensRef = useRef>>(new Map()) + const pendingOpensRef = useRef(new HostClientOpenRegistry()) // Why: a fast-path cache of already-loaded HostProfiles. Screens that // have run loadHosts() can call primeHosts() to populate this and skip @@ -91,19 +92,18 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { } const closeEntry = useCallback((hostId: string) => { + pendingOpensRef.current.cancel(hostId) + primedHostsRef.current.delete(hostId) const entry = storeRef.current.get(hostId) - if (!entry) { - return - } - entry.unsubState() - entry.client.close() + entry?.unsubState() storeRef.current.delete(hostId) + entry?.client.close() notifyHostState(hostId, 'disconnected') notifyAllHosts() }, []) const openEntry = useCallback(async (hostId: string): Promise => { - const existing = pendingOpensRef.current.get(hostId) + const existing = pendingOpensRef.current.getActivePromise(hostId) if (existing) { await existing return storeRef.current.get(hostId) ?? null @@ -112,7 +112,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { const promise = new Promise((res) => { resolve = res }) - pendingOpensRef.current.set(hostId, promise) + const pendingOpen = pendingOpensRef.current.register(hostId, promise) try { // Why: prefer the primed cache (populated by primeHosts when the @@ -143,6 +143,10 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { } } + if (pendingOpen.cancelled) { + return null + } + // Re-check after any await — another acquire() may have completed. const after = storeRef.current.get(hostId) if (after) { @@ -184,7 +188,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { notifyAllHosts() return entry } finally { - pendingOpensRef.current.delete(hostId) + pendingOpensRef.current.deleteIfCurrent(hostId, pendingOpen) resolve() } }, []) @@ -326,6 +330,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { useEffect(() => { const store = storeRef.current return () => { + pendingOpensRef.current.cancelAll() for (const [hostId] of store) { closeEntry(hostId) } diff --git a/mobile/src/transport/host-client-open-registry.ts b/mobile/src/transport/host-client-open-registry.ts new file mode 100644 index 00000000000..a47ca5a231a --- /dev/null +++ b/mobile/src/transport/host-client-open-registry.ts @@ -0,0 +1,42 @@ +export type HostClientOpenTicket = { + cancelled: boolean + promise: Promise +} + +export class HostClientOpenRegistry { + private readonly pending = new Map() + + getActivePromise(hostId: string): Promise | null { + const ticket = this.pending.get(hostId) + return ticket && !ticket.cancelled ? ticket.promise : null + } + + register(hostId: string, promise: Promise): HostClientOpenTicket { + const ticket = { cancelled: false, promise } + this.pending.set(hostId, ticket) + return ticket + } + + cancel(hostId: string): void { + const ticket = this.pending.get(hostId) + if (ticket) { + ticket.cancelled = true + // Why: the host lookup may never settle; release the registry's strong + // reference immediately while the ticket still cancels its continuation. + this.pending.delete(hostId) + } + } + + deleteIfCurrent(hostId: string, ticket: HostClientOpenTicket): void { + if (this.pending.get(hostId) === ticket) { + this.pending.delete(hostId) + } + } + + cancelAll(): void { + for (const ticket of this.pending.values()) { + ticket.cancelled = true + } + this.pending.clear() + } +} diff --git a/mobile/src/transport/host-credential-cleanup.test.ts b/mobile/src/transport/host-credential-cleanup.test.ts new file mode 100644 index 00000000000..bd483a3280f --- /dev/null +++ b/mobile/src/transport/host-credential-cleanup.test.ts @@ -0,0 +1,273 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + loadPendingHostCredentialCleanup, + loadPendingHostCredentialCleanupIds, + resetHostCredentialCleanupForTests, + retryPendingHostCredentialCleanups, + scheduleHostCredentialCleanup, + subscribePendingHostCredentialCleanup +} from './host-credential-cleanup' + +const asyncStorageMock = vi.hoisted(() => ({ + getItem: vi.fn(), + setItem: vi.fn() +})) + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: asyncStorageMock +})) + +const PENDING_STORAGE_KEY = 'orca:pending-host-credential-cleanups' +let storedPendingIds: string[] +let readShouldFail = false + +async function flushMicrotasks(): Promise { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() +} + +describe('host credential cleanup', () => { + beforeEach(() => { + vi.useRealTimers() + vi.clearAllMocks() + resetHostCredentialCleanupForTests() + storedPendingIds = [] + readShouldFail = false + asyncStorageMock.getItem.mockImplementation(async (key: string) => { + if (key !== PENDING_STORAGE_KEY) { + return null + } + if (readShouldFail) { + throw new Error('async storage unavailable') + } + return JSON.stringify(storedPendingIds) + }) + asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => { + if (key === PENDING_STORAGE_KEY) { + storedPendingIds = JSON.parse(raw) as string[] + } + }) + }) + + afterEach(() => { + vi.useRealTimers() + resetHostCredentialCleanupForTests() + }) + + it('clears the durable intent after a successful cleanup', async () => { + const deleteCredential = vi.fn().mockResolvedValue(undefined) + + await scheduleHostCredentialCleanup('host-1', deleteCredential, 20) + await vi.waitFor(async () => { + await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual([]) + }) + expect(deleteCredential).toHaveBeenCalledOnce() + }) + + it('persists cleanup intent before a rejected delete for manual retry', async () => { + const deleteCredential = vi.fn().mockRejectedValue(new Error('keychain unavailable')) + const listener = vi.fn() + const unsubscribe = subscribePendingHostCredentialCleanup(listener) + + await scheduleHostCredentialCleanup('host-1', deleteCredential, 20) + await vi.waitFor(() => expect(deleteCredential).toHaveBeenCalledOnce()) + await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual(['host-1']) + expect(listener).toHaveBeenCalled() + unsubscribe() + }) + + it('records intent before a stalled delete times out', async () => { + vi.useFakeTimers() + const deleteCredential = vi.fn(() => new Promise(() => undefined)) + + await scheduleHostCredentialCleanup('host-1', deleteCredential, 3_000) + await flushMicrotasks() + await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual(['host-1']) + + await vi.advanceTimersByTimeAsync(3_000) + await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual(['host-1']) + expect(deleteCredential).toHaveBeenCalledOnce() + }) + + it('returns after durable intent without waiting for the native delete', async () => { + vi.useFakeTimers() + const deleteCredential = vi.fn(() => new Promise(() => undefined)) + + await expect( + scheduleHostCredentialCleanup('host-1', deleteCredential, 3_000) + ).resolves.toBeUndefined() + await flushMicrotasks() + + expect(storedPendingIds).toEqual(['host-1']) + expect(deleteCredential).toHaveBeenCalledOnce() + + await vi.advanceTimersByTimeAsync(3_000) + }) + + it('clears a timed-out pending entry when the native delete later succeeds', async () => { + vi.useFakeTimers() + let resolveDelete: (() => void) | null = null + const deleteCredential = vi.fn( + () => + new Promise((resolve) => { + resolveDelete = resolve + }) + ) + + await scheduleHostCredentialCleanup('host-1', deleteCredential, 3_000) + await flushMicrotasks() + expect(deleteCredential).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(3_000) + await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual(['host-1']) + expect(resolveDelete).not.toBeNull() + + resolveDelete?.() + await vi.waitFor(async () => { + await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual([]) + }) + }) + + it('joins a concurrently observed delete instead of stacking native calls', async () => { + let resolveDelete: (() => void) | null = null + const deleteCredential = vi.fn( + () => + new Promise((resolve) => { + resolveDelete = resolve + }) + ) + + await scheduleHostCredentialCleanup('host-1', deleteCredential, 1_000) + await vi.waitFor(() => expect(deleteCredential).toHaveBeenCalledOnce()) + const readsBeforeSecondAttempt = asyncStorageMock.getItem.mock.calls.length + await scheduleHostCredentialCleanup('host-1', deleteCredential, 1_000) + await vi.waitFor(() => { + expect(asyncStorageMock.getItem.mock.calls.length).toBeGreaterThan(readsBeforeSecondAttempt) + }) + await flushMicrotasks() + expect(deleteCredential).toHaveBeenCalledOnce() + + resolveDelete?.() + await vi.waitFor(async () => { + await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual([]) + }) + }) + + it('starts a fresh native attempt when the user retries after a timeout', async () => { + vi.useFakeTimers() + const deleteCredential = vi + .fn() + .mockImplementationOnce(() => new Promise(() => undefined)) + .mockResolvedValueOnce(undefined) + + await scheduleHostCredentialCleanup('host-1', deleteCredential, 3_000) + await flushMicrotasks() + expect(deleteCredential).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(3_000) + + await expect(retryPendingHostCredentialCleanups(deleteCredential)).resolves.toEqual({ + clearedCount: 1, + remainingIds: [], + storageUnreadable: false + }) + expect(deleteCredential).toHaveBeenCalledTimes(2) + }) + + it('retries only on an explicit call and clears successful ids', async () => { + storedPendingIds = ['host-1', 'host-2'] + const deleteCredential = vi + .fn() + .mockRejectedValueOnce(new Error('still unavailable')) + .mockResolvedValue(undefined) + + expect(deleteCredential).not.toHaveBeenCalled() + await expect(retryPendingHostCredentialCleanups(deleteCredential)).resolves.toEqual({ + clearedCount: 1, + remainingIds: ['host-1'], + storageUnreadable: false + }) + expect(deleteCredential).toHaveBeenCalledTimes(2) + }) + + it('surfaces a fallback handle without clobbering durable ids when the queue write fails', async () => { + storedPendingIds = ['host-a'] + const deleteCredential = vi.fn().mockRejectedValue(new Error('keychain unavailable')) + const listener = vi.fn() + const unsubscribe = subscribePendingHostCredentialCleanup(listener) + + readShouldFail = true + await scheduleHostCredentialCleanup('host-b', deleteCredential, 20) + await vi.waitFor(() => expect(deleteCredential).toHaveBeenCalledOnce()) + readShouldFail = false + + // Durable queue is untouched (not clobbered); the failed-to-record host-b is + // still surfaced via the session-scoped fallback so its orphaned token keeps + // a retry affordance instead of being silently lost. + expect(deleteCredential).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalled() + await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual(['host-a', 'host-b']) + expect(asyncStorageMock.setItem).not.toHaveBeenCalled() + unsubscribe() + }) + + it('reports storageUnreadable when the durable queue cannot be read', async () => { + storedPendingIds = ['host-a'] + + readShouldFail = true + await expect(loadPendingHostCredentialCleanup()).resolves.toEqual({ + ids: [], + storageUnreadable: true + }) + }) + + it('clears the fallback handle once the native delete finally succeeds', async () => { + let resolveDelete: (() => void) | null = null + const deleteCredential = vi.fn( + () => + new Promise((resolve) => { + resolveDelete = resolve + }) + ) + + readShouldFail = true + await scheduleHostCredentialCleanup('host-b', deleteCredential, 1_000) + await vi.waitFor(() => expect(deleteCredential).toHaveBeenCalledOnce()) + readShouldFail = false + await expect(loadPendingHostCredentialCleanup()).resolves.toEqual({ + ids: ['host-b'], + storageUnreadable: false + }) + + resolveDelete?.() + await vi.waitFor(async () => { + await expect(loadPendingHostCredentialCleanup()).resolves.toEqual({ + ids: [], + storageUnreadable: false + }) + }) + }) + + it('cleans a new credential when the same host id is paired and removed again', async () => { + const deleteCredential = vi.fn().mockResolvedValue(undefined) + + await scheduleHostCredentialCleanup('host-1', deleteCredential, 20) + await vi.waitFor(async () => { + await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual([]) + }) + await scheduleHostCredentialCleanup('host-1', deleteCredential, 20) + await vi.waitFor(async () => { + expect(deleteCredential).toHaveBeenCalledTimes(2) + await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual([]) + }) + + expect(deleteCredential).toHaveBeenCalledTimes(2) + await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual([]) + }) + + it('soft-loads empty when pending-cleanup storage is malformed', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValueOnce('{') + + await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual([]) + }) +}) diff --git a/mobile/src/transport/host-credential-cleanup.ts b/mobile/src/transport/host-credential-cleanup.ts new file mode 100644 index 00000000000..d64e37a98dc --- /dev/null +++ b/mobile/src/transport/host-credential-cleanup.ts @@ -0,0 +1,256 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' + +const PENDING_STORAGE_KEY = 'orca:pending-host-credential-cleanups' +const CLEANUP_CONFIRM_TIMEOUT_MS = 3_000 + +type DeleteHostCredential = (hostId: string) => Promise +type CleanupAttemptResult = 'cleared' | 'pending' +type CleanupOutcome = 'cleared' | 'failed' | 'timed-out' +type PendingIdsRead = { ok: true; ids: string[] } | { ok: false } + +export type PendingHostCredentialCleanup = { + ids: string[] + // Why: the durable AsyncStorage queue could not be read. Settings surfaces a + // "pending unknown / retry to be safe" state instead of a silently-empty + // (hidden) section so an orphaned keychain token keeps a recovery affordance. + storageUnreadable: boolean +} + +let pendingMutation: Promise = Promise.resolve() +const pendingListeners = new Set<() => void>() +// Why: concurrent taps/callers share one native operation while it is being +// confirmed. A timed-out operation is released so the next user tap can retry. +const inflightDeletes = new Map>() +// Why: when the durable queue write fails we still need a recovery handle for a +// failed keychain delete. Keep the hostId here (session-scoped) so Settings can +// surface it and offer a retry; cleared once the native delete confirms. +const unrecordedPendingIds = new Set() + +function notifyPendingListeners(): void { + for (const listener of pendingListeners) { + listener() + } +} + +function markUnrecordedPending(hostId: string): void { + if (unrecordedPendingIds.has(hostId)) { + return + } + unrecordedPendingIds.add(hostId) + notifyPendingListeners() +} + +function clearUnrecordedPending(hostId: string): void { + if (unrecordedPendingIds.delete(hostId)) { + notifyPendingListeners() + } +} + +function parsePendingIds(raw: string): string[] | null { + try { + const parsed = JSON.parse(raw) as unknown + if (!Array.isArray(parsed)) { + return null + } + return [...new Set(parsed.filter((value): value is string => typeof value === 'string'))] + } catch { + return null + } +} + +function sameIdList(a: string[], b: string[]): boolean { + return a.length === b.length && a.every((id, index) => id === b[index]) +} + +async function readPendingIdsForMutation(): Promise { + try { + const raw = await AsyncStorage.getItem(PENDING_STORAGE_KEY) + if (raw === null) { + return { ok: true, ids: [] } + } + const ids = parsePendingIds(raw) + if (!ids) { + // Why: refuse to RMW over unreadable payload — treating it as [] would + // wipe durable pending ids on the next add/remove. + return { ok: false } + } + return { ok: true, ids } + } catch { + return { ok: false } + } +} + +async function loadPendingCleanupState(): Promise { + await pendingMutation + const result = await readPendingIdsForMutation() + const fallback = [...unrecordedPendingIds] + if (!result.ok) { + // Why: durable queue unreadable — only the session-scoped fallback is + // known. Report unreadable so callers can surface a retry rather than + // pretend the queue is empty. + return { ids: [...new Set(fallback)], storageUnreadable: true } + } + return { ids: [...new Set([...result.ids, ...fallback])], storageUnreadable: false } +} + +async function mutatePendingIds(update: (ids: string[]) => string[]): Promise { + const mutation = pendingMutation.then(async () => { + const current = await readPendingIdsForMutation() + if (!current.ok) { + throw new Error('pending host credential cleanup storage unreadable') + } + const next = update(current.ids) + if (sameIdList(current.ids, next)) { + return + } + await AsyncStorage.setItem(PENDING_STORAGE_KEY, JSON.stringify(next)) + notifyPendingListeners() + }) + pendingMutation = mutation.catch(() => {}) + return mutation +} + +async function addPendingId(hostId: string): Promise { + await mutatePendingIds((ids) => (ids.includes(hostId) ? ids : [...ids, hostId])) +} + +async function removePendingId(hostId: string): Promise { + await mutatePendingIds((ids) => ids.filter((id) => id !== hostId)) +} + +function observeCleanup(cleanup: Promise, timeoutMs: number): Promise { + return new Promise((resolve) => { + let settled = false + const finish = (outcome: CleanupOutcome) => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + resolve(outcome) + } + const timeout = setTimeout(() => finish('timed-out'), timeoutMs) + cleanup.then( + () => finish('cleared'), + () => finish('failed') + ) + }) +} + +function startOrJoinDelete(hostId: string, deleteCredential: DeleteHostCredential): Promise { + const existing = inflightDeletes.get(hostId) + if (existing) { + return existing + } + const cleanup = Promise.resolve() + .then(() => deleteCredential(hostId)) + .finally(() => { + if (inflightDeletes.get(hostId) === cleanup) { + inflightDeletes.delete(hostId) + } + }) + inflightDeletes.set(hostId, cleanup) + return cleanup +} + +async function recordCleanupIntent(hostId: string): Promise { + try { + await addPendingId(hostId) + return true + } catch { + return false + } +} + +async function confirmNativeCleanup( + hostId: string, + deleteCredential: DeleteHostCredential, + timeoutMs: number +): Promise { + const cleanup = startOrJoinDelete(hostId, deleteCredential) + // Why: attach before observing so a success that races the confirm timeout + // still clears the queue entry (including after timed-out returns). Clears the + // session-scoped fallback too, so a durable-write-failed intent stops being + // surfaced once the native delete finally lands. + const clearWhenDeleted = cleanup.then( + () => { + clearUnrecordedPending(hostId) + return removePendingId(hostId).catch(() => undefined) + }, + () => undefined + ) + const outcome = await observeCleanup(cleanup, timeoutMs) + if (outcome === 'cleared') { + await clearWhenDeleted + return 'cleared' + } + + if (outcome === 'timed-out' && inflightDeletes.get(hostId) === cleanup) { + // Why: timing out is the boundary between automatic work and a future + // user-owned retry. Releasing only the dedupe entry does not start work. + inflightDeletes.delete(hostId) + } + + // Why: failed/unconfirmed attempts stay user-owned; nothing auto-retries. + void clearWhenDeleted + return 'pending' +} + +export async function loadPendingHostCredentialCleanup(): Promise { + return loadPendingCleanupState() +} + +export async function loadPendingHostCredentialCleanupIds(): Promise { + return (await loadPendingCleanupState()).ids +} + +export function subscribePendingHostCredentialCleanup(listener: () => void): () => void { + pendingListeners.add(listener) + return () => pendingListeners.delete(listener) +} + +/** + * Record cleanup intent, then fire-and-forget the native keychain delete so + * removeHost never blocks on SecureStore. If the durable intent write fails, + * keep a session-scoped recovery handle so a failed keychain delete still + * surfaces in Settings instead of orphaning the token with no retry affordance. + */ +export async function scheduleHostCredentialCleanup( + hostId: string, + deleteCredential: DeleteHostCredential, + timeoutMs = CLEANUP_CONFIRM_TIMEOUT_MS +): Promise { + const recorded = await recordCleanupIntent(hostId) + if (!recorded) { + // Why: the only durable recovery handle failed to persist. Hold an in-memory + // one so Settings can still surface + retry; confirmNativeCleanup clears it + // if the native delete lands. removeHost stays non-blocking (freeze fix). + markUnrecordedPending(hostId) + } + void confirmNativeCleanup(hostId, deleteCredential, timeoutMs).catch(() => {}) +} + +export async function retryPendingHostCredentialCleanups( + deleteCredential: DeleteHostCredential +): Promise<{ clearedCount: number; remainingIds: string[]; storageUnreadable: boolean }> { + const pending = await loadPendingCleanupState() + const outcomes = await Promise.all( + // Why: these ids are already durable (or a session-scoped fallback). Re-adding + // intent can race a late success and recreate a ghost row after deletion. + pending.ids.map((id) => confirmNativeCleanup(id, deleteCredential, CLEANUP_CONFIRM_TIMEOUT_MS)) + ) + const remaining = await loadPendingCleanupState() + return { + clearedCount: outcomes.filter((outcome) => outcome === 'cleared').length, + remainingIds: remaining.ids, + storageUnreadable: remaining.storageUnreadable + } +} + +/** Test-only: drop module listeners/in-flight state between cases. */ +export function resetHostCredentialCleanupForTests(): void { + inflightDeletes.clear() + pendingListeners.clear() + unrecordedPendingIds.clear() + pendingMutation = Promise.resolve() +} diff --git a/mobile/src/transport/host-removal-lifecycle.test.ts b/mobile/src/transport/host-removal-lifecycle.test.ts new file mode 100644 index 00000000000..e398062b270 --- /dev/null +++ b/mobile/src/transport/host-removal-lifecycle.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const removeHostMock = vi.hoisted(() => vi.fn()) + +vi.mock('./host-store', () => ({ + removeHost: (hostId: string) => removeHostMock(hostId) +})) + +import { removeHostAndCloseClient } from './host-removal-lifecycle' + +describe('host removal lifecycle', () => { + beforeEach(() => { + removeHostMock.mockReset() + }) + + it('closes the client only after metadata removal commits', async () => { + let commitRemoval: (() => void) | null = null + removeHostMock.mockReturnValue( + new Promise((resolve) => { + commitRemoval = resolve + }) + ) + const closeHostClient = vi.fn() + + const removal = removeHostAndCloseClient('host-1', closeHostClient) + expect(closeHostClient).not.toHaveBeenCalled() + commitRemoval?.() + await removal + + expect(closeHostClient).toHaveBeenCalledWith('host-1') + }) + + it('keeps the client open when metadata removal fails', async () => { + removeHostMock.mockRejectedValue(new Error('storage unavailable')) + const closeHostClient = vi.fn() + + await expect(removeHostAndCloseClient('host-1', closeHostClient)).rejects.toThrow( + 'storage unavailable' + ) + expect(closeHostClient).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/transport/host-removal-lifecycle.ts b/mobile/src/transport/host-removal-lifecycle.ts new file mode 100644 index 00000000000..ba599614804 --- /dev/null +++ b/mobile/src/transport/host-removal-lifecycle.ts @@ -0,0 +1,11 @@ +import { removeHost } from './host-store' + +export async function removeHostAndCloseClient( + hostId: string, + closeHostClient: (hostId: string) => void +): Promise { + // Why: closing before the metadata commit can strand a still-paired host on + // storage failure; closing immediately after success prevents socket leaks. + await removeHost(hostId) + closeHostClient(hostId) +} diff --git a/mobile/src/transport/host-store.test.ts b/mobile/src/transport/host-store.test.ts new file mode 100644 index 00000000000..1f2c878271a --- /dev/null +++ b/mobile/src/transport/host-store.test.ts @@ -0,0 +1,176 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const asyncStorageMock = vi.hoisted(() => ({ + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn() +})) + +const secureStoreMock = vi.hoisted(() => ({ + deleteItemAsync: vi.fn(), + getItemAsync: vi.fn(), + setItemAsync: vi.fn() +})) + +const scheduleCleanupMock = vi.hoisted(() => vi.fn()) + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: asyncStorageMock +})) + +vi.mock('expo-secure-store', () => ({ + WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY', + ...secureStoreMock +})) + +vi.mock('react-native', () => ({ + Platform: { OS: 'ios' } +})) + +vi.mock('./host-credential-cleanup', () => ({ + scheduleHostCredentialCleanup: (...args: unknown[]) => scheduleCleanupMock(...args), + retryPendingHostCredentialCleanups: vi.fn() +})) + +import { removeHost, renameHost, resetHostStoreForTests, updateLastConnected } from './host-store' + +const HOSTS_STORAGE_KEY = 'orca:hosts' +const HOST_ONE = { + id: 'host-1', + name: 'Host 1', + endpoint: 'ws://127.0.0.1:1', + publicKeyB64: 'key', + lastConnected: 0 +} +const HOST_TWO = { + id: 'host-2', + name: 'Host 2', + endpoint: 'ws://127.0.0.1:2', + publicKeyB64: 'key-2', + lastConnected: 0 +} + +describe('host-store list mutations', () => { + let storedHostsRaw: string + + beforeEach(() => { + vi.clearAllMocks() + resetHostStoreForTests() + scheduleCleanupMock.mockReset() + scheduleCleanupMock.mockResolvedValue(undefined) + storedHostsRaw = JSON.stringify([HOST_ONE, HOST_TWO]) + asyncStorageMock.getItem.mockImplementation(async (key: string) => { + if (key === HOSTS_STORAGE_KEY) { + return storedHostsRaw + } + return null + }) + asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => { + if (key === HOSTS_STORAGE_KEY) { + storedHostsRaw = raw + } + }) + }) + + it('commits the removal when credential cleanup scheduling rejects', async () => { + scheduleCleanupMock.mockRejectedValue(new Error('intent storage unavailable')) + + await expect(removeHost(HOST_ONE.id)).resolves.toBeUndefined() + + expect(JSON.parse(storedHostsRaw)).toEqual([HOST_TWO]) + expect(scheduleCleanupMock).toHaveBeenCalledWith(HOST_ONE.id, expect.any(Function)) + }) + + it('awaits cleanup scheduling after metadata commit', async () => { + let resolveSchedule: (() => void) | null = null + scheduleCleanupMock.mockReturnValue( + new Promise((resolve) => { + resolveSchedule = resolve + }) + ) + + let settled = false + const removal = removeHost(HOST_ONE.id).then(() => { + settled = true + }) + await vi.waitFor(() => { + expect(JSON.parse(storedHostsRaw)).toEqual([HOST_TWO]) + }) + expect(settled).toBe(false) + expect(scheduleCleanupMock).toHaveBeenCalledOnce() + + resolveSchedule?.() + await removal + expect(settled).toBe(true) + }) + + it('applies concurrent rename and remove without clobbering either', async () => { + let releaseReads: (() => void) | null = null + const readsReleased = new Promise((resolve) => { + releaseReads = resolve + }) + let pendingReads = 0 + asyncStorageMock.getItem.mockImplementation(async (key: string) => { + if (key !== HOSTS_STORAGE_KEY) { + return null + } + pendingReads += 1 + if (pendingReads <= 2) { + await readsReleased + } + return storedHostsRaw + }) + + const rename = renameHost(HOST_ONE.id, 'Renamed Host') + const remove = removeHost(HOST_TWO.id) + // Both writers have started their RMW and are blocked on the shared read + // gate; without a mutation queue the second would clobber the first. + await vi.waitFor(() => { + expect(pendingReads).toBeGreaterThanOrEqual(1) + }) + releaseReads?.() + await Promise.all([rename, remove]) + + expect(JSON.parse(storedHostsRaw)).toEqual([ + { + ...HOST_ONE, + name: 'Renamed Host' + } + ]) + }) + + it('preserves a rename when lastConnected updates race it', async () => { + const before = Date.now() + await Promise.all([ + renameHost(HOST_ONE.id, 'Alpha'), + updateLastConnected(HOST_ONE.id), + renameHost(HOST_TWO.id, 'Beta') + ]) + + const stored = JSON.parse(storedHostsRaw) as Array + expect(stored).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: HOST_ONE.id, name: 'Alpha' }), + expect.objectContaining({ id: HOST_TWO.id, name: 'Beta' }) + ]) + ) + const hostOne = stored.find((host) => host.id === HOST_ONE.id) + expect(hostOne?.lastConnected).toBeGreaterThanOrEqual(before) + }) + + it('does not wipe the host list when storage is unreadable during mutation', async () => { + storedHostsRaw = '{' + await expect(renameHost(HOST_ONE.id, 'Nope')).rejects.toThrow(/unreadable/) + expect(asyncStorageMock.setItem).not.toHaveBeenCalled() + expect(storedHostsRaw).toBe('{') + }) + + it('resolves instead of rejecting when updateLastConnected hits unreadable storage', async () => { + // Why: callers fire updateLastConnected with `void`; a rejection here would + // surface as an unhandled promise rejection rather than a caught error. + storedHostsRaw = '{' + await expect(updateLastConnected(HOST_ONE.id)).resolves.toBeUndefined() + expect(asyncStorageMock.setItem).not.toHaveBeenCalled() + expect(storedHostsRaw).toBe('{') + }) +}) diff --git a/mobile/src/transport/host-store.ts b/mobile/src/transport/host-store.ts index feb9eb477cb..e37b0427d8c 100644 --- a/mobile/src/transport/host-store.ts +++ b/mobile/src/transport/host-store.ts @@ -8,6 +8,10 @@ import { type StoredHostProfile } from './types' import { getNextHostNameFromHosts } from './host-names' +import { + retryPendingHostCredentialCleanups, + scheduleHostCredentialCleanup +} from './host-credential-cleanup' const STORAGE_KEY = 'orca:hosts' // Why: SecureStore keys must match [A-Za-z0-9._-]; colons are rejected. @@ -66,8 +70,39 @@ async function deleteDeviceToken(hostId: string): Promise { // (cleared on app uninstall, persisted across foreground/background). const tokenCache = new Map() let inflightLoad: Promise | null = null +// Why: rename / lastConnected / remove / save all RMW the same hosts JSON. +// Without a queue, concurrent writers re-read a stale snapshot and the last +// setItem wins — resurrecting a removed host or dropping a rename. +let hostListMutation: Promise = Promise.resolve() + +function parseStoredHosts(raw: string | null): StoredHostProfile[] | null { + if (!raw) { + return [] + } + try { + const parsed = JSON.parse(raw) as unknown + if (!Array.isArray(parsed)) { + return null + } + return parsed.flatMap((item) => { + // Why: pre-v0.0.3 records carry the deviceToken in AsyncStorage. + // Drop them silently — the three pre-launch users will re-pair on + // first run rather than carry a migration shim through the auth path. + if (item && typeof item === 'object' && 'deviceToken' in item) { + return [] + } + const result = StoredHostProfileSchema.safeParse(item) + return result.success ? [result.data] : [] + }) + } catch { + return null + } +} export async function loadHosts(): Promise { + // Why: writers hold the mutation chain across their full RMW; wait so a + // load right after rename/remove does not race a half-written list. + await hostListMutation // Why: deduplicate concurrent loadHosts() calls so multiple screens // mounting simultaneously share one Keychain read pass. if (inflightLoad) { @@ -81,38 +116,18 @@ export async function loadHosts(): Promise { async function doLoadHosts(): Promise { const raw = await AsyncStorage.getItem(STORAGE_KEY) - if (!raw) { - return [] - } - let parsed: unknown - try { - parsed = JSON.parse(raw) - } catch { - return [] - } - if (!Array.isArray(parsed)) { + const storedHosts = parseStoredHosts(raw) + if (!storedHosts) { return [] } const out: HostProfile[] = [] - for (const item of parsed) { - // Why: pre-v0.0.3 records carry the deviceToken in AsyncStorage. - // Drop them silently — the three pre-launch users will re-pair on - // first run rather than carry a migration shim through the auth - // path. - if (item && typeof item === 'object' && 'deviceToken' in item) { - continue - } - const stored = StoredHostProfileSchema.safeParse(item) - if (!stored.success) { - continue - } - - let token = tokenCache.get(stored.data.id) + for (const stored of storedHosts) { + let token = tokenCache.get(stored.id) if (!token) { let fetched: string | null try { - fetched = await readDeviceToken(stored.data.id) + fetched = await readDeviceToken(stored.id) } catch { // Why: a transient Keychain failure for one entry (e.g. // errSecInteractionNotAllowed while the device is briefly locked, @@ -127,37 +142,50 @@ async function doLoadHosts(): Promise { continue } token = fetched - tokenCache.set(stored.data.id, token) + tokenCache.set(stored.id, token) } - out.push({ ...stored.data, deviceToken: token }) + out.push({ ...stored, deviceToken: token }) } return out } async function loadStoredHosts(): Promise { - const raw = await AsyncStorage.getItem(STORAGE_KEY) - if (!raw) { - return [] - } try { - const parsed = JSON.parse(raw) as unknown - if (!Array.isArray(parsed)) { - return [] - } - return parsed.flatMap((item) => { - // Why: same drop-old-records rule as loadHosts; keeps internal - // mutators from re-persisting pre-v0.0.3 entries. - if (item && typeof item === 'object' && 'deviceToken' in item) { - return [] - } - const result = StoredHostProfileSchema.safeParse(item) - return result.success ? [result.data] : [] - }) + return parseStoredHosts(await AsyncStorage.getItem(STORAGE_KEY)) ?? [] } catch { return [] } } +async function readStoredHostsForMutation(): Promise { + try { + const parsed = parseStoredHosts(await AsyncStorage.getItem(STORAGE_KEY)) + if (!parsed) { + // Why: refuse to RMW over unreadable payload — treating it as [] would + // wipe the durable host list on the next rename/remove/save. + throw new Error('host list storage unreadable') + } + return parsed + } catch (error) { + if (error instanceof Error && error.message === 'host list storage unreadable') { + throw error + } + throw new Error('host list storage unreadable') + } +} + +async function mutateStoredHosts( + update: (hosts: StoredHostProfile[]) => StoredHostProfile[] +): Promise { + const mutation = hostListMutation.then(async () => { + const current = await readStoredHostsForMutation() + const next = update(current) + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next)) + }) + hostListMutation = mutation.catch(() => {}) + return mutation +} + function toStored(host: HostProfile): StoredHostProfile { return { id: host.id, @@ -170,51 +198,84 @@ function toStored(host: HostProfile): StoredHostProfile { export async function saveHost(host: HostProfile): Promise { const validated = HostProfileSchema.parse(host) - const hosts = await loadStoredHosts() const stored = toStored(validated) - const index = hosts.findIndex((h) => h.id === stored.id) - if (index >= 0) { - hosts[index] = stored - } else { - hosts.push(stored) - } + await mutateStoredHosts((hosts) => { + const index = hosts.findIndex((h) => h.id === stored.id) + if (index >= 0) { + const next = hosts.slice() + next[index] = stored + return next + } + return [...hosts, stored] + }) // Why: write metadata BEFORE the keychain token so a crash between the two // leaves orphaned metadata (which loadHosts skips and removeHost can clean // up) rather than an orphaned keychain token with no metadata pointer — // the latter would persist forever since removeHost only deletes by hostId // from current metadata. - await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(hosts)) await writeDeviceToken(stored.id, validated.deviceToken) tokenCache.set(stored.id, validated.deviceToken) } export async function removeHost(hostId: string): Promise { - const hosts = await loadStoredHosts() - const filtered = hosts.filter((h) => h.id !== hostId) - await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(filtered)) - await deleteDeviceToken(hostId) + await mutateStoredHosts((hosts) => hosts.filter((h) => h.id !== hostId)) tokenCache.delete(hostId) -} - -export async function renameHost(hostId: string, newName: string): Promise { - const hosts = await loadStoredHosts() - const host = hosts.find((h) => h.id === hostId) - if (host) { - host.name = newName - await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(hosts)) + // Why: await only the durable cleanup intent (AsyncStorage). Native keychain + // delete can reject or stall and must not freeze removeHost / the UI. + try { + await scheduleHostCredentialCleanup(hostId, deleteDeviceToken) + } catch { + // Metadata is already committed; orphan-token recovery is best-effort. } } +export async function retryPendingHostCredentialCleanup(): Promise<{ + clearedCount: number + remainingIds: string[] + storageUnreadable: boolean +}> { + return retryPendingHostCredentialCleanups(deleteDeviceToken) +} + +export async function renameHost(hostId: string, newName: string): Promise { + await mutateStoredHosts((hosts) => { + const index = hosts.findIndex((h) => h.id === hostId) + if (index < 0) { + return hosts + } + const next = hosts.slice() + next[index] = { ...next[index]!, name: newName } + return next + }) +} + export async function getNextHostName(): Promise { + await hostListMutation const hosts = await loadStoredHosts() return getNextHostNameFromHosts(hosts) } export async function updateLastConnected(hostId: string): Promise { - const hosts = await loadStoredHosts() - const host = hosts.find((h) => h.id === hostId) - if (host) { - host.lastConnected = Date.now() - await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(hosts)) + try { + await mutateStoredHosts((hosts) => { + const index = hosts.findIndex((h) => h.id === hostId) + if (index < 0) { + return hosts + } + const next = hosts.slice() + next[index] = { ...next[index]!, lastConnected: Date.now() } + return next + }) + } catch { + // Why: last-connected is a best-effort timestamp and callers fire it with + // `void`. Swallow unreadable-storage failures so they don't surface as an + // unhandled promise rejection. } } + +/** Test-only: drain module mutation chain between cases. */ +export function resetHostStoreForTests(): void { + hostListMutation = Promise.resolve() + tokenCache.clear() + inflightLoad = null +} diff --git a/mobile/src/transport/protocol-compat.test.ts b/mobile/src/transport/protocol-compat.test.ts new file mode 100644 index 00000000000..d70ffd61bb1 --- /dev/null +++ b/mobile/src/transport/protocol-compat.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { evaluateCompat } from './protocol-compat' + +describe('evaluateCompat', () => { + it('allows the current mobile app to connect before a protocol-2 desktop updates', () => { + expect( + evaluateCompat({ + desktopProtocolVersion: 2, + desktopMinCompatibleMobileVersion: 2 + }) + ).toEqual({ kind: 'ok' }) + }) +}) diff --git a/mobile/src/transport/use-worktree-resync.ts b/mobile/src/transport/use-worktree-resync.ts new file mode 100644 index 00000000000..ef035cd79a6 --- /dev/null +++ b/mobile/src/transport/use-worktree-resync.ts @@ -0,0 +1,45 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { RpcClient } from './rpc-client' +import type { ConnectionState } from './types' + +// Why (#8498): extracted from HostScreen so the file stays under its +// max-lines budget. Handles the reconnect edge (refetch on transition into +// 'connected') and manual pull-to-refresh, neither of which the steady-state +// focus/embedded polls cover. +export function useWorktreeResync(args: { + client: RpcClient | null + connState: ConnectionState + fetchWorktrees: (opts?: { allowDuringModal?: boolean }) => Promise + fetchRepoMetadata: () => Promise +}): { refreshing: boolean; onRefresh: () => Promise } { + const { client, connState, fetchWorktrees, fetchRepoMetadata } = args + + // Why (#8498): socket-only reconnect left a stale cached snapshot after + // background/sleep. Refetch on the transition INTO 'connected', not every poll tick. + const prevConnStateRef = useRef(connState) + useEffect(() => { + const prev = prevConnStateRef.current + prevConnStateRef.current = connState + if (prev !== 'connected' && connState === 'connected' && client) { + void fetchWorktrees({ allowDuringModal: true }) + void fetchRepoMetadata() + } + }, [connState, client, fetchWorktrees, fetchRepoMetadata]) + + const [refreshing, setRefreshing] = useState(false) + // Why (#8498): let the user force a fresh snapshot instead of the possibly-poisoned cache. + const onRefresh = useCallback(async () => { + if (!client || connState !== 'connected') { + return + } + setRefreshing(true) + try { + await fetchWorktrees({ allowDuringModal: true }) + await fetchRepoMetadata() + } finally { + setRefreshing(false) + } + }, [client, connState, fetchWorktrees, fetchRepoMetadata]) + + return { refreshing, onRefresh } +} diff --git a/native/windows-cli-launcher/OrcaCliLauncher.cs b/native/windows-cli-launcher/OrcaCliLauncher.cs new file mode 100644 index 00000000000..9f479b9abea --- /dev/null +++ b/native/windows-cli-launcher/OrcaCliLauncher.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Specialized; +using System.Diagnostics; +using System.IO; +using System.Text; + +internal static class OrcaCliLauncher +{ + private static int Main(string[] args) + { + try + { + string launcherDirectory = Path.GetDirectoryName(typeof(OrcaCliLauncher).Assembly.Location); + string resourcesDirectory = Directory.GetParent(launcherDirectory).FullName; + string appDirectory = Directory.GetParent(resourcesDirectory).FullName; + string electronPath = Path.Combine(appDirectory, "Orca.exe"); + string cliPath = Path.Combine( + resourcesDirectory, + "app.asar.unpacked", + "out", + "cli", + "index.js" + ); + + if (!File.Exists(electronPath)) + { + Console.Error.WriteLine("Unable to locate Orca.exe next to \"{0}\"", resourcesDirectory); + return 1; + } + + if (!File.Exists(cliPath)) + { + Console.Error.WriteLine("Unable to locate the Orca CLI entrypoint at \"{0}\"", cliPath); + return 1; + } + + ProcessStartInfo startInfo = new ProcessStartInfo + { + FileName = electronPath, + Arguments = BuildArguments(cliPath, args), + UseShellExecute = false + }; + + // Why: launching without cmd.exe preserves embedded newlines while matching the + // packaged batch launcher's Electron-as-Node environment contract. + MoveEnvironmentVariable(startInfo.EnvironmentVariables, "NODE_OPTIONS", "ORCA_NODE_OPTIONS"); + MoveEnvironmentVariable( + startInfo.EnvironmentVariables, + "NODE_REPL_EXTERNAL_MODULE", + "ORCA_NODE_REPL_EXTERNAL_MODULE" + ); + startInfo.EnvironmentVariables["ELECTRON_RUN_AS_NODE"] = "1"; + + using (Process child = Process.Start(startInfo)) + { + child.WaitForExit(); + return child.ExitCode; + } + } + catch (Exception error) + { + Console.Error.WriteLine("Unable to start the Orca CLI: {0}", error.Message); + return 1; + } + } + + private static void MoveEnvironmentVariable( + StringDictionary environment, + string sourceName, + string targetName + ) + { + string value = Environment.GetEnvironmentVariable(sourceName); + environment.Remove(sourceName); + environment.Remove(targetName); + if (value != null) + { + environment[targetName] = value; + } + } + + private static string BuildArguments(string cliPath, string[] args) + { + StringBuilder commandLine = new StringBuilder(QuoteArgument(cliPath)); + foreach (string arg in args) + { + commandLine.Append(' '); + commandLine.Append(QuoteArgument(arg)); + } + return commandLine.ToString(); + } + + private static string QuoteArgument(string value) + { + bool requiresQuotes = value.Length == 0; + for (int index = 0; index < value.Length && !requiresQuotes; index += 1) + { + requiresQuotes = value[index] == '"' || Char.IsWhiteSpace(value[index]); + } + if (!requiresQuotes) + { + return value; + } + + StringBuilder quoted = new StringBuilder("\""); + int backslashCount = 0; + foreach (char character in value) + { + if (character == '\\') + { + backslashCount += 1; + continue; + } + + if (character == '"') + { + quoted.Append('\\', backslashCount * 2 + 1); + quoted.Append('"'); + } + else + { + quoted.Append('\\', backslashCount); + quoted.Append(character); + } + backslashCount = 0; + } + + quoted.Append('\\', backslashCount * 2); + quoted.Append('"'); + return quoted.ToString(); + } +} diff --git a/notes/garble-fuzz-divergences.md b/notes/garble-fuzz-divergences.md deleted file mode 100644 index dde77ac2450..00000000000 --- a/notes/garble-fuzz-divergences.md +++ /dev/null @@ -1,296 +0,0 @@ -# Garble differential fuzz — divergence log - -Findings from the HeadlessEmulator-vs-renderer-twin differential fuzz -(`src/main/daemon/headless-emulator-fidelity.fuzz.test.ts`). Each divergence is -a case where restoring a hidden terminal from its main-side snapshot -(`serialize → replay`, exactly as `applyMainBufferSnapshot` does on reveal) -produces a screen that differs from an always-visible renderer terminal fed the -same bytes. Any such diff is a user-visible garble on reveal. - -## Method - -- Corpus: seeded agent-TUI byte streams (`buildAgentTuiStreamOps`), 3 pane - sizes, PTY-style random chunk splitting. -- Differential: production `HeadlessEmulator` snapshot replayed into a fresh - renderer-parity terminal, compared cell-by-cell (text, per-cell style, - cursor, modes, scrollback) against an always-visible renderer-parity twin. -- Parity confirmed: `createRendererParityTerminal` mirrors the renderer pane's - buffer-affecting options exactly — `scrollback: 5000`, `allowProposedApi`, - `vtExtensions.kittyKeyboard`, `Unicode11Addon`, Orca ZWJ provider (verified - against `buildDefaultTerminalOptions` in - `src/renderer/src/lib/pane-manager/pane-terminal-options.ts` and - `pane-dom-creation.ts`). Render-only options (`minimumContrastRatio`, - `drawBoldTextInBrightColors`, font, cursor, scrollbar) do not alter stored - cell attributes, so their omission is not a source of false diffs. - `windowsMode` is unset in both (matches renderer). Addon versions: - `@xterm/addon-serialize` / `@xterm/headless` / `@xterm/addon-unicode11` all - `*-beta.287` (headless `6.1.0-beta.287`). -- Scan: seeds 1..2000. Every divergence is either the known serialize-wrap bug - (predicate `bufferHasSerializeHostileWrappedRow`, tolerated + counted) or is - listed below. - -## Inventory - -| bug | found by | seeds | classification | -| --- | --- | --- | --- | -| A — serialize wrap null-cell | fidelity (suite 1) | 31, 157, 171, 207, 423, 426, 502, 801, 815, 826, 865, 881, 923, 977, 1004, 1119, 1142, 1238, 1241, 1318, 1351, 1374, 1532, 1601, 1657, 1728, 1770 (27 in 1..2000) | (a) real serialize bug, pre-documented + pinned — STILL OPEN | -| B — SGR bold loss (`1;22`) | fidelity (suite 1) | 435, 770, 1321 | (a) real serialize bug — FIXED by the addon patch (intensity-group SGR reorder, config/patches) | -| C — cursor off-by-one at right margin | fidelity (suite 1) | 454, 1696 | (a) real serialize bug — FIXED Orca-side (absolute-cursor epilogue, serializeWithAbsoluteCursor) | -| D — DECSC saved-cursor lost across reveal | reconciliation (suite 2) | seed 3 | (a) real snapshot limitation — FIXED (snapshot re-saves the DECSC register, readSavedCursorRegister) | -| E — snapshot boundary mid-escape-sequence | reconciliation (suite 2) | seed 4 (+~24% of corpus) | (a) real snapshot limitation — FIXED (pendingEscapeTailAnsi carried out-of-band, terminal-partial-escape-tail.ts) | - -Status update (fix/snapshot-decsc-midescape): B/C/D/E repros are UNSKIPPED and -their corpus tolerances removed — only Bug A remains tolerated + counted. Bug D -carries position only (saved SGR/charset are not re-established — the synthetic -ESC 7 saves the serializer's final pen). Bug E's pending tail is a separate -snapshot field written LAST by restorers because any later ESC (e.g. the -post-replay reset) would abort the dangling sequence; its bytes are already -counted by the snapshot seq, so tail-slice arithmetic is unchanged. - -All five bug classes are reproduced by dedicated minimal `test.skip` repros so -they cannot silently regress, AND each is tolerated + counted by its suite's -corpus loop so deep mode surfaces only genuinely NEW divergences: - -- Suite 1 (fidelity): Bug A via `bufferHasSerializeHostileWrappedRow`, Bug B via - `snapshotHasSelfCancellingBoldReset` (matches the `1;22` in the serialized - snapshot), Bug C via `isMarginWrapPendingCursorOffByOne` (cursor x-1 with a - full-width content row). Green at the default 300 and at `FUZZ_ITERATIONS=2000`. -- Suite 2 (reconciliation): Bug E via `prefixEndsMidSequence`. Bug D and the - Bug-C cursor cascade are kept out of the corpus by an append-only racing tail - (no DECSC/cursor motion) and pinned only as standalone repros. Green at the - default 200 and at `FUZZ_ITERATIONS=1000`. - -Each tolerance has a `< max(3, ITERATIONS*0.5)` guard so a predicate that starts -tripping on most seeds fails the suite instead of silently swallowing it. - -Seed 113 (called out in the handoff as a "DECSC/DECRC detour writing colored -text mid-line") does not diverge on the current harness. It is a `savedCursor -Detour` op seed; DECSC/DECRC SGR carry is correctly preserved by both the -emulator and the serializer here. It was most likely an earlier observation -folded into Bug C (the DECRC cases 1696 also involve `\x1b7`/`\x1b8`), or a -transient during harness construction. No live divergence at 113. - ---- - -## Bug A — SerializeAddon drops null cells at a soft-wrap boundary - -**Classification: (a) real `@xterm/addon-serialize` bug.** Pre-existing; found -and minimized by the prior agent, pinned by two `test.skip` repros in the fuzz -suite (V1 seed 31, V2 seed 157). Full mechanism documented in -`bufferHasSerializeHostileWrappedRow` and the suite's headline comment. - -- **V1 (cell loss):** a wrapped continuation row starting with a NULL cell - passes the addon's wrap-validity ternary, gets skipped with `CUF` which clamps - at the right margin, overwriting the previous row's last cell and shifting the - tail left by one. `cols=20: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ12\r\n' then '\x1b[1A\x1b[1K'`. -- **V2 (stray `-` filler):** a wrapped pair whose source row is entirely null - takes the forced-wrap "magic" path; cleanup emits `ESC[0C` (param 0 → 1) so - the ECH erase lands one cell right and the first filler `-` survives. - -**Impact:** any snapshot consumer (hidden reveal, parked-tab reveal, sleep/wake, -mobile subscribe replay) paints lost/shifted characters or stray `-` fillers -when a TUI erases inside a soft-wrapped line. Tolerated + counted by the suite; -unskip the repros when upstream fixes or a local serialize post-processor lands. - ---- - -## Bug B — SerializeAddon loses BOLD when serializing a dim→bold-only transition - -**Classification: (a) real `@xterm/addon-serialize` bug.** New finding. - -**Seeds:** 435, 770 (alt-screen), 1321 (minimal, 2 ops). - -**Minimal repro (isolated, no fuzz corpus needed), cols=20:** - -``` -live bytes: "\x1b[2mA\x1b[22m\x1b[1mB" -SerializeAddon → : "\x1b[2mA\x1b[1;22mB" -live cell B: bold=1 dim=0 (style flags 100000) -restored cell B: bold=0 dim=0 (style flags 000000) ← BOLD LOST -``` - -**Mechanism:** cell A is dim, cell B is bold-only. The serializer diffs the pen -from A (dim on) to B (bold on, dim off). To clear dim it appends SGR 22 — but in -xterm/ECMA-48 **SGR 22 resets *both* bold and dim** (`normalIntensity`). So the -emitted `\x1b[1;22m` sets bold then immediately clears it: the restored cell is -neither dim nor bold. Verified directly: writing `\x1b[1;22mX` yields `bold=0`. -(`\x1b[1;2m` — the same-cell dim+bold case — round-trips fine, so the bug is -specific to a dim-cell → bold-only-cell attribute transition.) - -**Why it garbles a real pane:** agent TUIs routinely draw a dim body line then a -bold status/spinner line (Claude Code, Codex). On the live screen the status -line is bold; after a hide→reveal snapshot restore it renders normal-weight. -The seed-1321 live row `⠦ bash: pnpm typecheck` is bold live, non-bold restored. - -**Repro test:** `headless-emulator-fidelity.fuzz.test.ts` → -`it.skip('preserves bold when serializing a dim cell followed by a bold-only cell')`. -Tolerated + counted in the corpus via `snapshotHasSelfCancellingBoldReset`. - ---- - -## Bug C — SerializeAddon cursor restore is off-by-one when the last content row fills the right margin - -**Classification: (a) real `@xterm/addon-serialize` bug.** New finding. - -**Seeds:** 454 (minimal, plain CUP), 1696 (DECSC/DECRC + wide CJK). - -**Minimal repro (isolated, pure serializer replay), cols=10:** - -``` -live bytes: "0123456789\x1b[3;5H" (fill row 0 to the margin, CUP to r3c5) -SerializeAddon → : "0123456789\x1b[2B\x1b[6D" -live cursor: { x: 4, y: 2 } -restored cursor: { x: 3, y: 2 } ← ONE COLUMN SHORT -``` - -Control (`"012\x1b[3;5H"` — row 0 not full) serializes to `"012\x1b[2B\x1b[1C"` -and round-trips the cursor exactly, isolating the trigger to a full-width final -content row. - -**Mechanism:** after emitting a row filled to exactly `cols`, xterm is left in -the *wrap-pending* state (cursor visually on the last column, logically "one -past"). The serializer computes its final cursor-restore as relative -`CUD`/`CUB` moves from that ambiguous position; the horizontal delta is computed -one column short, so the restored cursor lands at `x-1`. Reproduced with pure -`serializeAddon.serialize()` replay into a fresh terminal — **no Orca preamble -or normalization involved**, confirming it is upstream, not Orca's snapshot -path. - -**Why it garbles a real pane:** the cursor is where the next keystroke echoes -and where the block/bar cursor is drawn. On reveal of a TUI whose bottom line -reached the right edge (wide status lines, long prompts), the cursor sits one -cell left of where the live pane had it — visible as a mispositioned prompt -caret or spinner, and subsequent input can overwrite the wrong cell. - -**Repro test:** `headless-emulator-fidelity.fuzz.test.ts` → -`it.skip('restores the cursor exactly when the last content row fills the right margin')`. -Tolerated + counted in the corpus via `isMarginWrapPendingCursorOffByOne`. - ---- - -## Bug D — snapshot does not preserve the DECSC saved-cursor register across a hide/reveal boundary - -**Classification: (a) real bug — a structural snapshot limitation.** New -finding, surfaced by the reveal-reconciliation fuzz (suite 2), not the fidelity -fuzz. - -**Minimal repro (cols=20):** - -``` -hidden bytes: "AB\x1b7\x1b[4;10HCD" (write AB, DECSC saves cursor at r0c2, - move to r3c9, write CD) -tail bytes: "\x1b8X" (DECRC restores the saved cursor, write X) - -live (always visible): rows ["ABX", " CD"] cursor { x: 3, y: 0 } -reveal (snapshot+tail): rows ["XB", " CD"] cursor { x: 1, y: 0 } - ^^ 'X' overwrote 'A' — DECRC landed at home, not r0c2 -snapshotAnsi: "AB\r\n\r\n\r\n\x1b[9CCD" (no saved-cursor state at all) -``` - -**Mechanism:** the snapshot is a serialized *screen* (SerializeAddon) plus a few -rehydrated modes. The VT100 DECSC/DECRC saved-cursor register (also `CSI s` / -`CSI u`) is runtime state that never appears in the serialized buffer, so it -cannot survive a snapshot. When a hidden TUI runs `\x1b7` (or `\x1b[s`) before -the reveal seq and the racing tail (or any post-reveal output) runs `\x1b8` (or -`\x1b[u`), the restore targets the fresh terminal's default saved position -(home) instead of where the TUI saved it — the next writes land at the wrong -cell and overwrite live content. - -**Why it garbles a real pane:** DECSC/DECRC is common in shell prompts and -status-line redraws (save cursor, jump to a corner to paint a clock/token -counter, restore). If the save happens while the pane is hidden and the restore -fires on reveal, the restored paint clobbers the wrong cells. Found by suite-2 -seed 3 (a `savedCursorDetour` op whose `\x1b7` fell in the hidden prefix and -whose `\x1b8` fell in the racing tail after chunk-splitting). - -**Handling:** suite 2 keeps its racing tail append-only (no DECSC/DECRC, cursor -motion, scroll regions, or alt frames) so the seq-reconciliation byte-stitch is -tested in isolation from this and the other terminal-state-loss garbles. Bug D -is instead pinned as a standalone repro, -`hidden-reveal-reconciliation.fuzz.test.ts` → -`it.skip('preserves the DECSC saved-cursor register across a hide/reveal …')`. - -**Fix (applied):** the snapshot epilogue re-establishes the register with -`CUP(saved) + ESC 7 + CUP(actual)` composed in `serializeWithAbsoluteCursor`, -reading the active buffer's core register via `readSavedCursorRegister` -(alt screen yields its own register). Position-only: saved SGR/charset are not -carried. - ---- - -## Bug E — snapshot boundary mid-escape-sequence drops the partial sequence - -**Classification: (a) real bug — a structural snapshot limitation.** New -finding, surfaced by the reveal-reconciliation fuzz (suite 2). - -**Minimal repro (cols=20):** - -``` -hidden prefix: "AB\x1b[3" (write AB, then ESC [ 3 — no final byte yet) -tail bytes: "mCD" ('m' completes ESC[3m = italic, then CD) - -live (always visible): rows ["ABCD"] (ESC[3m parsed atomically, CD italic) -reveal (snapshot+tail): rows ["ABmCD"] ← 'm' became a literal character -snapshotAnsi: "AB" (the partial ESC[3 is in the parser, gone) -``` - -**Mechanism:** a PTY read (one delivery record) can split an escape sequence. -If the pane is revealed while the emulator's parser sits mid-`ESC[…`, the -serialized SCREEN cannot carry the partial sequence (it lives in the parser -state machine, not the buffer). The racing tail supplies the sequence's -remaining bytes, but with the prefix gone the terminal parses them as literal -text. Reproduced end-to-end against the real `HeadlessEmulator.getSnapshot`. - -**Why it garbles a real pane:** any TUI whose output is heavy with escape -sequences (all of them) can have a read boundary fall mid-escape; if a reveal -lands in that window the continuation renders as stray literal bytes (a rogue -`m`, `H`, digits) injected into the visible text. - -**Reachability:** requires the reveal/snapshot to fire in the gap between the two -halves of a split escape. `main` writes each PTY read to the emulator and -records it as one delivery unit (`session.ts emitSubprocessOutput`), and the -snapshot is taken synchronously at a drain — so the window is a single delivered -record that ended mid-escape. Narrow but real. - -**Handling:** suite 2 tolerates + counts scenarios whose hidden prefix ends -mid-escape-sequence (`prefixEndsMidSequence`), the same way suite 1 tolerates the -serialize wrap bug — it fired on ~24% of the corpus, confirming the class is -common. Pinned by `hidden-reveal-reconciliation.fuzz.test.ts` → -`it.skip('completes an escape sequence split across the hide/reveal boundary')`. - -**Fix (applied):** the emulator tracks the unparsed trailing partial escape at -ingest (`terminal-partial-escape-tail.ts`, committed post-parse like the mouse -mirror) and ships it as `TerminalSnapshot.pendingEscapeTailAnsi`; restorers -write it LAST, after their post-replay resets, so the racing tail's -continuation completes it exactly as live. Snapshot seq already counted those -ingested bytes, so reconcile slicing is unchanged. - ---- - -## Known-legitimate normalization (NOT bugs) - -- **OSC 8 hyperlink underline** — classification (c). xterm marks OSC-8 link - cells underlined; SerializeAddon never re-emits OSC 8. Production restores the - link ranges out-of-band via `snapshot.oscLinks` - (`collectHeadlessOscLinkRanges`), so byte replay keeps the text but drops the - underline by design. Pinned by the passing - `it('drops OSC 8 underline from byte replay but preserves the range …')`. -- **P256→P16 color mode** — classification (c). SerializeAddon re-emits palette - indices 0–15 written as `38;5;N` using classic SGR 30–37/90–97, so a restored - cell reports `CM_P16` where live reported `CM_P256`. Both resolve through the - same 16 theme slots — no visual difference. Canonicalized by - `canonicalColorMode` in the parity fixture. - ---- - -## Corpus vs deep mode - -- **Suite 1** (`headless-emulator-fidelity.fuzz.test.ts`): default - `FUZZ_ITERATIONS=300` (~17s). `FUZZ_ITERATIONS=2000` (~113s) is green — Bugs A, - B, and C are each tolerated + counted by a predicate, so the corpus fails only - on a genuinely new divergence. -- **Suite 2** (`hidden-reveal-reconciliation.fuzz.test.ts`): default - `FUZZ_ITERATIONS=200` (~5s). `FUZZ_ITERATIONS=1000` is green — the racing tail - is append-only, so the only tolerated class is Bug E (`prefixEndsMidSequence`). -- Combined default runtime is ~19s (well under the 60s gate). -- `FUZZ_SEED=`: re-run exactly one seed for a repro (both suites). diff --git a/notes/orca-performance-branch-guide.md b/notes/orca-performance-branch-guide.md deleted file mode 100644 index 870c3813911..00000000000 --- a/notes/orca-performance-branch-guide.md +++ /dev/null @@ -1,532 +0,0 @@ -# orca-performance Branch Guide - -Agent-facing map of every optimization on this branch: what it does, why it exists, -where it lives, and the invariants you must not break when adding to it. The -chronological evidence trail (benchmarks, retractions, A/B protocols) is in -`notes/terminal-performance-initiative.md`; this doc is the _current-state_ view. - -**Context**: Orca's terminal was ~300× slower than Terminal.app under agent load -(DSR-under-load p50 134ms, p99 292ms on v1.4.91; agent-TUI throughput 2.0 MB/s). -As of v1.4.122-rc.1.perf: p50 13.3ms / p99 18.7ms, zero timeouts, throughput -11.8–15.5 MB/s — beats VS Code on 5 of 6 metrics. Goal line still open: 4.5ms -(10× Terminal.app). - -## The pipeline - -``` -shell → pty → daemon (persistence, headless model) → unix socket - → main (ipc/pty.ts: batching, delivery gate, flow control, snapshots) - → IPC → renderer (pty-dispatcher → pty-connection → output scheduler → xterm) -``` - -Main is on the hot path for every byte (unlike VS Code's ptyHost→renderer -MessagePort). The daemon owns sessions so they survive app restarts; it also runs -a headless xterm emulator per pty — the _model_ — which is the source of truth -for screen contents. The renderer terminal is a _view_ that can be discarded and -rebuilt from model snapshots. - -## Optimization inventory - -### 1. Renderer parse-path fixes (the original 16× on agent TUIs) - -- **Parse-clocked scheduler drains** (`pane-terminal-output-scheduler.ts`): drain - cadence follows xterm's actual parse completion instead of fixed timers, so the - queue never outruns the parser. -- **Windowed retained-tail redraw**: TUI repaints (erase-down + redraw) only - re-process a bounded window instead of the full retained tail. Guarded by - differential fuzz `retained-tail-redraw-window.equivalence.test.ts`. -- **Throttled wait-blocked check** (`orca-runtime.ts`): the per-chunk agent - wait-detection (two 256KB waitText builds + multi-pattern scans) now runs at - 50ms cadence with trailing edge + keyword pre-filter. Was ~85% of main's - per-chunk cost. - -### 2. term-speed-2 chain (model/view contract — the architecture) - -Revived from ~38 never-merged branches; kill-switched, default ON. Docs: -`docs/reference/terminal-model-view-contract.md`. - -- **Hidden view parking**: hidden tabs tear down their xterm view entirely - (memory: parked panes cost ~0). -- **Hidden delivery gate** (main): renderer-bound bytes for hidden ptys are - dropped at main — hidden panes receive nothing. Reveal rebuilds the view from a - model snapshot + live chunks after the snapshot's seq. -- **Side-effect authority**: main extracts side-effect facts (bell, title, cwd) - from the model so parked panes stay live in the UI without a view. -- **Model query authority**: main answers terminal queries (DSR/CPR, DA1, OSC - colors) deterministically from the model for hidden panes. -- **Seq/ordered-delivery bookkeeping**: every chunk carries a seq; reveal - reconciliation drops duplicates already covered by the snapshot baseline. - -### 3. Batching & scheduling cadence - -- **Batch windows 8ms → 2ms** in both `daemon-stream-data-batcher.ts` - (`STREAM_DATA_BATCH_INTERVAL_MS`) and `ipc/pty.ts` (`PTY_BATCH_INTERVAL_MS`). - At 9% utilization there is no queue — latency was literally the sum of fixed - batch windows. This one change took dev DSR-load 19→8ms. -- **MessageChannel zero-delay drains** (`pane-terminal-output-scheduler.ts`): - Chromium clamps nested `setTimeout(0)` to ~4ms; posted messages are macrotasks - without the clamp, preserving cooperative yield (input/paint still serviced). - Vitest keeps the timer path (fake timers can't advance channel posts). -- **Input write coalescing** (from main, #7205): renderer input writes coalesce - instead of queuing macrotask-per-keystroke. - -### 4. Backpressure (the correctness spine — read before touching delivery) - -Three cooperating layers, innermost first: - -- **ACK at parse-drain** (`deliverPtyDataWithDeferredAck`, scheduler - `ackCredit`): the renderer credits a chunk when xterm has _parsed_ it (or the - chunk is legitimately discarded), not when IPC delivered it. - **INVARIANT: every delivered chunk credits exactly once — parsed or - discarded.** Every scheduler/pty-connection discard path (backlog replacement, - disposed terminal, reconcile drop, split remainders) must fire the credit. -- **Cumulative ACKs + solicited resync** (`terminal-pty-ack-gate.ts`, - `applyCumulativeAck` in pty.ts): ACKs carry monotonic per-pty processed totals - (TCP-style); main max-merges, so lost ACKs self-heal. Data arriving for a - fully-gated pty triggers a resync probe instead of a timeout reset. The only - timer is a hygiene warn that mutates nothing. Main's 512KB per-pty in-flight - gate + 2MB pendingData cap sit on top. -- **Renderer-pull delivery watchdog** (`terminal-delivery-watchdog.ts`, - `pty:reportRendererDeliveryState` in pty.ts): recovers the field-confirmed - wedge where every main→renderer PUSH channel dies while invoke stays alive - (v1.4.121-rc.0 snapshot; electron#37067 class) — a state the push-ridden - resync probe can never reach. The 15s heartbeat costs one Map upsert per - received chunk and does no IPC while output flows; mutation stays - verified-state-only (the timer decides when to REPORT; the write-off derives - entirely from the renderer's cumulative received totals, never wall-clock, - and a received-but-unparsed window is never written off). Heal = re-attach - push listeners + pull restore markers through the modelRestoreNeeded router. - E2e blackhole harness: `__terminalDeliveryWatchdog`, - `terminal-push-delivery-loss-recovery.spec.ts`. -- **Stale-visibility proof for the hidden gate** (`stale-document-visibility.ts` - and the `shouldWritePtyOutputForeground` fallthrough in pty-connection.ts): - recovers the field-confirmed wedge where macOS occlusion tracking pins - `document.visibilityState` at `'hidden'` after display sleep and never fires - another visibilitychange (v1.4.124-rc.2.perf snapshot: 78MB hidden-gate - dropped across 2 pane-level-visible ptys, transport healthy). Real user - input (keydown/pointerdown/window focus) while the document claims hidden is - a physical contradiction — it latches an override, runs each pane's existing - visibilitychange resync (gate unhide + hidden-output restore), and a genuine - visibilitychange hands authority back. No timers; recovery is purely - event-proven, and the failure bias is safe (a wrong override only restores - pre-gate delivery cost, never drops bytes). Hot-path cost: zero when - visible (same single comparison); one property read per user-interaction - event. E2e: `terminal-stuck-occlusion-recovery.spec.ts` (pins both the - freeze repro and the keystroke recovery, plus the - `hiddenDeliveryGatedVisiblePtyCount` field discriminator). -- **One-paste freeze report** (`terminal-freeze-report.ts`, prod-installed - `await window.__orcaTerminalFreezeReport()`): a single DevTools command that - returns renderer state (visibilityState + stale override, pty:data listener - count, watchdog totals), main's snapshot with a per-pty delivery table - (sent/acked/pending, hidden vs visible-set membership, last send/ACK ages, - window focus flags, power suspend/resume ages, app version), and bounded - breadcrumb rings from BOTH processes (`pty-delivery-diagnostics.ts` shared - ring: 100 entries, same-kind coalescing) recording gate marks, visibility - trust changes, watchdog stalls/heals, restore markers, heal write-offs, - renderer lifecycle resets. Pty ids are redacted to their `@@` suffix — - daemon session ids embed worktree paths. Recording happens only on rare - transitions; the table/report is built only when read. This exists so a - field freeze report never needs a follow-up ask. -- **Hidden/parked exit teardown completeness** (pty-connection.ts kept-exit - guard + `terminal-parked-tab-watchers.ts` exit sidecar): two invariants that - keep a split pane's death near the hidden/park boundary from stranding state - (field incident: a closed setup-split leaf persisted in `root` with no - binding and remounted as a permanently blank pane, unreachable by - dead-session reconcile — it skips ptyId-null panes by design). - (1) The "keep a fresh split whose newborn PTY died" branch is **gated on - `isVisibleRef`** — hidden panes' bytes are gate-withheld, so "no output" - proves nothing there; a hidden newborn death must `closePane`, or the kept - pane becomes a binding-less ghost. (2) A PTY exit that lands **while parked** - reaches ONLY the parked watcher's exit sidecar (hosts' `onPtyExit` needs a - mounted TerminalPane), so the sidecar itself collapses the dead leaf out of - the stored layout via `detachTerminalLayoutLeaf` — a stale binding left - behind reattaches on reveal and the daemon re-creates the exited session id - as a fresh shell (silent pane resurrection). E2e: - `terminal-pane-close-layout-consistency.spec.ts` sweeps close/exit at every - lifecycle phase and asserts leaves(root) == bindings == live panes. -- **Producer flow control** (protocol v19 `pausePty`/`resumePty`, 256KB pause / - 32KB resume watermarks, keyed off **pendingData only** — never renderer - counters; kill switch `PRODUCER_FLOW_CONTROL_ENABLED`, ipc/pty.ts): when main's - buffer grows, the _shell_ blocks. For main-hosted ptys pause is synchronous - (drops impossible); for daemon ptys the pause notify has ~20-30ms socket - latency, so wire-speed bursts can still cross the 2MB cap (known follow-up: - daemon-side self-pacing watermark). -- **Shallow stream-socket write gate + per-session fairness** - (`daemon-stream-data-batcher.ts`, 128KB gate / 64KB safe-split slices / - 4KB small-session bypass / 32MB write-through valve; kill switch - `ORCA_DAEMON_SHALLOW_SOCKET_GATE=0`): the stream socket is one FIFO for - every session — bytes already written can never be overtaken, so a deep - user-space buffer buries a visible pane's echo behind other panes' bulk - (measured 192MB / 6+s under 12 flooding hidden agents). Bulk writes stop at - the gate and hold in the batcher, where the interactive flushSession path - and the deterministic small-session bypass still jump them; socket `drain` - refills. This layer alone bounds echo latency by the shallow depth. -- **Background keep-tail stream thinning + daemon fact authority** - (`daemon-stream-keep-tail-drop.ts` 1MB cap / 512KB keep-tail, - `daemon-background-transient-facts.ts`; kill switch - `ORCA_DAEMON_BACKGROUND_STREAM_DROP=0`): hidden-gated ptys are exempt from - pendingData flow control (main drops their bytes after ingestion), which - let N background agents run unbounded ahead of main. Main mirrors the - hidden-delivery gate to the daemon via the wire-tolerated - `setSessionBackground` notification (introduced in v19; authoritative - thinning requires v20 snapshots, so preserved v19 sessions are explicitly - unthinned; older daemons swallow it) — but a live remote view subscriber - (mobile/web) vetoes backgrounding (`hasRemoteTerminalViewSubscriber`). - Backgrounded sessions' queued output is keep-tail dropped (oldest bytes - replaced by an in-order `dataGap` event; reply-eliciting query bytes are - salvaged so hidden programs never hang on DSR/DA replies); producers are NEVER paused, - so reveal stays instant with zero catch-up. Un-background neither discards - nor force-flushes the queued tail — restore paths read MAIN's model - (hidden-output recovery buffer), so a discarded tail loses a finished - program's last output forever (caught by the ACK-backpressure e2e), and a - 16-pane force-flush dumps ~12MB onto the socket ahead of the reveal's own - bytes; the ordered drain loop delivers it within the budget below. Two - aggregate bounds make that budget real: (1) a GLOBAL background keep - budget (~2MB): per-session keep-tails shrink (512KB → floor 64KB) as more - backgrounded sessions hold queued data, and tighten retroactively when the - count grows — without this, N sub-cap sessions queue N×cap and a worktree - switch waits seconds behind the aggregate (measured 9MB → 2.5s hidden - restore vs the 1.5s budget, probe-verified drain overlap); (2) a - kernel-flush refill sentinel: a held flush pass arms one ~90B empty data - event whose write callback re-flushes when the kernel accepts the - in-flight bytes — without it, held bulk advances one gate-depth per - 'drain' (user-space empty) per event-loop turn (~8MB/s ceiling on a busy - daemon). NOTE: an empty `socket.write('')`'s callback fires immediately - even with megabytes buffered (verified) — the sentinel must be a real - protocol no-op line. Notifications are - structurally lossless: while backgrounded, the DAEMON runs the same shared - scanners main uses (`terminal-output-side-effects.ts`: bell / OSC 133 - command-finished / pr-link / DECSET 2031) over every byte BEFORE drop - decisions and relays facts as in-order `transientFact` events; ordered - `sessionBackgroundMarker` events hand scan authority back and forth - (main suppresses just those four scanners in between), and the emulator's - `partialEscapeTailAnsi` seeds each side's fresh scanner carry so a - sequence split across the handoff neither phantom-fires nor goes missing. - Titles/agent-status stay main-side (they converge from the kept tail and - fuse with synthetic spinner frames). On `dataGap`, main resets its - cross-chunk parse carries, drops the mobile headless mirror (rebuilds from - tail/seeds), and sends the model-restore-needed marker so any - renderer-side buffer heals from the snapshot. Visible ptys are never - touched by this layer. Backlog observability: - `ORCA_DAEMON_STREAM_BACKLOG_FILE=` JSONL - (`daemon-stream-backlog-probe.ts`; events incl. `backgroundKeepTailDrop`, - `setSessionBackground`, `mainBackgroundSync`, `heldWriteThrough`). - Causation A/B (`bench:multi-workspace-typing`): realistic steady rates - (8×192KB/s) don't reproduce even fix-off; burst rates on a loaded machine - do (8×512KB/s + 12 CPU spinners: fix-off p50 293ms/p90 647ms → fix-on p50 - 29ms); extreme 12×1MB/s: fix-off p50 6,146ms → 29ms. - -### 5. Flood resilience (why bulk output can't wedge or lie anymore) - -- **Restore-loop cut** (pty-connection.ts): the hidden-output-restore loop - abandons immediately when a foreground pane's live-chunk queue overflows - (3-iteration hard cap), and a 2s flood-suppression window stops main's own - backpressure drops (`droppedOutput`/`modelRestoreNeeded`) from re-arming - restore — bytes write through, ONE deferred repaint heals after the flood. - This killed a positive feedback loop (restore starves ACKs → main drops → - drop re-arms restore) that caused multi-second renderer stalls. -- **Query survival**: if the 2MB cap ever drops bulk output, embedded terminal - queries are extracted (`terminal-reply-query-extraction.ts`) and answered by - _synthesizing replies on the input path_ (CPR from live buffer, DA1 canned, - OSC via direct responder) — probes and TUIs never hang on a dropped reply. -- Drops are downstream of the model: the daemon ingests every byte, so the - post-flood repaint restores complete, correct content. - -### 6. Wake/sleep recovery - -- powerMonitor resume → `system:resumed` IPC → renderer wake recovery (fixes - WebGL-latch blank-after-sleep that DOM focus/visibilitychange missed). -- Cumulative ACKs make the historical "lost ACKs across suspend pin the global - window forever" wedge (BMW user bug) structurally impossible. - -### 7. Snapshot fidelity (the garble fixes — all fuzz-pinned) - -Reveal-from-snapshot multiplied exposure of serializer defects ~1000×. Five bugs -found by differential fuzzing; four fixed, one tolerated: - -- **B: SGR intensity ordering** — upstream `@xterm/addon-serialize` emitted - `1;22` (22 clears the bold 1 just set). Patched via pnpm patch - (`config/patches/@xterm__addon-serialize@*.patch`): clear-before-set for the - bold/dim group (+2 sibling bare-22 defects). -- **C: cursor off-by-one at wrap-pending margin** — bypassed entirely: - `serializeWithAbsoluteCursor` (`terminal-serialize-absolute-cursor.ts`) - appends absolute CUP from the source terminal's authoritative cursor - (skipped when wrap-pending, where CUP would corrupt). -- **D: DECSC saved-cursor register not serialized** — snapshot appends - `CUP(saved) + ESC 7 + CUP(actual)` when a register exists. -- **E: snapshot mid-escape-sequence** (fired on 24% of fuzz corpus) — - `terminal-partial-escape-tail.ts` is a fold-safe VT-parser-state scanner; the - unparsed tail ships as `TerminalSnapshot.pendingEscapeTailAnsi` and is written - LAST on restore so continuation bytes complete the sequence. Seq accounting - unchanged (the tail is a suffix of bytes ≤ snapshot seq). -- **A (tolerated)**: upstream wrap-null-cell serialize defect — fenced by - `bufferHasSerializeHostileWrappedRow`, the only remaining tolerance. - -## Correctness infrastructure (run these before merging delivery/restore changes) - -- `headless-emulator-fidelity.fuzz.test.ts` — differential: HeadlessEmulator vs - reference xterm, seeded TUI streams. `FUZZ_ITERATIONS=2000` for deep, - `FUZZ_SEED=n` to replay. -- `hidden-reveal-reconciliation.fuzz.test.ts` — property tests: random - hide/reveal boundaries × snapshot seq × racing chunks must equal an - always-visible reference. -- `terminal-snapshot-serialize-roundtrip.test.ts` — the garble repros (unskipped - = regression alarms). -- e2e: `terminal-hidden-view-parking` (incl. 25-cycle park/reveal drift test — - byte-identical vs control), `terminal-parked-memory`, - `terminal-sleep-wake-restore`. -- Scheduler credit-invariant + ack-gate deferred-credit + restore-flood tests - (pane-manager / terminal-pane suites). - -## Benchmarking protocol (hard-won rules) - -- Rig: `tools/benchmarks/terminal-pipeline-bench.mjs` — DSR idle + DSR under - 1MB/s agent-TUI load + DSR-fenced throughput on 4 fixtures. Run _inside_ the - terminal under test. -- Multi-workspace typing rig: `pnpm bench:multi-workspace-typing -- --panes 12 ---rate-kbps 1024 --keys 32 --cadence-ms 250 [--cpu-workers 8] --label ` - — real keystrokes (CDP) into a visible pane while N hidden-worktree panes - replay paced agent-TUI streams through real daemon ptys; decomposes each key - into input-half (keydown→pty, sidecar timestamps) and echo-half - (pty→screen). JSON in `tools/benchmarks/results/`. Noise band at 4×256KB/s: - p50 10-15ms, p90 ≤50ms. The latency signature lives in echo-half; renderer - timer drift staying ~15ms while echo-half grows means the backlog is - upstream of the renderer (daemon socket / main ingest). -- **Bench at 10MB** (`--size-mb 10`). The ACK-at-parse bug shipped because dev - benches used 3MB and never tripped the cap. -- Load-controlled A/B only: alternate builds within one session; dev carries ~2× - day-to-day variance. Never conclude from runs while agents/builds hammer the - machine (two false convictions came from this). -- Never set `ORCA_E2E_USER_DATA_DIR` for benches (arms the e2e ACK gate → hang). -- Packaged builds are truth; dev has ~2× overhead. - -## Release mechanics - -- Perf RCs: `release-cut.yml` workflow_dispatch, `kind=rc ref=orca-performance -version_suffix=perf` → tags like `v1.4.122-rc.1.perf`. The suffix sorts above - its base rc.N but below rc.N+1 (never hijacks the RC channel). The rc counter - (`release-rc-history.mjs`), telemetry identity classifier, and build guard are - all suffix-aware — a suffixed rc classifies as `rc`. -- cmd/ctrl-click "Check for Updates" fetches the latest perf-tagged release - (PR #7278; merged here) — perf-line users self-update after one manual install. - -## Syncing with main: MERGE, never rebase - -`orca-performance` is a long-lived, shared, continuously-pushed integration -branch — RCs are cut from it and agents branch off it. **Always -`git merge origin/main`; never rebase** (rebasing rewrites pushed history and -strands every RC tag, fix branch, and worktree based on the old commits). -Conflict pattern, established over ~6 syncs: - -1. **Our structure wins; main's semantics graft in.** This branch deliberately - restructures terminal code (shared scanners, single-policy handlers, - model/view split). When main adds a feature inside code we've restructured, - keep our shape and port their new behavior into it. Example: main inlined an - OSC 133 parser to add `onCommandStarted` (133;C); we kept the shared - `createOsc133CommandFinishedScanner` (main's side-effect tracker must parse - byte-identically) and added 133;C support to the shared scanner instead. -2. Preserve the invariants in **Guardrails** below through every resolution — - especially chunk-credit, `pendingData`-keyed flow control, and the - single `handleCommandFinished` policy (byte path AND sideEffect-fact path - route through it). -3. After resolving: `pnpm typecheck`, the terminal-pane + ipc/pty + daemon - suites, and both fuzz suites. Commit the merge with a message stating what - was kept from each side; push. If the push races a moved remote, merge the - remote tip — never `pull --rebase` a merge. -4. If a sync lands anything on the delivery/restore path, re-run a 10MB bench - before the next RC cut. - -## Known limits / next levers (in rough priority order) - -1. Daemon self-pacing: daemon-hosted ptys can cross the 2MB cap for ~20-30ms at - wire speed before `pausePty` bites. Fix: daemon enforces its own watermark - locally (VS Code does this server-side for remotes). -2. Cadence floor to the 4.5ms goal: xterm's 12ms parse slices and remaining - drain cadence dominate the 13.3ms prod p50. -3. utilityProcess router endgame: take main off the per-byte hot path - (VS Code's ptyHost→renderer MessagePort shape). -4. SerializeAddon full-buffer stalls at 50k-row scrollbacks (#5096 follow-up). -5. Peel PRs to main: throughput fixes → batch+MessageChannel → flow control → - term-speed-2 last. PR #7214 is the integration overview; #7260 (wake/ACK) - is open against main separately. - -## Guardrails for future agents - -- The chunk-credit invariant (§4) is the load-bearing one. If you add ANY path - that receives, defers, drops, or splits pty data in the renderer, prove it - credits exactly once. The credit-invariant unit tests are the gate. -- Flow control keys off `pendingData` only. Do not couple it to renderer - counters; the two layers compose because they are independent. -- Snapshot changes must keep seq semantics: a snapshot covers _exactly_ bytes - ≤ its seq (Bug E made this true; don't regress it). Chunks after restore are - reconciled by seq — off-by-N re-triggers duplicate-drop garble. -- Hidden panes must receive nothing (delivery gate) but side-effects and query - replies must stay live via the model. If you add a new query type, wire it - through model authority AND the drop-path synthesis. -- Never add timeout-based recovery that mutates counters (user requirement — - design decision from #7260). Deterministic resync or nothing; hygiene timers - may only log. -- Any change on the delivery/restore path: run both fuzz suites, the roundtrip - tests, the chain e2e trio, AND a 10MB bench before calling it done. - -## Audit 1 - -Completed 2026-07-10 against `orca-performance`. Scope: the daemon → main → -renderer terminal path, with particular attention to hidden delivery, parking, -stream thinning, snapshot fidelity, ACK/backpressure semantics, wake/reattach, -mobile/remote composition, SSH routing, and teardown. The audit treated model -state and user scrollback as correctness requirements, not expendable memory. - -### Findings and fixes - -1. **Hidden-gate handoff owners could undo one another.** A parked watcher and - an unmounting/remounting pane shared one boolean hidden mark; likewise a - retiring pane could report `visible=false` after its replacement had already - reported `visible=true`. Hidden claims are now reference-counted and - visibility is counted per owner (`pty-renderer-delivery-claims.ts`). Eager - pre-mount buffers no longer hold raw-byte delivery interest: they are not a - side-effect consumer, and model-backed hidden output can restore from a - snapshot. This removes the ownership and eager-interest races formerly - listed in Known limits item 6 without weakening parked side effects. The - remaining transient-visibility concern was checked separately: bind and - reconnect reports read `TerminalPane`'s synchronously refreshed - `isVisible && isWorktreeActive` ref, the global effect uses the same - expression, and owner counting prevents a retiring pane from overriding its - replacement. No hidden-worktree `visible=true` report site remains. - -2. **A natural/synthetic daemon exit could overtake final output.** When a - shallow-gated socket had queued data, `daemon-server.ts` wrote the exit event - directly. The final bytes could therefore arrive after `exit`. Exit is now an - ordered control event in `DaemonStreamDataBatcher`; both natural and - synthetic exits flush through the same FIFO. A deep-socket regression test - pins final-data-before-exit ordering. - -3. **Keep-tail thinning could permanently reduce scrollback to the retained - tail.** On `dataGap`, main discarded its headless model, then rebuilt it from - later tail bytes even though the daemon still owned the complete model. Live - daemon snapshots now carry `outputSequence`; the provider exposes an - authoritative `getBufferSnapshot`, accepts the requested scrollback depth, - and main requires that provider snapshot after a gap. Reconciliation starts - in the pre-snapshot absolute sequence domain and runtime sequence accounting - advances across dropped bytes. The daemon remains the source of complete - scrollback instead of making a transport optimization destructive. If that - authoritative RPC is temporarily unavailable, main now returns no snapshot - and lets the renderer retry; it never paints main's known-incomplete tail as - a full recovery. - -4. **Query-salvage copies corrupted the absolute sequence domain.** DSR/DA - bytes salvaged from a dropped region are copies of bytes already counted by - the daemon, not new output. Stream events now distinguish delivered text - from `sequenceChars`; salvaged query data advances by zero while the gap - advances by the original characters. Main can still parse/deliver the query - copy without shifting every later snapshot baseline. - -5. **“Parse-deferred” ACKs were submission-deferred, not parse-deferred.** ACK - credit fired when bytes entered `terminal.write`, before xterm's callback. - Split scheduler chunks also attached `onParsed` to the first slice. ACK - credit is now owned by `pane-terminal-output-ack-credit.ts`, fires after the - final xterm parse callback, and is released exactly once on throw, discard, - or terminal disposal. Submitted-but-unparsed credit is retained until parse - or disposal, so main's flow-control window measures parser work rather than - renderer submission. - -6. **Hidden restore ignored configured scrollback.** The renderer always asked - for 5,000 rows, so users configured for 10k–50k silently lost older history - on a hide/reveal rebuild. Restore now reads the pane's xterm scrollback - option and clamps it through the shared 0–50,000 policy - (`terminal-hidden-restore-scrollback.ts`). - -7. **Active alternate-screen snapshots discarded the normal shell buffer.** - SerializeAddon emits `normal buffer + ?1049h + alternate buffer`; the old - normalization sliced away everything before the last `?1049h`. A restored - TUI looked correct until it exited alternate mode, then returned to empty - history. Snapshots now carry the normal buffer separately in - `scrollbackAnsi`. Fresh reattach and mobile/remote snapshot streams compose - both buffers; an already-alt renderer exits alt, clears/rebuilds the normal - buffer, then re-enters and rebuilds alt. History replay also composes both - buffers, including legacy empty-field compatibility. - - Deep fuzz then found a second two-buffer issue: normal-buffer serialization - can leave its SGR pen active while the separately serialized alternate body - assumes default SGR. The rehydrate boundary now emits `SGR 0` before - `?1049h`, preventing a shell color from tinting restored TUI cells. The - regression proves the TUI is visible immediately and `?1049l` returns to the - original shell history. - -8. **Daemon provider wrappers forwarded only part of the recovery contract.** - A preserved current/legacy daemon could emit `dataGap` through a provider - wrapper, but `DegradedDaemonPtyProvider` omitted `getBufferSnapshot`, while - the ordinary multi-version `DaemonPtyRouter` omitted background hints, gap - events, snapshots, and explicit `sequenceChars`. Both wrappers now route the - complete contract to the provider that owns the session, including requested - 50,000-row recovery and zero-advance query-salvage events. - -9. **Sequence-safe recovery was added without advancing the daemon protocol.** - An already-running v19 daemon could accept background-thinning hints but - could not return the new `outputSequence`, making any resulting gap - impossible to reconcile safely. The authoritative snapshot contract is now - protocol v20. Preserved v19 sessions remain live but are explicitly marked - unthinned; their stale background hint is cleared on the ordered control - socket before `createOrAttach`, while fresh v20 sessions retain keep-tail - performance and full-model recovery. - -### Static audit conclusions - -- Model query authority still captures ownership synchronously at ingestion; - seed/hydration/snapshot writes remain reply-silent, remote view subscribers - retain view authority, and replies use the provider input path (including - daemon shell-ready queuing and SSH routing). -- Hidden/visibility/interest/background-sync/provider-snapshot state is cleared - by the centralized PTY teardown path. Parked watcher timers, byte sidecars, - fact consumers, exit subscriptions, hidden claims, and runtime-title slots - dispose on reveal/exit/worktree shutdown. -- Remote-runtime and SSH PTYs remain excluded from cold parking. SSH hidden - panes still have a main-owned headless model, so mounted hidden-gate restore - is valid; live remote viewers veto daemon background thinning. The new - two-buffer payload is recomposed before mobile/remote snapshot frames. -- Wake recovery keeps its focus/visibility/system-resume listener symmetry and - cancels its settled animation frame on cleanup. No timeout was added that - mutates ACK or delivery counters. -- The one documented upstream SerializeAddon null-cell/wrapped-row defect - remains tolerated. Deep reveal fuzz now uses the same narrow hostile-row - predicate as fidelity fuzz, rather than misclassifying that known serializer - defect as sequence-reconciliation loss. - -### Validation evidence - -- Focused ownership/connection/dispatcher tests: 422 passed. -- Daemon server/batcher/order tests passed, including deep queued-socket exit. -- Broad main/daemon/runtime/RPC/SSH run: 1,854 passed, 5 skipped. Three stale - mocks were updated to assert the new explicit `sequenceChars` argument; the - production behavior was already correct. -- Broad renderer terminal/pane/scheduler/runtime-stream run: 1,934 passed. -- Final restore/roundtrip/history/adapter/runtime/scheduler sweep: 1,315 passed. -- Post-protocol completion sweep: 1,106 affected main/daemon tests passed; - 484 renderer/restore tests passed with 2 expected skips. -- Scheduler throughput harness passed with `ORCA_TERMINAL_PERF_BENCH=1`. -- Required hidden-view parking, parked-memory, and sleep/wake E2E trio: - 7 passed on a fresh v20 Electron build, including byte-identical output - across 25 park/reveal cycles. -- `FUZZ_ITERATIONS=2000` headless-emulator fidelity: passed (120.19s). -- `FUZZ_ITERATIONS=2000` hidden reveal reconciliation: passed (69.77s). It - reproducibly found the SGR boundary bug at seed 16 and the known upstream - wrapped-null-cell case at seed 1221 before the final green run. -- Snapshot roundtrip, retained-tail equivalence, ACK gate, PTY connection, and - remote incomplete-escape regression suites passed. -- Fullscreen real-app headful flow: a real shell wrote normal history, entered - a TUI while its worktree was hidden, restored on reveal, then exited with - `?1049l` back to the original history. The BrowserWindow was fullscreen; - no click/focus occurred before evidence; the restored frame settled for - 1.5s before capture. Measurements: window 1710×1073 at DPR 2; xterm 133×63; - `fitAddon.proposeDimensions()` 133×63; cell width 8px; screen-to-xterm gap - 11px (the scrollbar/remainder, with grid and proposed dimensions equal). - Artifacts: `.tmp/terminal-audit-headful/fullscreen-alt-restore.png` and - `.tmp/terminal-audit-headful/fullscreen-alt-restore-metrics.json`. -- Visible, non-E2E current-build Orca 10MB agent-TUI bench: 10.14 MB/s - (986ms for 10.0MB), DSR idle p50/p90/p99 0.64/6.47/57.82ms, - DSR-under-load 6.72/9.86/13.87ms, zero timeouts. The pane was 115×39, - reported app version 1.4.131-rc.2, and ran on a v20 daemon/session. Result: - `tools/benchmarks/results/terminal-pipeline-audit-v20-20260710-2026-07-10T10-54-45-990Z.json`. -- `pnpm typecheck`, oxlint on every touched TypeScript file, - `pnpm check:max-lines-ratchet`, `git diff --check`, and the E2E production - build all passed. No max-lines bypass was added. diff --git a/notes/orca-serve-terminal-persistence.md b/notes/orca-serve-terminal-persistence.md deleted file mode 100644 index d96488572d4..00000000000 --- a/notes/orca-serve-terminal-persistence.md +++ /dev/null @@ -1,152 +0,0 @@ -# Orca Serve Terminal Persistence - -## Problem - -`orca serve` exposes terminal tabs to paired web clients through the runtime -`session.tabs` API, but the host-side terminal tab registry was process-local. -When the host published an empty session-tabs snapshot for a worktree, the web -client bootstrapped a new terminal, giving the user a fresh shell instead of -the previously running host session. - -The browser must remain stateless for terminal identity. Browser storage is -intentionally sanitized because remote handles become stale after a new pairing -or host restart. - -## Goals - -- Keep the host runtime as the source of truth for paired web terminal tabs. -- Persist `orca serve` terminal tab, leaf, and PTY/session bindings in the - existing workspace session model. -- Hydrate headless `session.tabs` snapshots from host persistence before a web - client decides a worktree has no terminals. -- Mirror the SSH persistence model where it applies, while keeping SSH relay - leases and local serve persistence behind their own provider checks. -- Preserve split-pane identity by routing all activation and attachment through - parent tab id plus leaf id. - -## Non-Goals - -- Do not persist remote handles in browser local storage. -- Do not make browser panes supported in headless `orca serve`. -- Do not redesign the terminal daemon or SSH relay. -- Do not treat a persisted PTY id alone as proof that a live process belongs to - a pane. - -## Design - -### 1. Persist Runtime-Owned Serve Spawns - -The runtime PTY spawn path accepts a main-only `persistHostSessionBinding` flag. -Headless serve sets it when creating session-tab terminals. The PTY handler -then calls `Store.persistPtyBinding` only after validating `worktreeId`, -`tabId`, and stable `leafId`. - -This keeps unrelated renderer-local PTY spawns from writing workspace-session -terminal bindings. - -### 2. Use Stable Session IDs - -Serve-created terminals pass `tabId`, `leafId`, optional `sessionId`, and -`persistHostSessionBinding` into `ptyController.spawn`. - -If a pending hydrated terminal has a persisted PTY/session id, activation -passes that id back to the provider. New serve-owned local sessions use a -nonnumeric `serve-${uuid}` id so they cannot collide with older numeric PTY ids -after restart. - -### 3. Hydrate Headless Snapshots - -Before `list`, `listAll`, subscribe initial emission, activation, close, or -move returns an empty headless state, the runtime hydrates -`mobileSessionTabsByWorktree` from `workspaceSession.tabsByWorktree` and -`terminalLayoutsByTabId`. - -Hydration preserves: - -- parent terminal tab id -- stable leaf id -- title fields -- active tab and active leaf -- split layout and `ptyIdsByLeafId` -- persisted PTY/session id - -Legacy terminal tabs without layout entries are still hydrated using a -deterministic stable leaf id derived from the parent tab id. - -### 4. Materialize Pending Tabs On Activation - -Hydrated terminal surfaces with no live trusted handle are exposed as -`pending-handle`. Activating one in headless serve materializes the exact -parent tab and leaf on the server, then returns a ready terminal surface. - -Explicit leaf activation is exact. If `leafId` is provided and the requested -leaf is missing, the runtime returns `tab_not_found` instead of falling back to -a sibling. - -### 5. Require Trusted PTY Identity - -For headless-hydrated persisted tabs, a live PTY is safe to expose only when -the runtime record already matches the same worktree, parent tab id, and pane -key. A process-list entry with the same PTY id but no pane identity stays -pending, preventing stale or numeric id collisions from attaching the wrong -terminal. - -Renderer-published authoritative session snapshots retain their existing -worktree-only daemon PTY adoption path. - -### 6. Close And Move Without A Renderer - -Headless close and move have no-renderer mutation paths: - -- close hydrates and refreshes first, removes the persisted terminal tab and - layout, updates active pointers, emits a new snapshot, and kills every live - trusted leaf under the closed parent tab -- move updates in-memory and persisted tab order without changing PTY bindings - -This prevents closed or reordered tabs from reverting on reconnect. - -### 7. Renderer Guards - -The web client still drops remote terminal identity from browser storage. It -mirrors the host snapshot and activates pending host mirrors through -`session.tabs.activate`. - -Bootstrap of a default web terminal is allowed only for a fresh empty snapshot -for the active worktree when no local terminal state already exists. Stale empty -snapshots and fresh empty snapshots racing with staged local terminals do not -create duplicates. - -### 8. SSH Parity - -Runtime-owned SSH spawns record remote PTY leases with target-local relay PTY -ids while workspace-session PTY bindings keep app-facing ids. Lease writes are -deferred until after binding persistence succeeds for persisted runtime-owned -spawns, so a failed binding save cannot leave durable SSH lease metadata for a -tab/leaf that was not saved. - -## Edge Cases Covered - -- Browser reload or WebSocket reconnect mirrors host-owned terminal tabs. -- Pending headless terminal activation reuses persisted tab and leaf identity. -- Split panes attach and activate the requested leaf only. -- Removed split leaves fail fast even when siblings remain. -- Legacy tabs without layouts hydrate instead of appearing empty. -- Stale empty snapshots do not bootstrap duplicate terminals. -- Fresh empty snapshots do not bootstrap when local terminal state already - exists. -- Numeric PTY id collisions remain pending until a safe reattach/spawn occurs. -- SSH reattach failure after binding persistence failure leaves no stale lease. - -## Verification Plan - -- Unit-test runtime hydration, activation, close, move, split-leaf exactness, - stale PTY id handling, and legacy no-layout tabs. -- Unit-test PTY persistence gates, local session-id reattach behavior, SSH - lease parity, and persistence failure cleanup. -- Unit-test renderer snapshot bootstrap guards and remote runtime PTY transport - pending-mirror behavior. -- Run adjacent session-tab RPC and web-runtime session tests. -- Run typecheck, lint, and `git diff --check`. -- Launch the Electron dev app from this worktree with an isolated profile and - verify CDP attachment, app identity, store availability, visible boot, and - zero console errors. diff --git a/notes/terminal-performance-initiative.md b/notes/terminal-performance-initiative.md deleted file mode 100644 index 2ac56bbf9e4..00000000000 --- a/notes/terminal-performance-initiative.md +++ /dev/null @@ -1,583 +0,0 @@ -# Terminal Performance Initiative - -Working plan for the `orca-performance` branch. Goal: make Orca's terminal as -performant as the architecture allows, with every claim backed by a number. -Started 2026-07-02. - -## Why (user-reported, from the team meeting) - -1. Typing in the terminal is sometimes laggy — occasionally seconds of delay. -2. Users say the terminal is slower than iTerm (unclear if typing or scrolling). -3. Scrolling in Claude Code / OpenCode is slow. -4. Idle memory is high (1–2 GB). -5. Battery usage is high. - -Goals: legit performance complaints ≤ 1/week; sampled P90 typing/scrolling -latency down significantly; lower memory with 0–1 agents. - -## Ground truth (verified against source, 2026-07-02) - -Research corpus: xterm.js 6 / VS Code / Ghostty internals study (verified -file:line claims) — see the archived digest and the "xterm.js vs Ghostty" -deep-dive. The Orca-specific findings below were re-verified against this -repo's code: - -- **Electron main sits on every terminal byte's path** (daemon → main → - renderer). VS Code ships the same xterm.js but bypasses main entirely: its - ptyHost is a UtilityProcess with a direct MessagePort to each renderer. -- **The PTY producer is never paused.** `acknowledgeDataEvent` is a no-op in - both `LocalPtyProvider` and `DaemonPtyAdapter`. Only main→renderer delivery - is watermarked (512 KB, `src/main/ipc/pty.ts:1374`); main's own buffer can - grow toward a 512 MB cap under flood. VS Code pauses the actual pty at 100k - unacked chars (kernel backpressure blocks the shell). -- Renderer terminals share one thread with the entire React app; xterm.js - parses in 12 ms slices at a documented 5–35 MB/s ceiling. -- Renderer scrollback default is 5,000 rows (`src/shared/terminal-scrollback-policy.ts`), - 5× VS Code's default; 12 B/cell plus per-line JS objects; O(all lines) - reflow on column resize. -- Latency physics: Ghostty ~4 ms median keypress latency, VS Code ~31 ms - (same-library reference), native class 5–10 ms. Realistic target: beat - VS Code, close on iTerm2, eliminate the stall/jank class entirely (P99 - dominates perception). - -## Current state - -Branch `orca-performance` (long-lived testing line, from main @ `8e8a08ac7`): - -1. `tools/benchmarks/terminal-pipeline-bench.mjs` — cross-terminal rig - (see Benchmark protocol below). -2. Merge of PR #7153 = #7150 (freeze/memory: backlog caps, wedge guards, - probe-certified replay release) + #7139 (cooperative drain: paced backlog - draining keeps typing responsive under floods). Post-merge on this base: - `pnpm typecheck` clean, 626 targeted tests green (scheduler, guards, - pty/pty-connection/pty-transport suites). #7153 itself is a disposable - testing PR; #7139 and #7150 land separately on main. - -## Workstreams - -### 1. Baseline benchmarks (now; human-in-terminal required) - -Run the rig in each terminal on the same machine — Orca pane, iTerm2, Ghostty, -Terminal.app, VS Code (T3Code if available): - -``` -node tools/benchmarks/terminal-pipeline-bench.mjs --label - -node tools/benchmarks/terminal-pipeline-bench.mjs report -``` - -These numbers answer "are we actually slower than iTerm, and where," and are -the before/after for everything below. - -### 2. Validate #7153 on orca-performance (this week, extended testing) - -Watch for: typing responsiveness under agent floods, bounded memory, -skip-notice + snapshot repaint on overflow, no permanent input loss. When -validated, land #7139 and #7150 as separate PRs on main. - -### 3. Revive term-speed-2 (the headline structural work) - -History: nwparker's ~38-branch chain (+20k lines) implementing the terminal -model/view contract — hidden view parking, hidden delivery gate, side-effect -authority in main, model query authority, skip-grammar deletion — all -kill-switched, documented in -`origin/nwparker/term-speed-2-architecture-docs:docs/reference/terminal-model-view-contract.md`. -It shipped only in v1.4.78-rc.1, a deliberate personal-testing build; it was -never rejected and never reached main. Directly targets complaints 3–5 -(hidden panes stop receiving bytes and unmount their xterm + WebGL atlases). - -Merge scout (2026-07-02, chain tip into orca-performance): 144 files, 34 -conflicted, 115 hunks. Hotspots: `pty-connection.ts` (31), `pty.ts` (16), -`daemon-pty-adapter.ts` (6), `orca-runtime.ts` (5). -`pane-terminal-output-scheduler.ts` does NOT conflict — #7139/#7150 and the -chain touch different layers; runtime interaction (drain pacing × hidden -gate) still needs deliberate testing. - -Execution: dedicated focused session; resolve on `revive/term-speed-2` off -orca-performance; keep both sides' kill switches; validate with typecheck + -the contract tests listed in the model-view-contract doc + #7153's suites; -merge back to orca-performance for extended testing. Estimated ~1 day of -careful resolution + validation. - -### 4. Remaining stall-bug fixes (parallel, independently shippable) - -The "seconds of delay" class = discrete thread-blocking events, not -steady-state latency: - -- PR #7105 (open): skip synchronous cold-restore replay for live daemon - sessions in doSpawn. -- `SerializeAddon.serialize()` audit: ~1.2 s renderer block at 50k scrollback - rows (#5096 follow-up, never done). Call sites include the mobile snapshot - path (`pty-connection.ts:2861`) and sleep/hibernate serialization. -- #2836 frozen-terminal leads: replay-guard latch, codex-stale gate, uncapped - buffers (repro harness exists). -- Checkpoint-RPC main-thread scrub (measured ~2–10 ms bursts per hot 5 s - tick; small, part of the same program). - -### 5. Producer-side PTY flow control - -Ack-driven pause/resume of the actual PTY through the daemon protocol -(node-pty supports it), watermarks per the xterm.js flow-control guide -(≤500 KB). Converts flood-induced buffered lag into shell blocking — the -correct physics. Sequence after #7139 lands (interacts with its drain pacing). - -Design (2026-07-03, implement after the term-speed-2 revival merges — -same files): - -- Signal source: main already tracks per-pty pending + in-flight - (`pendingData`, `rendererInFlightCharsByPty` in `ipc/pty.ts`). When a - pty's pending exceeds HIGH (256 KB), main asks the producer to pause; - below LOW (32 KB), resume. -- Producer side: two new protocol notifications (`pausePty`/`resumePty`, - protocol vNext, version-gated like `supportsIncrementalCheckpoints`); - daemon `Session` calls node-pty `pause()`/`resume()` — stops reading the - pty fd, kernel buffer fills, the shell blocks on write: true kernel - backpressure, identical physics to VS Code's 100k/5k design. - `LocalPtyProvider` calls pause/resume directly. -- Safety invariants: (1) failsafe auto-resume after 5 s regardless of - watermark, so a lost resume can never wedge a shell; (2) resume on - detach/exit/kill/daemon-reconnect; (3) pause must not suppress the - interactive-echo bypass — with the pipeline fixed (11.5 MB/s dev), the - HIGH watermark is only reachable during genuine floods where echo is - already queued; (4) PTY reads never stop for model/tail ingestion - (term-speed-2 invariant #1) — pause gates the fd read, so daemon-side - emulator state pauses with it, which is correct (state = what was read). -- Tests: watermark transition unit tests, lost-resume failsafe, kill/exit - cleanup, plus an e2e pressure scenario asserting bounded main memory and - a blocked producer (`yes` exits promptly on SIGINT while paused). - -### 6. Extend the measurement rig - -- True keypress→pixel latency: Typometer manual protocol (the DSR probe stops - at the parser reply, before paint). -- Idle memory + battery: per-process RSS breakdown + `powermetrics` sampling - at 0/1/5 agents (goal-3 metric). -- FPS under flood; event-loop-delay probes (`monitorEventLoopDelay`) in - main/daemon/renderer behind a debug flag for pipeline attribution. - -### 7. utilityProcess terminal router (structural endgame; gated on data) - -An Electron UtilityProcess owns the daemon socket and hands each renderer a -MessagePort — VS Code's topology while keeping Orca's detached daemon (warm -reattach). Takes main off the terminal data path entirely; daemon-side -history persistence falls out naturally. Prototype only after baselines show -how much tail latency lives in the main hop. - -### 8. Production P90 telemetry - -Sampled keypress→echo latency + long-task/stall counts from real users; -defines the success criterion and becomes the permanent regression gate. -Design after the local rig stabilizes so the metrics match. - -## Benchmark protocol - -`tools/benchmarks/terminal-pipeline-bench.mjs` measures, from inside any -terminal: - -- **DSR idle latency** — ESC[6n round trips (p50/p90/p99); replies come only - after the parser reaches the query, so it proxies the input pipeline - without keystroke injection. -- **Fenced throughput** — 4 deterministic fixtures (`ascii-log`, `cjk-emoji`, - `agent-tui` — Claude-Code-shaped transcript + DEC-2026 status repaints — - and labeled-pathological `styles-stress`), each run ended by a DSR fence so - xterm.js-class ingest queues can't flatter the result. -- **DSR under load** — latency sampled during a paced 1 MB/s agent-TUI - stream: "typing while the agent works," quantified. - -Rules: same machine, AC power, comparable window size, no tmux/screen, hands -off the keyboard during runs. Never compare numbers across machines. - -## Sequencing - -``` -now: [1] baselines [2] #7153 testing (parallel) -next: [3] term-speed-2 revival (dedicated session) -parallel: [4] stall fixes, [6] rig extensions -after 2/3: [5] flow control -gated: [7] utility router [8] telemetry -``` - -BMW-group crash work remains the team's priority gate above all of this -(#7150's wedge guards overlap it); this plan runs measurement and revival -prep in parallel without displacing it. - -## Findings log - -### 2026-07-02 — baseline + decomposition (results committed in tools/benchmarks/results/) - -Same machine, unattended serial runs (Orca 1.4.91 prod, Terminal.app, Ghostty -1.3.1; iTerm2 not installed, VS Code pending): - -| metric | Orca prod | Terminal.app | Ghostty | -|---|---|---|---| -| DSR idle p50/p99 (ms) | 0.69 / 22.7 | 0.35 / 0.68 | 0.19 / 0.72 | -| DSR under 1 MB/s agent load p50/p99 (ms) | **134 / 292** | 0.45 / 7.9 | 0.21 / 6.1 | -| agent-tui fenced throughput | **2.0 MB/s** | 37 | 78 | -| ascii-log fenced throughput | 13 MB/s | 39 | 93 | - -Decomposition of the 51× agent-tui gap — both pipeline ends are fast: - -- Bare `@xterm/headless` (114×85, scrollback 5000): agent-tui **103 MB/s** - (`terminal-headless-parse-bench.mjs`). The xterm parser is not the problem. -- Daemon `Session` ingest (emulator + pending-output recording + fanout): - agent-tui **103 MB/s** (`session-ingest-throughput.bench.test.ts`, - `ORCA_TERMINAL_PERF_BENCH=1`). The daemon is not the problem. - -Conclusions: (1) idle latency is fine — the extra process hop costs ~0.5 ms, -so the utilityProcess router is deprioritized by data; (2) the crisis is -queueing between daemon egress and renderer parse completion — main -per-chunk processing, the 512 KB delivery/ACK pacing (ACKs fire after -renderer write callbacks, so renderer slowness throttles delivery -multiplicatively), and renderer per-chunk layers above xterm; (3) the -agent-TUI shape (DEC-2026 frames + erase/repaint) is 6.5× worse than plain -text inside Orca while being equal-cost everywhere else — profile it in the -renderer first (task #9). - -### 2026-07-02 — dev-build check of #7139/#7150 (confounded; directional only) - -Dev build of orca-performance (282-col window, 3MB fixtures, dev-mode -overhead): DSR idle p50 0.64 ms (unchanged), **DSR under load p50 161 ms** — -the cooperative-drain branch does not move the under-load class. In -hindsight this is structural: DSR replies are ordered within the output -stream, so the metric measures output-queue depth; #7139 paces draining to -protect input-send responsiveness but cannot reorder the queue. Implications: -(1) the 134 ms-class number is fixed only by shrinking the queue (producer -flow control) or raising drain rate (the 51× throughput hunt); (2) #7153's -own wins (freeze class, bounded memory, input-loss guards) must be validated -with freeze scenarios and real typing, not DSR. Also learned: dev-mode runs -are ~2× slower across the board and fences need `--dsr-timeout-ms` headroom. - -### 2026-07-02 — 51× loss attributed: scheduler fixed-nap drip (task #9) - -The renderer output scheduler (`pane-terminal-output-scheduler.ts`) drained -at most 2×16KB per tick, then slept 4ms (high-priority) / 16ms (background) -regardless of parse speed. Isolation bench (fake timers, instant-parse -terminal — `pane-terminal-output-scheduler-throughput.bench.test.ts`, -`ORCA_TERMINAL_PERF_BENCH=1`): **background cadence = 1.9 MB/s — matching -prod's measured 2.0 MB/s agent-tui ceiling**; foreground = 27 MB/s (only -when arrivals re-poke 0ms drains; Chromium's ~4ms timer clamp makes the -sustained real-world HP ceiling ~8 MB/s). Classification: pty-connection's -`isLatencySensitiveForegroundOutput` routes sizable no-recent-input chunks -to the queue, so floods always ride the drip. - -Fix (committed 9e8bb2243): high-priority drains are now **parse-clocked** — -a pacer re-arms a 0ms drain when xterm's write callback confirms the batch -parsed — and carry 8 writes/tick (128KB ≈ 1.3ms parse). Isolation ceiling: -27 → **117.6 MB/s** (parse-limited). Background cadence deliberately -unchanged (protects the focused pane; hidden panes are term-speed-2's job). -`DRAIN_TIME_BUDGET_MS` still bounds tick work (cooperative-drain intent of -#7139 preserved; its budget-yield test still passes). 621 tests green. - -Open follow-ups from this attribution: (a) end-to-end dev verification (in -progress); (b) whether main's `background:true` delivery marking demotes -visible-pane floods to the background drip — check -`window.__terminalOutputSchedulerDebug` counters in a dev run; (c) ascii-log -gap (13 vs 83 MB/s headless) — likely per-chunk `beforeWrite` side-effect -scanning; profile after (a). - -### 2026-07-03 — THE WHALE: main's retained-tail redraw path is O(tail) per chunk - -Parse-clock fix didn't move end-to-end (agent-tui still 0.7 MB/s dev). Layered -probes (renderer scheduler counters → main whole-method timer → per-section -timers → targeted micro-benches) attributed it fully: - -- Renderer receives only ~350–770 KB/s — it is **starved**, not slow. -- `OrcaRuntime.onPtyData` consumes **~93% of main's event loop** during the - flood (~950 ms/s at ~450 chunks/s ≈ 2.1 ms/chunk). -- All wrapped sub-calls (OSC scanners, agent detect, watchers, headless - track, leaves loop, mobile touch) together: **~3.5%**. The remainder is the - pty-record tail block. -- Micro-bench (`appendNormalizedToTailBuffer` with a real agent-TUI frame - containing `ESC[10A ESC[0J`): **0.888 ms/chunk at a 2,000-line tail** — 32× - the plain-append path. Cause: `appendNormalizedToMultilineTailBuffer` - materializes ~2,001 row objects per chunk (orca-runtime.ts:22324) and - `finalizeRetainedTerminalRows` allocates them all again plus runs a - trailing-whitespace regex per row (:22458) — ~4k allocations + 2k regexes - per tiny chunk, twice the tail length in O(n) passes. Every Claude-Code - frame (cursor-up + erase-below) takes this path; plain logs don't — which - is exactly the measured agent-tui vs ascii asymmetry. - -Chain: TUI flood → O(tail) work per chunk in main → main event loop -saturates → daemon socket backpressures → renderer starved at ~0.4 MB/s → -deep queue → 134 ms DSR-under-load. - -Fix (in progress): run the existing algorithm on a lazy suffix window (the -cursor's maximum upward reach, computed from the chunk) with the untouched -prefix shared by reference; differential fuzz test proves output equality -against the original implementation. Worst case (pathological full-height -cursor-up) falls back to today's cost. - -### 2026-07-03 — windowed-tail fix: partial end-to-end win; next suspect queued - -Dev-build bench after the windowed redraw-tail fix (label dev-tailfix, same -protocol as dev-parseclock): agent-tui **0.7 → 1.0 MB/s (+43%)**, DSR-under- -load **p50 161 → 108 ms, p99 624 → 154 ms (4×)**. Real movement for the -first time, but the pipeline is still far from the renderer's 27–117 MB/s -capacity — another main-side consumer remains hot. - -Next cycle (exact recipe): re-apply the whole-method main probe -(`onPtyDataMs` sampler in `pty.ts` bindProviderListeners) on the fixed -build. If onPtyData still dominates, the remaining O(tail)/per-chunk -suspects in priority order: (1) `buildTerminalWaitText` ×2 per chunk (full -tail join, 0.116 ms/chunk in prod-node isolation — likely 2-4× that in -dev); (2) `normalizeTerminalChunk` (regex over every chunk, never measured); -(3) the per-leaf duplicate tail path when `tailStateMatches` fails. If -onPtyData no longer dominates, probe the main→renderer delivery batching -next. The probe/bench cycle is mechanical: relaunch dev -(`ELECTRON_ENABLE_LOGGING=1 pnpm dev`), `orca-dev terminal create --command -" --label X --size-mb 3 --dsr-timeout-ms 120000"`, grep the log. - -### 2026-07-03 — post-fix attribution: `blockedCheck` is the remaining whale - -Post-windowed-tail probe run (dev build, agent-tui): `onPtyData` still -~90% of main's event loop (~930 ms/s). Bucket split per second: -**blockedCheck ≈ 700–790 ms (~85%)**, waitText ≈ 70, append ≈ 25 (windowed -fix confirmed), normalize ≈ 7, preview ≈ 0. - -Mechanism (orca-runtime.ts:23128 `nextTailHasNewerBlockedReason` + its -callers): per chunk, TWO full wait texts are built (`buildTerminalWaitText` -joins the whole ≤256KB tail), then the check calls `.toLowerCase()` on both -(another ~512KB of string allocation per chunk) and runs multi-pattern -blocked/ready scans (`findTerminalWaitBlockedSignal`, -`findKnownReadyPromptIndex` — lastIndexOf/regex passes over the full text) -— all to timestamp `waitBlockedAt` for `terminal wait`. - -Fix design (next session): blocked/ready prompts are end-anchored — an -actionable prompt is at the END of output. (1) Run the check on a bounded -suffix of the wait text (last ~64 lines / 16KB) instead of the full tail; -(2) cheap pre-filter: skip entirely unless the appended chunk (plus a small -carry for split keywords) can contain a blocked keyword; (3) build the two -wait texts only when the check runs. Verification mirrors the windowed-tail -pattern: keep the full-text check as reference + differential fuzz over -randomized tails/prompts (split-across-chunks cases included — the -`appendCandidateSignal` ordering semantics at :23146 must be preserved), -plus the terminal-wait contract tests. Expected effect: removes ~85% of -remaining onPtyData cost; combined with the two landed fixes should -finally unlock the pipeline toward the renderer's measured 27–117 MB/s. - -### 2026-07-03 — pipeline unlocked: three stacked fixes, 16× throughput, 9× latency - -Dev-build bench with all three fixes (parse-clocked drains 9e8bb2243, -windowed tail 4e08a28cd, throttled blocked-check 66f20258e), label -dev-blockedfix, same protocol/config as prior dev rows: - -| metric | pre-fix dev | +tail fix | +blocked fix | -|---|---|---|---| -| agent-tui MB/s | 0.7 | 1.0 | **11.5** | -| DSR load p50/p99 (ms) | 161 / 624 | 108 / 154 | **18.8 / 24.9** | -| DSR idle p50/p99 (ms) | 0.95 / 21 | 1.09 / 18 | **0.52 / 8.6** | -| ascii-log MB/s | 6.4 | 4.7 | **9.6** | - -The agent-TUI-specific penalty is gone (agent-tui ≈ cjk ≈ ascii now). The -throttled blocked-check delivered the predicted ~85% cut. Dev mode carries -~2× overhead vs prod, so the prod build should land near ~10ms DSR-under- -load — from the 134ms baseline (~13×) — pending a packaged-build rerun. -Remaining floor is structural cadence (8ms daemon batch + 4ms HP drain -ticks + xterm 12ms slices), which flow control (#6) does not target; -re-evaluate the "within 10× of Terminal.app" goal line after a prod -measurement. Next: term-speed-2 revival (#4), then flow control (#6). - -### 2026-07-03 — term-speed-2 revival: merged, green, NOT yet mergeable (perf gate) - -`revive/term-speed-2` pushed (merge a5052c35f, tip 64b6f7abe): 144 files, -typecheck clean, ~2,776 targeted tests green, all three of our fixes -verified present, chain features present and kill-switched (subagent's -six review risks recorded in its report). Bench verdict on the revived -build (dev): DSR-load p50 ~19ms holds, but **throughput regressed ~35% -unconditionally** (agent-tui 11.5 → 7.2–7.4 MB/s; all-switches-OFF round -proved the kill switches are NOT the cost) and idle p50 doubled. - -Attribution so far: main exonerated (whole-method probe: onPtyData ~60ms/s -≈ 6%); renderer reconcile + HP-first selection O(1)-checked; **daemon -CONVICTED by unit bench — `Session` ingest 103 → 39.5/47.7 MB/s (2.2–2.6×) -on the revive branch** (`session-ingest-throughput.bench.test.ts`, -ORCA_TERMINAL_PERF_BENCH=1). Cause: the chain's headless-emulator -restructure (scanner classes / query-reply forwarding / view-attribute -responder) added per-byte cost to the daemon hot path. Chunks reaching -main are now ~5.8KB vs ~650B (daemon emits slower, batches bigger). - -NEXT (fast inner loop — pure unit bench, no app restarts): on -revive/term-speed-2, diff `headless-emulator.ts`/`session.ts` vs -7839fb9db, find the per-chunk scanner cost, restore our bounded-parser -fast paths (the daemon emulator must never pay per-byte JS scanning for -bytes that contain no ESC — same pre-filter pattern as the blocked-check -keyword bypass), verify with the ingest bench back at ~100 MB/s, then -full dev bench expecting blockedfix parity (~11.5 MB/s), THEN merge to -orca-performance. A residual renderer-side share is possible once the -daemon is fixed — re-attribute after. - -Merge gate: revive branch merges only at ≥ blockedfix numbers. - -**RETRACTION (2026-07-03, later):** the daemon conviction above was a -confounded measurement — the 39–48 MB/s ingest runs executed while a dev -app was still running. On a quiet machine the revive branch ingests at -**82–109 MB/s** (≈ pre-merge) and its HeadlessEmulator alone does 99.5 MB/s -vs raw xterm 77.7. The daemon is innocent. Consequently the end-to-end -revival delta (11.5 → 7.2/7.4 dev) is also UNTRUSTED — none of those runs -were load-controlled, and unit benches show up to 2.6× machine-load -variance. Scanner pre-filters landed anyway on revive (71c89da9b; -strictly positive, 641 daemon tests green). - -**New measurement protocol (mandatory from here):** quiet machine (no dev -apps or benches concurrent), paired A/B runs back-to-back alternating -branches, n≥2 per side, report spread not just p50. The merge-gate -comparison (blockedfix vs revive) must be redone under this protocol -before any verdict. Next: run the controlled A/B; if the delta -disappears, merge revive into orca-performance and proceed to flow -control (#6); if it persists, resume attribution renderer-side (probe -pty-connection dataCallback additions per chunk). - -### 2026-07-03 — A/B gate passed; term-speed-2 MERGED to orca-performance - -Load-controlled alternating A/B (fresh app per run, n=2/side, agent-tui + -DSR-load): perf 6.7/5.2 MB/s, dsr p50 19.9/21.3, p99 107.8/218.1; revive -6.1/3.6 MB/s, dsr p50 21.4/20.3, **p99 63.4/26.1**. Verdict: latency p50 -tied, p99 better on revive, throughput within overlapping noise (revive2's -3.6 followed two runtime-busy create failures). The earlier "35% -regression" is confirmed noise. Note: both branches ~5-7 MB/s today vs -11.5 yesterday — dev benches carry ~2x day-to-day machine variance; -absolute dev numbers are only comparable within one A/B session. - -Merged revive/term-speed-2 → orca-performance; typecheck clean, 288 -post-merge spot tests green. orca-performance now = main-ish base + #7153 -+ three perf fixes + full term-speed-2 chain (kill-switched, default ON) -+ scanner pre-filters. Extended user testing now covers everything. -Remaining from the revival agent's risk list: gate×drain e2e specs -(terminal-hidden-*, parked-memory, sleep-wake) still not run — queue them. -Next: producer flow control (#6) per design §5; prod packaged-build bench -for the real headline numbers. - -### 2026-07-03 — flow control merged; goal-state accounting - -Producer flow control merged to orca-performance (348aeb325): protocol -v19 `pausePty`/`resumePty`, 256KB/32KB watermarks on main's pendingData, -node-pty kernel backpressure, 5s daemon-side lost-resume failsafe + -main-side pause re-assert, resume on every teardown path, version-gated -(v≤18/SSH no-op), kill switch `PRODUCER_FLOW_CONTROL_ENABLED` -(ipc/pty.ts:143), 29 new tests. Typecheck + 292 post-merge spot tests -green. - -**Definition-of-done accounting:** -- 51× loss: ATTRIBUTED AND FIXED (three fixes; agent-tui 0.7→11.5 MB/s - and DSR-load p50 161→18.8 dev, results committed). -- term-speed-2: REVIVED AND MERGED (A/B gate passed). -- Flow control: IMPLEMENTED AND MERGED. -- "Within 10× of Terminal.app (4.5ms)": RE-SCOPED to pending a packaged - RC measurement. Evidence: dev = 18.8ms with ~2× dev overhead → prod - projection ~9-10ms ≈ 20× Terminal.app (vs 300× at baseline). The - remaining gap is structural cadence (daemon 8ms batch, renderer drain - ticks, xterm 12ms parse slices) — tunable follow-ups, distinct from the - waste class this initiative eliminated. Prod verification path: - electron-vite preview CANNOT host the bench (CLI-created panes are not - adopted by the preview window's renderer → no ACKs → pending-cap drop; - two attempts, documented) — measure on the next packaged RC cut from - orca-performance using the committed rig + protocol instead. - -**Deferred, ordered:** (1) sync orca-performance with main — conflicts -incl. stream-opcode collision (chain `Ack=12` vs main's #7205-era -`Metadata=12`; renumber chain side, audit mobile/web stream consumers); -(2) chain's e2e specs (hidden parking / parked memory / sleep-wake) — -gate×drain risk; (3) cadence tuning toward the 10× line; (4) rig -extensions + P90 telemetry (tasks #3/#8). - -### 2026-07-03 — PROD VERDICT: v1.4.121-rc.0 benchmarked (the headline numbers) - -Same rig, same protocol, same machine as the 1.4.91 baseline: - -| metric | 1.4.91 baseline | v1.4.121-rc.0 | change | -|---|---|---|---| -| DSR idle p50 | 0.69 ms | **0.44 ms** | = Terminal.app (0.45) | -| DSR under load p50 | 134 ms | **18.6 ms** | 7.2x | -| DSR under load p99 | 292 ms | **29.7 ms** | 9.8x | -| agent-tui | 2.0 MB/s | **11.2 MB/s** | 5.6x | -| styles-stress | 7.8 MB/s | **10.4 MB/s** | 1.3x | -| ascii-log | 13 MB/s | 11.0 MB/s | ~0.85x | -| cjk-emoji | 15 MB/s | 12.2 MB/s | ~0.81x | - -Reading: the anomalous TUI penalty is GONE — all four fixtures now sit at -a uniform ~11-12 MB/s, which is the scheduler pacing ceiling, not parse -CPU (prod ≈ dev for both latency and throughput; the pipeline is -cadence-bound, so faster prod code changes nothing). That uniform cap -also explains plain-text dipping slightly below baseline: ascii/cjk used -to run unpaced ahead of the old scheduler; now everything flows through -the same parse-clocked path. Goal line check: 18.6 ms = 41x Terminal.app -under load (goal was 10x = 4.5 ms) — NOT met; down from 300x. Idle IS at -parity. The remaining 4x is the named cadence stack (daemon 8 ms batch, -scheduler drain ticks + 8x16KB per-tick budget, xterm 12 ms slices) — -next lever, tunable, tracked as follow-up. p99 tail (the freeze class) -is 29.7 ms — users cannot perceive it. - -Caveat: measured on the user's live app (this session active in it); -idle p99 118 ms reflects that activity, not the terminal path. - -### 2026-07-03 — Same-engine reference: VS Code head-to-head (same machine, same rig) - -| metric | Orca v1.4.121-rc.0 | VS Code | verdict | -|---|---|---|---| -| DSR idle p50 | **0.44 ms** | 7.00 ms | Orca 16x faster | -| DSR load p50 | 18.6 ms | **7.18 ms** | VS Code 2.6x faster | -| DSR load p99 | **29.7 ms** | 43.4 ms | Orca 1.5x better tail | -| ascii-log | **11.0 MB/s** | 9.0 | Orca +22% | -| cjk-emoji | 12.2 | 11.3 | tie | -| agent-tui | 11.2 | 11.7 | tie | -| styles-stress | **10.4 MB/s** | 2.0 | Orca 5.2x | - -Orca now beats or ties the best-known xterm.js terminal on 5 of 6 -metrics — including 16x at idle (what users feel all day) and 5x on -SGR-heavy output — and holds a better p99 tail under load. Throughput -sits at the shared engine ceiling (~9-12 MB/s), confirming the class -limit. - -The one loss (load p50) has a clean mechanism: VS Code's producer flow -control caps unacked output at ~100KB, so its standing queue is -~100KB / 11.7 MB/s ≈ 8.5 ms — matching its 7.18. Our standing queue -(18.6 ms ≈ ~200KB at 11 MB/s) is set by the main→renderer ACK window -(512KB/pty high water) + drain re-arm cadence (Chromium clamps nested -setTimeout to ~4ms). Two levers, both cheap to test: (1) MessageChannel -drain scheduling (sub-ms re-arm; also raises the throughput ceiling); -(2) tighter effective in-flight window on the renderer delivery path. -Target: VS Code's ~7ms class or below without giving back throughput. - -### 2026-07-03 — Batch windows were the gap: dev DSR-load p50 19 -> 8.0ms - -Lever results (dev, 3MB protocol, same session): -- MessageChannel drains (2434dfaae): 19.01ms — NO change. Proved the - ~19ms was NOT queue depth: at 1MB/s vs ~11MB/s capacity (9% util) - there is no standing queue. Kept (correct, removes a real clamp). -- Batch windows 8->2ms on BOTH hops (e67a91d7a: daemon - STREAM_DATA_BATCH_INTERVAL_MS + main PTY_BATCH_INTERVAL_MS): - **p50 8.00 / p90 10.13 / p99 12.26ms** (from 19.01/22.7/28.1). - Throughput unchanged (agent-tui 9.8 vs 9.1, ambient noise). 239 - batcher+pty tests green after timing updates. - -Dev-mode 8.0ms already matches VS Code prod (7.18); prod build should -land BELOW VS Code. p99: ours 12.3 vs VS Code 43.4. The remaining -fixed-latency terms are renderer/xterm-internal (12ms parse slices). -Note: main's interactive bypass (input-gated) means real keystroke echo -skips batching entirely — the DSR metric understates real typing -responsiveness; VS Code measured on the same freight path, comparison -fair. - -Next: cut RC, confirm in prod, re-baseline vs Terminal.app (expect -~8-15x from 300x at baseline; goal line 10x = 4.5ms now plausibly in -reach). - -### 2026-07-03 — Chain e2e debt PAID: all 6 hidden-pane specs green - -terminal-hidden-view-parking (parks + restores rich TUI on reveal; bell/ -title side effects live while parked), terminal-sleep-wake-restore -(output restored + input accepted after wake), terminal-parked-memory -(renderer memory released on park; views retained when kill-switched -off): 6/6 passed, electron-headless, 1.1m. The gate x drain interplay — -the revival's top flagged risk — now has e2e coverage on the exact -branch the RC ships from. Remaining garble-hardening: differential -hide/reveal fuzz harness (next build), reveal-time seq diagnostics. - -## Success criteria (baseline-relative; finalize after task 1) - -- DSR-under-load p90 in Orca within striking distance of iTerm2 on the same - box; zero DSR timeouts (today's freeze class). -- Fenced agent-tui throughput ≥ VS Code on the same box. -- Idle RSS with 0–1 agents materially down (target set after the memory - harness lands; hidden-pane parking is the main lever). -- Zero >100 ms event-loop stalls in main/renderer during a 10 MB flood. -- Production P90 typing latency down and monitored continuously. diff --git a/notes/windows-perf-progress.md b/notes/windows-perf-progress.md deleted file mode 100644 index 51bee65e4f6..00000000000 --- a/notes/windows-perf-progress.md +++ /dev/null @@ -1,306 +0,0 @@ -# Windows Performance Investigation — Progress Log - -Goal: (1) significantly improve Windows startup time (~1 min cold start reported), -(2) fix OpenCode-driven UI freezes, (3) improve overall Windows performance. -All changes must be proven with before/after benchmark numbers. - -## Phase 2 (2026-07-02, branch Jinwoo-H/windows-performance-improvement) — terminal interaction latency - -Complaints: slow workspace switching, slow tab create/switch (terminal-related), occasional crashes. -Harness: `tools/benchmarks/terminal-perf-bench.mjs` (CDP-driven dev app, renderer-clock phase -timings; scenarios tab-create / tab-switch / workspace-switch; local git fixture). -Main-process spawn attribution: `ORCA_PTY_SPAWN_TIMING=1` → `[pty-spawn-timing]` lines -(pty.ts handler phases: preflight/auth/host_env/options/provider_spawn). - -Findings (baseline, this machine): -- Workspace switch: every hide disposed each pane's WebGL context; resume recreated it — - ~5ms macOS, 100-500ms/pane Windows ANGLE (the comment in terminal-visibility-resume.ts - admitted this). Premise (16-context budget) stale since #7064 raised budget to 128. -- Tab create: ~550ms steady state; main handler only ~115ms (host_env≈50ms, daemon - provider_spawn≈68ms). Remainder is renderer-side (xterm open + WebGL context for the new - pane + React mount). First-ever spawn paid +2.7s inside provider_spawn = daemon's first - ConPTY (native module + conpty.dll + OpenConsole + Defender), lazily on the user's first terminal. -- Tab switch: paint settle median 80-99ms; longtasks 64-151ms — every light tab resume runs - scheduleTerminalWebglAtlasRecovery: 3× (frame/120ms/500ms) global shared-atlas clear + - refresh of EVERY pane in EVERY manager. Parse-time recovery (pty-connection.ts - recoverWebglAtlasAfterParse / hiddenOutputNeedsAtlasRecoveryAfterParse) already covers - risky output including hidden. CAUTION: #7058 changed this area and was reverted (#7073) — - left as follow-up. -- LocalPtyProvider spawned without useConptyDll while the daemon path used it (legacy system - ConPTY corruption + perf differences on degraded-mode/fresh-local spawns). - -Fixes on this branch (PR #7080 merged in — WebGL release on dispose + stale pty:exit synthesis): -- A/D: WebGL context retention across hide/show and the suspended-pane atlas recovery scoping - were reverted/parked for more terminal lifecycle testing. Hidden workspaces return to the - previous dispose-on-hide behavior. -- B: useConptyDll for LocalPtyProvider spawns (local-pty-utils.ts) — parity with daemon. -- F: daemon boots a throwaway `cmd.exe /c exit` ConPTY (windows-conpty-warmup.ts) so the - first user terminal doesn't pay the ~2.7s first-ConPTY cost. - -Follow-ups (documented, not in this branch): -- Gate/scope the light-tab-switch atlas burst (see #7058/#7073 history first). Residual - tab-switch cost besides the burst: debounced ResizeObserver re-fit can reflow scrollback - when column count changed while hidden. -- Renderer-side tab-create cost (~400ms): mount chain runs new Terminal() + 5 eager addons - + synchronous attachWebgl (pane-lifecycle.ts:108) before the spawn IPC (deferred one rAF, - pty-connection.ts:5158). Candidate: defer WebGL attach for brand-new panes. -- Cold-restore respawn fan-out: reconnectPersistedTerminals does NOT spawn; the fan-out is - Terminal.tsx mounting a TerminalPane per restored tab at once — each fires connectPanePty - → rAF-deferred spawn IPC (pty-connection.ts:5158) with no concurrency cap. Cap belongs at - that renderer connect layer, not in reconnectPersistedTerminals. -- First terminal opened immediately after launch also waits on the one-time daemon-init - barrier (pty:spawn awaits getLocalPtyStartupPromise, ipc/pty.ts:2518; measured - preflight=0 in the bench because hydration had finished first, but an early Ctrl+T pays - it). In-daemon Windows shell resolution (pwsh -Version probe, PowerShell exe-chain - existsSync/statSync scan) is uncached per spawn; the conpty warm-up spawns cmd.exe so it - does not warm PowerShell resolution. Note the warm-up and an early first spawn serialize - on the daemon's single thread — the 1255ms post-fix first-spawn number is mostly queueing - behind the in-flight warm-up, not unwarmed cost. -- node-pty ≥1.2.0-beta defers conpty connect (spawn returns pid=0 fast) — would stop spawn - storms serializing the daemon loop. - -Pre-existing Windows-only test failures (also on main, CI is ubuntu-only): 5 attribution-shim -PATH assertions in src/main/ipc/pty.test.ts (path-separator artifacts). - -## Status - -- [x] Benchmark harness for startup time (`tools/benchmarks/startup-time-bench.mjs`) -- [x] Startup bottleneck FIXED + verified: **19.31s → 1.80s median** (fixture); - real-world profile was 62s of blocked main thread → now 0 icacls spawns steady-state -- [x] OpenCode freeze ROOT CAUSE found + fixed: MessagePart hook flood (see F5/D2). - Benchmark: 22.9 MB / 540 ms / 400 main-process fanouts per turn → 469 KB / 79 ms / 120 - (legacy vs throttled plugin behavior through the real hook HTTP pipeline) -- [x] General Windows sync-work audit (results below); audit item #2 (readHooksJson per - status IPC) investigated and found NOT hot — renderer barely calls those handlers. - Fixed pre-existing Windows-only test failures (hydrate-shell-path delimiter). -- [x] Windows ConPTY e2e perf validation (F7 below) - -## Key facts / environment - -- Branch: `Jinwoo-H/windows-launch-time` -- Electron app, entry: `src/main/index.ts` (~1557 lines) -- Existing startup diagnostics: `ORCA_STARTUP_DIAGNOSTICS=1` writes `[startup] ` lines to stderr - (`src/main/startup/startup-diagnostics.ts`) -- Prior art: PR #4618 "perf: speed up desktop startup", #5011 "stop main-thread PowerShell ACL storm - on env-store reads", #4526 "Avoid OpenCode config cleanup freezes on Windows", b240d5eee - "Measure startup hydration phases" - -## Follow-ups / known issues (out of scope for this branch) - -- Pre-existing Windows-only unit test failures: `daemon-pty-adapter.test.ts` (61) and - `history-manager.test.ts` (3, chmod-based fs-error simulation is a no-op on Windows). - Identical with/without this branch's changes. CI never sees them (ubuntu-only). -- Consider a Windows CI lane for the terminal-perf e2e suite (F6/F7) and these unit suites. -- Typing-latency load-sensitivity (F7): possible deeper work on daemon checkpoint - scheduling/priority if user reports persist after D3. -- Audit leftovers (F4): non-recursive `grantDirAcl` execFileSync on hook install - (installer-utils.ts:210) could be async; readHooksJson caching unnecessary (not hot). - -### D3 — Async checkpoint writes (implemented) - -`HistoryManager.checkpoint` (every ~5s per dirty session, Electron main process) switched -from writeFileSync+renameSync (~1MB snapshot JSON, inflated by Defender on Windows) to -fs.promises with the same tmp+rename atomicity; ordering preserved by the adapter's -checkpointInFlight guard. - -## Suspects (startup) - -1. **`grantDirAcl(userData, { recursive: true })`** — `src/main/index.ts:517-523`, win32 only, - runs **synchronously on the main process inside `openMainWindow()` before window creation**. - Spawns `icacls /grant:r :(OI)(CI)(F) /T /C` with a **60s timeout**. - The comment itself admits large userData dirs (tens of thousands of Chromium cache files) - can take >10s. This blocks first paint for the whole walk. Matches "1 minute launch" and - "Windows only". -2. Windows Defender real-time scan of exe/asar/native modules on cold start (environmental, - can't fix in code, but reducing file count / sync IO helps). -3. TBD: store sync load, daemon init, i18n init, sherpa-onnx native module load. - -### F3 — Baseline benchmark (2026-06-10) - -Harness: `node tools/benchmarks/startup-time-bench.mjs --label baseline --iterations 3 --files 28000` -(28k-file synthetic Chromium-cache-shaped userData fixture in %TEMP%, headless launch of -the electron-vite build with `ORCA_STARTUP_DIAGNOSTICS=1`, milestones parsed from stderr). - -| phase (median of 3) | baseline | -|---|---| -| spawnToAppReady | 857ms | -| appReadyToServices | 178ms | -| servicesToI18n | 2ms | -| i18nToOpenWindow | 7ms | -| **aclGrantMs** | **15.65s** | -| windowCreatedToLoaded | 1.06s | -| **totalToDidFinishLoad** | **19.31s** | - -ACL walk = 81% of total. (Fixture is kinder than the real profile: same file count but -freshly-written small files → real %APPDATA%\Orca measured 62s for the same command.) -JSON: tools/benchmarks/results/startup-baseline-2026-06-10T19-36-01-305Z.json - -### F4 — Sync main-thread audit (subagent, 2026-06-10) - -Ranked offenders beyond the ACL grant (#1): -2. `readHooksJson` + JSON.parse re-read per agent-status IPC call across ~10 hook services - (`src/main/*/hook-service.ts` via `agent-hooks/installer-utils.ts:50`) — 10-100ms per - status snapshot, all platforms. Remediation: in-memory cache. -3. `whoami.exe` SID resolution (win32-utils.ts:92) — already cached, OK. -4. macOS-only `defaults read` per browser probe — not Windows. -5. `installer-utils.ts:210` non-recursive grantDirAcl on hook install (execFileSync, - 500ms-2s) — infrequent write path, low priority. -6. `secure-file.ts` sync PowerShell on credential write path — by design (#5011), leave. - -## Suspects (OpenCode freeze) - -- User report: UI freezes ~5s after sending prompt; OpenCode session itself continues fine - (visible from external terminal). So the agent process is healthy — the freeze is in Orca's - main process or renderer. Spinner in left panel still animates (= renderer compositor alive? - or just that one timer). Need to find sync main-process work triggered by OpenCode activity. -- Prior fix #4526 "Avoid OpenCode config cleanup freezes on Windows" — re-check that path. - -### Research results (subagent, 2026-06-10) — ranked candidates - -1. **ConPTY output flood vs PTY batching/backpressure** (HIGH): Windows ConPTY re-renders - full TUI frames → 10-100x output volume vs macOS. Batching in `src/main/ipc/pty.ts` - (16KB chunks / 8ms flush, 512KB renderer in-flight window). If renderer xterm.write is - slow, ACKs stall → in-flight fills → main stalls. Tests: terminal-foreground-redraw-freeze, - artificial-opencode-terminal-load e2e. -2. **Sync `runtime.onPtyData` per data event before batching** (MED-HIGH): - `src/main/ipc/pty.ts:1376-1430` → `orca-runtime.ts:3256-3420`: normalizeTerminalChunk + - tail-buffer append + agent-status OSC parsing run synchronously per chunk on main. - Daemon PTY path. High event rate × per-event cost can saturate the main loop. -3. **`mirrorUserConfig` recursive fs work in `buildPtyEnv` on PTY spawn** (MED): - `src/main/opencode/hook-service.ts:359-524` + `pty/overlay-mirror.ts:63-110` — - readdir/safeRemoveTree/symlinks on main thread at spawn; #4526 fixed only clearPty side. - Timing mismatch with "5s after prompt" though. -4. Agent-status event fan-out per OSC title (LOW-MED). 5. Tail-buffer O(n²) (LOW). - -Gap in coverage: no test exercises rapid continuous ConPTY-scale data + sync onPtyData -accumulation on Windows. - -### F5 — ROOT CAUSE (2026-06-10): OpenCode MessagePart hook flood - -Eliminated candidates first: ran `terminal-foreground-redraw-freeze.spec.ts` on THIS Windows -machine (real ConPTY + daemon provider) — passes; renderer output scheduler protections hold. -The raw TUI-output-flood theory doesn't explain an OpenCode-specific permanent freeze. - -The actual mechanism (src/main/opencode/hook-service.ts plugin source): -- OpenCode publishes `message.part.updated` with the FULL accumulated text of the part on - every streamed append (architecture: parts are republished, not deltas). -- Orca's plugin POSTed that full text to the agent-hook server on EVERY event → - **O(n²) bytes per streaming turn**. A 120KB reply in 400 updates = ~23 MB through - loopback HTTP + main-process JSON.parse; real turns are worse (per-token updates). -- Main process spends its whole loop on HTTP receive + parse + normalize + fanout. UI symptom - matches the user report exactly: everything dead (window close needs main + renderer - round-trip), EXCEPT the sidebar agent indicator — which is the one thing fed by the very - agentStatus:set flood that's starving everything else. -- Why Windows-biased: same flood exists on macOS but combines on Windows with ConPTY - full-frame redraw volume and generally slower process IO; also Windows daemon-PTY path - adds main-process onPtyData work. -- Why "5 seconds after sending the prompt": that's when the accumulated text gets big. -- Why OpenCode keeps working: plugin POST failures are swallowed; the session is healthy. -- Downstream payloads were already bounded (prompt 200 chars, lastAssistantMessage 8000 - chars via agent-status-types normalization) — the renderer wasn't the bottleneck; the - main-process ingest was. - -### F7 — Windows ConPTY e2e perf validation (2026-06-10) - -Ran the terminal-perf budget specs on this Windows machine (real ConPTY + daemon PTY -provider — a path CI never exercises): -- `terminal-output-scheduler.spec.ts`: PASS (all tests) -- `terminal-foreground-redraw-freeze.spec.ts`: PASS -- `terminal-typing-latency.spec.ts`: PASSES in isolation, repeatedly — median 13.6-23.1ms, - worst 34-42ms (budgets: 250ms median / 1000ms worst). Two earlier runs that exceeded the - worst-key budget (1054.9ms, 2016.1ms outlier on a single key) occurred while other heavy - tooling (vitest/tsgo/builds) ran concurrently on the machine → load-sensitivity, not a - deterministic product defect. Note the product implication: under heavy host load - (exactly what coding agents generate), a keystroke can stall >1s on Windows. Plausible - contributors for follow-up: daemon checkpoint ticks (5s interval; snapshot serialize in - daemon + sync writeFileSync of checkpoint JSON on main — daemon-pty-adapter.ts:592, - history-manager.ts:109), Defender scanning fresh build artifacts. - -### F6 — Windows e2e perf coverage gap - -All terminal-perf e2e specs run on ubuntu-latest in CI. Verified they DO run on a Windows -dev machine (`npx playwright test ... --project electron-headless` works locally). Consider -a Windows CI lane for the terminal-perf suite. - -## OpenCode fix (D2) - -1. **Plugin throttle + cap (source fix)** — `src/main/opencode/hook-service.ts`: - assistant MessagePart posts are trailing-edge coalesced to ≥250ms apart and text is - capped at 4000 chars (leading edge posts immediately so previews stay snappy; pending - snapshot flushed before SessionIdle so the done-row preview is the final message; user - prompts bypass the throttle slot). Plugin file is rewritten on every Orca-launched - OpenCode spawn, so the fix deploys to new sessions immediately. -2. **Listener-side cap (stale-plugin defense)** — `src/shared/agent-hook-listener.ts`: - OpenCode MessagePart text capped at 8000 chars at ingest (OPENCODE_HOOK_TEXT_MAX_CHARS) - so pre-fix plugins in long-running OpenCode processes can't blow up state maps. -3. **Benchmark/regression test** — `src/main/agent-hooks/opencode-message-part-flood-bench.test.ts` - drives the real hook HTTP pipeline with both behaviors. Measured on this machine: - | metric/turn | legacy plugin | throttled plugin | - |---|---|---| - | posts | 400 | 120 | - | bytes through main | 22.9 MB | 469 KB (49x less) | - | wall time | 540 ms | 79 ms | - | listener fanouts | 400 | 120 | -4. Behavioral plugin tests — `src/main/opencode/hook-plugin-message-part-throttle.test.ts` - executes the generated plugin with fake timers + stubbed fetch. - -## Findings - -### F1 — Recursive icacls walk is the ~1 min startup (CONFIRMED, 2026-06-10) - -- This machine's real packaged-Orca userData: `%APPDATA%\Orca` = **28,650 files / 2.06 GB** - (mostly Chromium caches: Cache, Code Cache, GPUCache, blob_storage…). -- Measured the exact command Orca runs in `openMainWindow()` (src/main/index.ts:517-523): - - `icacls /grant:r :(OI)(CI)(F) /T /C` → **62.0 s** - - App runs it with `execFileSync` (main thread, BLOCKING, before BrowserWindow creation) - with a **60s timeout** → every cold launch freezes ~60s, then the grant *times out and - silently fails* (execFileSync throws, caught). Users pay the full minute and get nothing. - - Non-recursive root-only grant: **4.8 s** (NTFS propagates inheritable ACE internally). - - `icacls \* /grant:r …` (immediate children, 48 entries): **4.7 s**. -- Why it exists (PR #1152): Chromium's BrowserWindow ctor resets userData DACL with - Inherit-Only ACEs → EPERM on writes in existing subdirs (codex-runtime-home, agent-hooks…). - Explicit child ACEs survive propagation. Per-write EPERM retries exist as backstop in - `codex-accounts/fs-utils.ts` + `agent-hooks/installer-utils.ts`. -- Windows ACL inheritance recalculates from the immediate parent during propagation, so - explicit ACEs on userData + immediate children are sufficient; per-file ACEs on 28k - Chromium cache files are useless work. - -### F2 — Instrumentation prior art - -- `ORCA_STARTUP_DIAGNOSTICS=1` → `[startup] ` lines on stderr (startup-diagnostics.ts). - Only 2 events exist today (single-instance lock). Commit b240d5eee (branch - perf/startup-first-window, NOT merged here) has a full StartupPhaseTimer framework — - too large to cherry-pick; adding minimal milestone logs instead. -- Hermetic benchmark launch path: `ORCA_E2E_USER_DATA_DIR=` redirects userData - (works packaged + dev), `ORCA_E2E_HEADLESS=1` keeps window hidden. Dev/preview mode - skips single-instance lock → safe alongside installed Orca. - -## Decisions / fixes - -### D0 — RESULTS: ACL fix benchmark (2026-06-10) - -| phase (median) | baseline (3 it.) | after fix (4 it.) | steady state (3 it.) | -|---|---|---|---| -| aclGrantMs | **15.65s sync/blocking** | async (off critical path) | **0ms (marker hit)** | -| totalToWindowCreated | 18.25s | 930ms | 814ms | -| totalToDidFinishLoad | **19.31s** | **2.04s** | **1.80s** | - -- First launch after fix: total 2.06s while the background grant ran 6.81s concurrently. -- Marker verified written by real icacls run; subsequent launches log `acl-grant-done - mode=marker-hit` with zero spawns. -- JSON evidence: tools/benchmarks/results/startup-{baseline,acl-fix,acl-fix-steady}-*.json -- Files: src/main/startup/windows-user-data-acl.ts (+tests), src/main/index.ts (wire-up + - startup milestones), src/main/win32-utils.ts (export identity resolver), - tools/benchmarks/startup-time-bench.mjs (harness). - -### D1 — ACL grant fix (implemented as planned) - -Replace the synchronous recursive walk with: -1. A persisted marker (`windows-acl-grant.json` in userData, keyed on identity + scheme - version): when present → skip everything (steady-state launches: 0 icacls spawns, 0 ms). -2. When marker missing (first launch after install/profile import): grant root + - immediate children via **async spawn** (never blocks window creation); write marker - on success. Per-write EPERM retries remain the backstop during the async window — - that's exactly what they're for (#1152 comment says so). -3. Drop the /T full-tree walk entirely; it grants nothing the immediate-children - ACEs + inheritance propagation don't already cover. diff --git a/package.json b/package.json index 6f5400d484e..713b26e50a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.136-rc.0", + "version": "1.4.140-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", @@ -72,7 +72,7 @@ "build:mac": "pnpm run build:desktop && pnpm run build:computer-macos && pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --mac", "build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build:desktop && ORCA_MAC_RELEASE=1 pnpm run build:computer-macos && ORCA_MAC_RELEASE=1 pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac", "build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --linux AppImage deb", - "test:e2e": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headless", + "test:e2e": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project=electron-headless", "test:e2e:floating-mobile-emulator": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/floating-mobile-emulator-tab.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:terminal-rendering-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts --grep @terminal-rendering-golden --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", @@ -83,6 +83,7 @@ "test:e2e:terminal-perf:summarize": "node config/scripts/summarize-terminal-perf-report.mjs", "test:e2e:terminal-perf:html-report": "node config/scripts/generate-terminal-perf-html-report.mjs", "test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs", + "test:e2e:ssh-docker-watcher-isolation": "node config/scripts/run-ssh-docker-watcher-isolation-e2e.mjs", "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 tools/win-update-e2e/run.mjs", "test:e2e:ssh-codex-artifacts-repro": "node config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs", @@ -93,6 +94,8 @@ "bench:daemon-coldstart": "pnpm run ensure:electron-runtime && node tools/benchmarks/daemon-coldstart-bench.mjs", "bench:main-thread-jank": "pnpm run ensure:electron-runtime && node tools/benchmarks/main-thread-jank-bench.mjs", "bench:multi-workspace-typing": "pnpm run ensure:electron-runtime && node config/scripts/run-multi-workspace-typing-bench.mjs", + "bench:cold-park-reveal": "pnpm run ensure:electron-runtime && node tools/benchmarks/terminal-cold-park-reveal-bench.mjs", + "bench:cold-park-resource": "pnpm run ensure:electron-runtime && node tools/benchmarks/terminal-cold-park-resource-bench.mjs", "bench:compare": "node config/scripts/compare-benchmark-artifacts.mjs" }, "dependencies": { @@ -150,7 +153,7 @@ "@tiptap/starter-kit": "^3.22.5", "@types/node": "^25.6.0", "@types/qrcode": "^1.5.6", - "@types/react": "^19.2.14", + "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@types/ssh2": "^1.15.5", "@types/ws": "^8.18.1", @@ -177,6 +180,7 @@ "lint-staged": "^16.4.0", "lowlight": "^3.3.0", "lucide-react": "^0.577.0", + "marked": "^17.0.1", "mermaid": "^11.15.0", "monaco-editor": "^0.55.1", "oxfmt": "^0.52.0", @@ -184,10 +188,10 @@ "oxlint-plugin-react-doctor": "0.2.10", "oxlint-tsgolint": "0.23.0", "pdfjs-dist": "^5.7.284", - "radix-ui": "^1.4.3", - "react": "^19.2.5", + "radix-ui": "^1.6.2", + "react": "^19.2.7", "react-colorful": "^5.7.0", - "react-dom": "^19.2.5", + "react-dom": "^19.2.7", "react-grab": "^0.1.33", "react-markdown": "^10.1.0", "rehype-highlight": "^7.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf2c0e1c933..ed2c5a12649 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,7 +15,7 @@ patchedDependencies: hash: 81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9 path: config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch '@xterm/addon-webgl@0.20.0-beta.286': - hash: c32044ff5e9bfa651090eedbec19a13b2343fca3cc163fa8eec04c97880a3e19 + hash: 1d4557ed6d14437403023085c037053bbc6b8566c4b08f1f712232eb3269ca78 path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch node-pty@1.1.0: hash: c3e917380aab5b8d6b6dac26101ce6c991127dd07c6dcf5e23b16106b8f9e792 @@ -69,7 +69,7 @@ importers: version: 1.5.4 react-i18next: specifier: ^17.0.8 - version: 17.0.8(i18next@26.3.1(typescript@7.0.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@7.0.2) + version: 17.0.8(i18next@26.3.1(typescript@7.0.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2) serve-sim: specifier: ^0.1.40 version: 0.1.40(typescript@7.0.2) @@ -94,10 +94,10 @@ importers: devDependencies: '@dnd-kit/core': specifier: ^6.3.1 - version: 6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@dnd-kit/sortable': specifier: ^10.0.0 - version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) '@electron-toolkit/tsconfig': specifier: ^2.0.0 version: 2.0.0(@types/node@25.6.0) @@ -106,7 +106,7 @@ importers: version: 4.2.0 '@monaco-editor/react': specifier: ^4.7.0 - version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@playwright/test': specifier: ^1.59.1 version: 1.59.1 @@ -118,13 +118,13 @@ importers: version: 4.2.4(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) '@tanstack/react-virtual': specifier: ^3.13.24 - version: 3.13.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 3.13.24(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 '@testing-library/react': specifier: ^16.3.2 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.1) @@ -172,7 +172,7 @@ importers: version: 3.22.5 '@tiptap/react': specifier: ^3.22.5 - version: 3.22.5(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 3.22.5(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tiptap/starter-kit': specifier: ^3.22.5 version: 3.22.5 @@ -183,11 +183,11 @@ importers: specifier: ^1.5.6 version: 1.5.6 '@types/react': - specifier: ^19.2.14 - version: 19.2.14 + specifier: ^19.2.17 + version: 19.2.17 '@types/react-dom': specifier: ^19.2.3 - version: 19.2.3(@types/react@19.2.14) + version: 19.2.3(@types/react@19.2.17) '@types/ssh2': specifier: ^1.15.5 version: 1.15.5 @@ -214,7 +214,7 @@ importers: version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287) '@xterm/addon-webgl': specifier: 0.20.0-beta.286 - version: 0.20.0-beta.286(patch_hash=c32044ff5e9bfa651090eedbec19a13b2343fca3cc163fa8eec04c97880a3e19)(@xterm/xterm@6.1.0-beta.287) + version: 0.20.0-beta.286(patch_hash=1d4557ed6d14437403023085c037053bbc6b8566c4b08f1f712232eb3269ca78)(@xterm/xterm@6.1.0-beta.287) '@xterm/xterm': specifier: 6.1.0-beta.287 version: 6.1.0-beta.287 @@ -226,7 +226,7 @@ importers: version: 2.1.1 cmdk: specifier: ^1.1.1 - version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) dompurify: specifier: ^3.4.11 version: 3.4.11 @@ -241,7 +241,7 @@ importers: version: 5.0.0(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) emoji-picker-react: specifier: ^4.19.1 - version: 4.19.1(react@19.2.5) + version: 4.19.1(react@19.2.7) happy-dom: specifier: ^20.9.0 version: 20.9.0 @@ -262,7 +262,10 @@ importers: version: 3.3.0 lucide-react: specifier: ^0.577.0 - version: 0.577.0(react@19.2.5) + version: 0.577.0(react@19.2.7) + marked: + specifier: ^17.0.1 + version: 17.0.6 mermaid: specifier: ^11.15.0 version: 11.15.0 @@ -285,23 +288,23 @@ importers: specifier: ^5.7.284 version: 5.7.284 radix-ui: - specifier: ^1.4.3 - version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + specifier: ^1.6.2 + version: 1.6.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: - specifier: ^19.2.5 - version: 19.2.5 + specifier: ^19.2.7 + version: 19.2.7 react-colorful: specifier: ^5.7.0 - version: 5.7.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 5.7.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react-dom: - specifier: ^19.2.5 - version: 19.2.5(react@19.2.5) + specifier: ^19.2.7 + version: 19.2.7(react@19.2.7) react-grab: specifier: ^0.1.33 - version: 0.1.34(react@19.2.5) + version: 0.1.34(react@19.2.7) react-markdown: specifier: ^10.1.0 - version: 10.1.0(@types/react@19.2.14)(react@19.2.5) + version: 10.1.0(@types/react@19.2.17)(react@19.2.7) rehype-highlight: specifier: ^7.0.2 version: 7.0.2 @@ -337,7 +340,7 @@ importers: version: 4.7.0(@types/node@25.6.0)(typescript@7.0.2) sonner: specifier: ^2.0.7 - version: 2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) tailwind-merge: specifier: ^3.5.0 version: 3.5.0 @@ -370,7 +373,7 @@ importers: version: 9.3.2 zustand: specifier: ^5.0.13 - version: 5.0.13(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)) + version: 5.0.13(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) optionalDependencies: sherpa-onnx-darwin-arm64: specifier: 1.12.37 @@ -399,10 +402,6 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} - '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} - engines: {node: '>=6.9.0'} - '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -415,10 +414,6 @@ packages: resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} - '@babel/generator@7.29.1': - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} - engines: {node: '>=6.9.0'} - '@babel/generator@7.29.7': resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} engines: {node: '>=6.9.0'} @@ -437,10 +432,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} - engines: {node: '>=6.9.0'} - '@babel/helper-globals@7.29.7': resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} engines: {node: '>=6.9.0'} @@ -449,20 +440,10 @@ packages: resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} - engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.29.7': resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - '@babel/helper-module-transforms@7.29.7': resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} engines: {node: '>=6.9.0'} @@ -487,26 +468,14 @@ packages: resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.29.7': resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.29.7': resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} engines: {node: '>=6.9.0'} @@ -515,11 +484,6 @@ packages: resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.3': - resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} - engines: {node: '>=6.0.0'} - hasBin: true - '@babel/parser@7.29.7': resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} engines: {node: '>=6.0.0'} @@ -577,26 +541,14 @@ packages: resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} - '@babel/template@7.28.6': - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} - engines: {node: '>=6.9.0'} - '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.0': - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} - engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.7': resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} - '@babel/types@7.29.7': resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} @@ -1624,14 +1576,17 @@ packages: '@posthog/types@1.372.9': resolution: {integrity: sha512-B7k9S+H9WUKHXxe1HOkQWbpWtMcrBvsodm5stZaLQ3pYxf9TowtwssdzTtX4hHjzSYqgrS1IpNnJX4vs1KgBzA==} - '@radix-ui/number@1.1.1': - resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + '@radix-ui/number@1.1.2': + resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==} '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} - '@radix-ui/react-accessible-icon@1.1.7': - resolution: {integrity: sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==} + '@radix-ui/primitive@1.1.5': + resolution: {integrity: sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg==} + + '@radix-ui/react-accessible-icon@1.1.11': + resolution: {integrity: sha512-HQDOFTKwSnmUij6l54wYJJtxTAnxI71+YJLOrjm2ladFB8HAV5Jt7hwaZPhWTGBkYoW4+ZAOfNZrLDh/qvxSYA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1643,8 +1598,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-accordion@1.2.12': - resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==} + '@radix-ui/react-accordion@1.2.16': + resolution: {integrity: sha512-BpZJNmetujnGgUI6OX0jEhEmlA46WPqgub8Rv09Kyquwd0cc1ndMKpiPYCjmBU6KSSRPAMtgLpEoZSG/tdNIWQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1656,8 +1611,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-alert-dialog@1.1.15': - resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==} + '@radix-ui/react-alert-dialog@1.1.19': + resolution: {integrity: sha512-FA7n1f6D/DwGE0+AWxiY5LacNbbExQuEgMubeG06idEaH+mSLuf9dp/qBNqOnvbTQ+4gZ2ue1RATF1Ub91Mg5g==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1669,8 +1624,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-arrow@1.1.7': - resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + '@radix-ui/react-arrow@1.1.11': + resolution: {integrity: sha512-Kdil9BB1rIFC/khmf4hC35bn8701AJcizTU7G7cUbEbk5XqqbjDuHW60uUfKqO5WojjZcbAW51Q7P0hRmMLw8A==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1682,8 +1637,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-aspect-ratio@1.1.7': - resolution: {integrity: sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==} + '@radix-ui/react-aspect-ratio@1.1.11': + resolution: {integrity: sha512-IUAhIVpBUvP5NNICjlaB1OFmtRLGqQqTF3ZOSGPoq3XeLXRFtHiWTRxSVEULgOd9GQR2c7tsYqDnhUennapZnw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1695,8 +1650,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-avatar@1.1.10': - resolution: {integrity: sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==} + '@radix-ui/react-avatar@1.2.2': + resolution: {integrity: sha512-sST0qh8GzOB7besQ3tMLWLyngnRuSk0gc/Hm+667KYKQFCt6Y6ZXv25WlqM7dIDK54ULCh5+CHmk4LIolzfz+A==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1708,8 +1663,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-checkbox@1.3.3': - resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} + '@radix-ui/react-checkbox@1.3.7': + resolution: {integrity: sha512-JroKHfQBfh+fDuzpPsBC+pESkhuq8ql4hljTguz8MWnS35cISr3d/Jhl9kYrB44FlDtxCArYdDvTx+BSsJ64rQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1721,8 +1676,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-collapsible@1.1.12': - resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} + '@radix-ui/react-collapsible@1.1.16': + resolution: {integrity: sha512-opfXRe6nnzyGmCDPx+l1Aqo/RbqWtQal2FnsBqF9hhePp6j0LsRoBaRxcMOlTv+uYTJVtWYZKg9t9wTe+BA/ZA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1734,8 +1689,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-collection@1.1.7': - resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + '@radix-ui/react-collection@1.1.12': + resolution: {integrity: sha512-nb67INpE0IahJKN7EYPp9m9YGwYeKlnzxT3MwXVkgCskaSJia97kG4T0ywpjNUSSnoJk/uvk12V8vbrEHEj+/Q==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1756,8 +1711,17 @@ packages: '@types/react': optional: true - '@radix-ui/react-context-menu@2.2.16': - resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==} + '@radix-ui/react-compose-refs@1.1.3': + resolution: {integrity: sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context-menu@2.3.3': + resolution: {integrity: sha512-PS+gKE0z2prJ74Y0sM+brAGK4mYOHIR7TlcV5EJgUQ6E0xMvyswkK2X4yRqyganrzsRL+WCSKAPu0NQITICRWg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1778,6 +1742,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.2.0': + resolution: {integrity: sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dialog@1.1.15': resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} peerDependencies: @@ -1791,8 +1764,21 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-direction@1.1.1': - resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + '@radix-ui/react-dialog@1.1.19': + resolution: {integrity: sha512-+HhbN2+YtkRgVirjZ2afMeutQRuGOrdkWR5+EFC58SJojGmtyNQwYzgi6tHBpOxvFHefMtPeHdgtjz0BOGxFQg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.2': + resolution: {integrity: sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -1813,8 +1799,21 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-dropdown-menu@2.1.16': - resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + '@radix-ui/react-dismissable-layer@1.1.15': + resolution: {integrity: sha512-b0XaRlzn2QKuo10XyNgi2DAJDf5XC9d1nD3FJcuvCjbR7+4Ad28zmZsLsqx+hvDEzMnRuZaZxZm9gYObV6RmRA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.20': + resolution: {integrity: sha512-slfm+rRaZRuQBvHq60lXvSVUPhid0IPtjSZzIuUlWZMUs01iYZNlGS3mJgRD3ChLQVBAYlKiL/tFyWGX+dz8Xw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1835,6 +1834,28 @@ packages: '@types/react': optional: true + '@radix-ui/react-focus-guards@1.1.4': + resolution: {integrity: sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.12': + resolution: {integrity: sha512-jjk/lqTeNL0azUx5ZYzVrl4NgaDIrdzTNE4mABV9yBFI7FQqN7pIgzV1bTleUezP2QiTGA1BFTqY8MegDgWX9A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-focus-scope@1.1.7': resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} peerDependencies: @@ -1848,8 +1869,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-form@0.1.8': - resolution: {integrity: sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==} + '@radix-ui/react-form@0.1.12': + resolution: {integrity: sha512-JTX94E4LDL91rzLg7X0mHPdxr0A8JEdVwZEmeOwZJSMDHCGW5DFtSlTSJozUyUs807IQmnvbfzKZFVCK5DmkqQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1861,8 +1882,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-hover-card@1.1.15': - resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==} + '@radix-ui/react-hover-card@1.1.19': + resolution: {integrity: sha512-2KTgMLQtKvicznQgbindEI2RZ3QbDIwU5gabjUPwFJsormjGDz+rUvO4NANmYwzEEpTcTONUt33vBHIfTIVSfw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1883,8 +1904,17 @@ packages: '@types/react': optional: true - '@radix-ui/react-label@2.1.7': - resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==} + '@radix-ui/react-id@1.1.2': + resolution: {integrity: sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.11': + resolution: {integrity: sha512-3PKvDDxOn62k0oV1n4QtNtD2vpu+zYjXR7ojLBPaO6SPvhy53yg0vAmgNeBQeJW5rV3dffoRG+HYfLBZuzw0CQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1896,8 +1926,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-menu@2.1.16': - resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + '@radix-ui/react-menu@2.1.20': + resolution: {integrity: sha512-VsUrXxFe9d2ScbZF0fR/oPR1+qjyeLs5p0jzG8h90puMoA9bq4SirYlXbE+USRg9Q2qTeJSFNqjw2nts8jJe4w==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1909,8 +1939,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-menubar@1.1.16': - resolution: {integrity: sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==} + '@radix-ui/react-menubar@1.1.20': + resolution: {integrity: sha512-gzFZvybgmwYsFBWDqanycIoEYnhyk8MMnuLamdFVHUZYGp4COM+sqXiwbnn0VMWqGLeeU7GV7jm+dXRa+Wufag==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1922,8 +1952,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-navigation-menu@1.2.14': - resolution: {integrity: sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==} + '@radix-ui/react-navigation-menu@1.2.18': + resolution: {integrity: sha512-K9HiuxZ6xCwSaHcIuUpxyhy4w5gpwzWjh9dHTSbMN3Ix4qAyVObS9RlU3zMycb0PO3v9Tpk0BXMwWvXOUbVXew==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1935,8 +1965,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-one-time-password-field@0.1.8': - resolution: {integrity: sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==} + '@radix-ui/react-one-time-password-field@0.1.12': + resolution: {integrity: sha512-nQLu5OAcORDQp1EHAv6k3mJGV1hjMTw2NTGVAsGE1g/mWeNqAd1R5jyaAs3U+A8ZD/W8XNPY2yKT0ZdQnqo3NA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1948,8 +1978,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-password-toggle-field@0.1.3': - resolution: {integrity: sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==} + '@radix-ui/react-password-toggle-field@0.1.7': + resolution: {integrity: sha512-gB1Mr8vzdv1XzDjrtJTXmL0JORRs1B4g7ngUs0F+H2VvMOwXTZMTmLCl0wZZ3m7ylX8TssI7NCvgiSHmLuTm/A==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1961,8 +1991,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-popover@1.1.15': - resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + '@radix-ui/react-popover@1.1.19': + resolution: {integrity: sha512-jkrTdQVxnIB8fpn0NyyxW9CTB5aCXZZelVz5z+Xmii6g5WxMqS3fInNslZ63puP39+Puu4jYohUK31y3dT87gQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1974,8 +2004,21 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-popper@1.2.8': - resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + '@radix-ui/react-popper@1.3.3': + resolution: {integrity: sha512-mS7dGpyjv6b+gsDjLF7e0ia1W4Im1B1hSCy2yuXlHuvnZxHKagfDaobt/KAKt27EpZMit2pss8eJBVyVjEWM+g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.13': + resolution: {integrity: sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2013,6 +2056,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-presence@1.1.7': + resolution: {integrity: sha512-zBZ4QM5XG3JRanDmqXYf3MD6th4AFXFmgU6KNMFzUaV6F3uw9I5/zjMUvFriSEn5ewo1nxuibvyxJdmLlDcslA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-primitive@2.1.3': resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} peerDependencies: @@ -2039,8 +2095,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-progress@1.1.7': - resolution: {integrity: sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==} + '@radix-ui/react-primitive@2.1.7': + resolution: {integrity: sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2052,8 +2108,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-radio-group@1.3.8': - resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==} + '@radix-ui/react-progress@1.1.12': + resolution: {integrity: sha512-ZPHyI0JyzoH/rP0tq2uRaIZTj/4s8+kAbqPz+e2N8+ejHvwPJ889dHhqn+vh7PNvNeq+boAoH9yzqeoShzwF2w==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2065,8 +2121,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-roving-focus@1.1.11': - resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + '@radix-ui/react-radio-group@1.4.3': + resolution: {integrity: sha512-WwZFjWV4s3aC1QtR3k04R+oANHtX2q6fgKlc7MCEiDNlnTxCZ3H8k3mHtEgVlOejystwk1WQgarQhNOQZ2bK1g==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2078,8 +2134,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-scroll-area@1.2.10': - resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} + '@radix-ui/react-roving-focus@1.1.15': + resolution: {integrity: sha512-40svmmugfM3mUN7VUDGVE1tQGOhyi8enlGD0CNJEcMM36C1f71PKM21DFgNHUfem0XnA+d8H8oN3Z9ZpJjSslg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2091,8 +2147,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-select@2.2.6': - resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} + '@radix-ui/react-scroll-area@1.2.14': + resolution: {integrity: sha512-bBODCWZK7JTbQLHs0uIP4f73wIWatakK4OS33UzkR1x897wu0PuO658a3f+6P2GEGyDzGYMuHRatMVoAk9WZTw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2104,8 +2160,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-separator@1.1.7': - resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==} + '@radix-ui/react-select@2.3.3': + resolution: {integrity: sha512-L5RQTXz6Anxsf9CCv+pTgiAsUpyVj7rJxsGtmhFaEOJ++cVfXucv4qWfsIO0AIB4NAhi3yovWGVMKKS1Xf1Wrg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2117,8 +2173,21 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-slider@1.3.6': - resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==} + '@radix-ui/react-separator@1.1.11': + resolution: {integrity: sha512-jRhe86+8PF7VZ1u14eOWVOuh2BuAhALg/FT1VcMC4OHedMTRUazDnDlKTt+yxo5cRNKHMfmvZ4sSQtWDeMV4CQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slider@1.4.3': + resolution: {integrity: sha512-CWVVj+XaTom0SKCqw1EUgb0NuiLwS+N3OFG73mVEezKEjgNIvZiu0EevMelSSU+CbX3owbqJweG2gPU31WGC5A==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2148,8 +2217,17 @@ packages: '@types/react': optional: true - '@radix-ui/react-switch@1.2.6': - resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} + '@radix-ui/react-slot@1.3.0': + resolution: {integrity: sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.3.3': + resolution: {integrity: sha512-1+mlB4/lxJfk5tgJ4g+R5mUCbRpPE1T9+UsEyeLYbGgMtwiMgmuTnfKz4Mw1nHALHjuwyxw4MLd4cSHn6pNSlQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2161,8 +2239,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-tabs@1.1.13': - resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + '@radix-ui/react-tabs@1.1.17': + resolution: {integrity: sha512-nRyXnrAVCwjeXcHbvEbLS6ndbTeKHG1RqCP4A8Gw5L4cemDzPXdD8rAmr6wet0v57R69wGvuIIsFjHSVkZiMzQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2174,8 +2252,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-toast@1.2.15': - resolution: {integrity: sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==} + '@radix-ui/react-toast@1.2.19': + resolution: {integrity: sha512-SxfVZfVOibWKWdkf0Xx1awW2d09fQu4V4PXDY1j5hi4MVf7MWdJZqTBJMa1KWtOr1S6GGtCk02nniZ0Iia+dHw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2187,8 +2265,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-toggle-group@1.1.11': - resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==} + '@radix-ui/react-toggle-group@1.1.15': + resolution: {integrity: sha512-gIC5Q+Xljg7lmUdzSuDoy0t97yZn1sZl00Ra37ZvKrYdWnQLU6sWLd09yG8cIB9jUAlQfHgJ2ACAG00MFwsqSQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2200,8 +2278,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-toggle@1.1.10': - resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} + '@radix-ui/react-toggle@1.1.14': + resolution: {integrity: sha512-QI/hB65XKWACA66P64A+aHxtLUgHJeJLkaQa+awUNXT6T3swndtY5DojeHA+vldrTspMTtFBd7HfZ9QGbM1Qrw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2213,8 +2291,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-toolbar@1.1.11': - resolution: {integrity: sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==} + '@radix-ui/react-toolbar@1.1.15': + resolution: {integrity: sha512-t/iEuVjUnXXtrsGK40AA43uIx37sn3AqZ7oAVnPICK6lFJP6dzMzWR3U9b6eCfFjb6wtSEqkJ9Rn9xDjiOx20g==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2226,8 +2304,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-tooltip@1.2.8': - resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} + '@radix-ui/react-tooltip@1.2.12': + resolution: {integrity: sha512-U3HoftgWnmla78vzQbLvKKb7bUYJxoiiqYFzp1wu/TBMyDqMZSuCl3aRICsD6EfVEwcJD2mumGDGUXLFVqQHKA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2248,6 +2326,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-callback-ref@1.1.2': + resolution: {integrity: sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-controllable-state@1.2.2': resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} peerDependencies: @@ -2257,6 +2344,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-controllable-state@1.2.3': + resolution: {integrity: sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-effect-event@0.0.2': resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} peerDependencies: @@ -2266,6 +2362,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-effect-event@0.0.3': + resolution: {integrity: sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-escape-keydown@1.1.1': resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} peerDependencies: @@ -2275,8 +2380,17 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-is-hydrated@0.1.0': - resolution: {integrity: sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==} + '@radix-ui/react-use-escape-keydown@1.1.3': + resolution: {integrity: sha512-3wEkMiPHXha/2VadZ68rYBcmYnPINVGl4Y3gtcM7fKRjANk0OscK+cdqBgUWdozb7YJxsh0vefM7vgAMHXOjqg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.1': + resolution: {integrity: sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -2293,8 +2407,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-previous@1.1.1': - resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + '@radix-ui/react-use-layout-effect@1.1.2': + resolution: {integrity: sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -2302,8 +2416,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-rect@1.1.1': - resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + '@radix-ui/react-use-previous@1.1.2': + resolution: {integrity: sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -2311,8 +2425,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-size@1.1.1': - resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + '@radix-ui/react-use-rect@1.1.2': + resolution: {integrity: sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -2320,8 +2434,17 @@ packages: '@types/react': optional: true - '@radix-ui/react-visually-hidden@1.2.3': - resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + '@radix-ui/react-use-size@1.1.2': + resolution: {integrity: sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.7': + resolution: {integrity: sha512-1wNZBggTDK3GRuuQ6nP4k2yi7a6l7I5qbMPbZcRsrGsGVead/f/d5FhEzUvqFs0bcrDLx7n1zKQ3JvLR6whaaw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2333,8 +2456,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/rect@1.1.1': - resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@radix-ui/rect@1.1.2': + resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==} '@react-grab/cli@0.1.34': resolution: {integrity: sha512-L2eAxN46Vq2Ss3nDegrH7wQVMeWH03ahawp+OdzUtQWqL3cq6Bt149q9XhY3cWc9fJsxuWjLfCn+3T9uApIlBA==} @@ -3051,8 +3174,8 @@ packages: peerDependencies: '@types/react': ^19.2.0 - '@types/react@19.2.14': - resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} @@ -3457,6 +3580,7 @@ packages: boolean@3.2.0: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. brace-expansion@1.1.14: resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} @@ -4354,14 +4478,6 @@ packages: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} - fs-extra@11.3.4: - resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} - engines: {node: '>=14.14'} - - fs-extra@11.3.5: - resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} - engines: {node: '>=14.14'} - fs-extra@11.3.6: resolution: {integrity: sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==} engines: {node: '>=14.14'} @@ -4446,6 +4562,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-agent@3.0.0: resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} @@ -4642,6 +4759,7 @@ packages: inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -4967,6 +5085,7 @@ packages: lodash.isequal@4.5.0: resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} @@ -5330,6 +5449,7 @@ packages: node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead node-fetch@3.3.2: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} @@ -5710,8 +5830,8 @@ packages: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} - radix-ui@1.4.3: - resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==} + radix-ui@1.6.2: + resolution: {integrity: sha512-OwYUjzMwiInCUxgAWpPsavXC3Kh4iyi/49uU1/qZTG3RQDlvegyk1GOMiGvSkjua1RDb3JD3fo3eroL9FV4GQw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -5737,10 +5857,10 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' - react-dom@19.2.5: - resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} peerDependencies: - react: ^19.2.5 + react: ^19.2.7 react-grab@0.1.34: resolution: {integrity: sha512-jtdOdv0kb90oqL+pMszSh9DOLgVRaX4ZE6XN4GkDEpNNUqveQfZT014+EeJraljpqQfuWKW+96NrrRqUD93D2g==} @@ -5810,8 +5930,8 @@ packages: '@types/react': optional: true - react@19.2.5: - resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} engines: {node: '>=0.10.0'} read-binary-file-arch@1.0.6: @@ -6740,12 +6860,6 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.1.2 - '@babel/code-frame@7.29.0': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -6774,14 +6888,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.29.1': - dependencies: - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - '@babel/generator@7.29.7': dependencies: '@babel/parser': 7.29.7 @@ -6792,7 +6898,7 @@ snapshots: '@babel/helper-annotate-as-pure@7.27.3': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@babel/helper-compilation-targets@7.29.7': dependencies: @@ -6810,26 +6916,17 @@ snapshots: '@babel/helper-optimise-call-expression': 7.27.1 '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.7) '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.7 semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/helper-globals@7.28.0': {} - '@babel/helper-globals@7.29.7': {} '@babel/helper-member-expression-to-functions@7.28.5': dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-imports@7.28.6': - dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -6840,15 +6937,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -6860,7 +6948,7 @@ snapshots: '@babel/helper-optimise-call-expression@7.27.1': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@babel/helper-plugin-utils@7.28.6': {} @@ -6869,27 +6957,21 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-member-expression-to-functions': 7.28.5 '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-string-parser@7.27.1': {} - '@babel/helper-string-parser@7.29.7': {} - '@babel/helper-validator-identifier@7.28.5': {} - '@babel/helper-validator-identifier@7.29.7': {} - '@babel/helper-validator-option@7.27.1': {} - '@babel/helper-validator-option@7.29.7': {} '@babel/helpers@7.29.7': @@ -6897,10 +6979,6 @@ snapshots: '@babel/template': 7.29.7 '@babel/types': 7.29.7 - '@babel/parser@7.29.3': - dependencies: - '@babel/types': 7.29.0 - '@babel/parser@7.29.7': dependencies: '@babel/types': 7.29.7 @@ -6923,7 +7001,7 @@ snapshots: '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.7) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color @@ -6953,7 +7031,7 @@ snapshots: dependencies: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-validator-option': 7.27.1 + '@babel/helper-validator-option': 7.29.7 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.7) '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7) '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7) @@ -6962,30 +7040,12 @@ snapshots: '@babel/runtime@7.29.7': {} - '@babel/template@7.28.6': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 - '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 '@babel/parser': 7.29.7 '@babel/types': 7.29.7 - '@babel/traverse@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.3 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - '@babel/traverse@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -6998,11 +7058,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/types@7.29.0': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/types@7.29.7': dependencies: '@babel/helper-string-parser': 7.29.7 @@ -7017,29 +7072,29 @@ snapshots: ajv: 6.15.0 ajv-keywords: 3.5.2(ajv@6.15.0) - '@dnd-kit/accessibility@3.1.1(react@19.2.5)': + '@dnd-kit/accessibility@3.1.1(react@19.2.7)': dependencies: - react: 19.2.5 + react: 19.2.7 tslib: 2.8.1 - '@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@dnd-kit/accessibility': 3.1.1(react@19.2.5) - '@dnd-kit/utilities': 3.2.2(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@dnd-kit/accessibility': 3.1.1(react@19.2.7) + '@dnd-kit/utilities': 3.2.2(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) tslib: 2.8.1 - '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)': + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': dependencies: - '@dnd-kit/core': 6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@dnd-kit/utilities': 3.2.2(react@19.2.5) - react: 19.2.5 + '@dnd-kit/core': 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@dnd-kit/utilities': 3.2.2(react@19.2.7) + react: 19.2.7 tslib: 2.8.1 - '@dnd-kit/utilities@3.2.2(react@19.2.5)': + '@dnd-kit/utilities@3.2.2(react@19.2.7)': dependencies: - react: 19.2.5 + react: 19.2.7 tslib: 2.8.1 '@dotenvx/dotenvx@1.66.0': @@ -7127,7 +7182,7 @@ snapshots: fs-extra: 10.1.0 isbinaryfile: 4.0.10 minimist: 1.2.8 - plist: 3.1.0 + plist: 3.1.1 transitivePeerDependencies: - supports-color @@ -7148,9 +7203,9 @@ snapshots: '@malept/cross-spawn-promise': 2.0.0 debug: 4.4.3 dir-compare: 4.2.0 - fs-extra: 11.3.4 + fs-extra: 11.3.6 minimatch: 9.0.9 - plist: 3.1.0 + plist: 3.1.1 transitivePeerDependencies: - supports-color @@ -7330,11 +7385,11 @@ snapshots: '@floating-ui/core': 1.7.5 '@floating-ui/utils': 0.2.11 - '@floating-ui/react-dom@2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@floating-ui/react-dom@2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@floating-ui/dom': 1.7.6 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) '@floating-ui/utils@0.2.11': {} @@ -7453,12 +7508,12 @@ snapshots: dependencies: state-local: 1.0.7 - '@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@monaco-editor/loader': 1.7.0 monaco-editor: 0.55.1 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) '@mswjs/interceptors@0.41.8': dependencies: @@ -7750,768 +7805,910 @@ snapshots: '@posthog/types@1.372.9': {} - '@radix-ui/number@1.1.1': {} + '@radix-ui/number@1.1.2': {} '@radix-ui/primitive@1.1.3': {} - '@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/primitive@1.1.5': {} - '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-accessible-icon@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-visually-hidden': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-accordion@1.2.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-collapsible': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-alert-dialog@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dialog': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-arrow@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-aspect-ratio@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-avatar@1.2.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-checkbox@1.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-collapsible@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-collection@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-compose-refs@1.1.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-context-menu@2.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-menu': 2.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-context@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-context@1.2.0(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-avatar@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.5)': - dependencies: - react: 19.2.5 - optionalDependencies: - '@types/react': 19.2.14 - - '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.5)': - dependencies: - react: 19.2.5 - optionalDependencies: - '@types/react': 19.2.14 - - '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.17)(react@19.2.7) aria-hidden: 1.2.6 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-dialog@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - react: 19.2.5 - optionalDependencies: - '@types/react': 19.2.14 - - '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.5)': - dependencies: - react: 19.2.5 - optionalDependencies: - '@types/react': 19.2.14 - - '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-form@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.5)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - optionalDependencies: - '@types/react': 19.2.14 - - '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) aria-hidden: 1.2.6 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-direction@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-dismissable-layer@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-one-time-password-field@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-dropdown-menu@2.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/number': 1.1.1 - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-menu': 2.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-password-toggle-field@0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.17)(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.7 optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.17 - '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-focus-guards@1.1.4(@types/react@19.2.17)(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-focus-scope@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-form@0.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-label': 2.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-hover-card@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popper': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-id@1.1.1(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-id@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-label@2.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-menu@2.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popper': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) aria-hidden: 1.2.6 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-menubar@1.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@floating-ui/react-dom': 2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/rect': 1.1.1 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-menu': 2.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-navigation-menu@1.2.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-one-time-password-field@0.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/number': 1.1.2 + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-password-toggle-field@0.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-popover@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - '@radix-ui/number': 1.1.1 - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - '@radix-ui/number': 1.1.1 - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popper': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) aria-hidden: 1.2.6 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-separator@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-popper@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-arrow': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-rect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/rect': 1.1.2 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-portal@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/number': 1.1.1 - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-slot@1.2.4(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-presence@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-toast@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-primitive@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-progress@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-radio-group@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-roving-focus@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-scroll-area@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - react: 19.2.5 + '@radix-ui/number': 1.1.2 + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-select@2.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 + '@radix-ui/number': 1.1.2 + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popper': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + aria-hidden: 1.2.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-separator@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-slider@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 + '@radix-ui/number': 1.1.2 + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-slot@1.2.3(@types/react@19.2.17)(react@19.2.7)': dependencies: - react: 19.2.5 - use-sync-external-store: 1.6.0(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.17 - '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-slot@1.2.4(@types/react@19.2.17)(react@19.2.7)': dependencies: - react: 19.2.5 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.17 - '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-slot@1.3.0(@types/react@19.2.17)(react@19.2.7)': dependencies: - react: 19.2.5 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.17 - '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-switch@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/rect': 1.1.1 - react: 19.2.5 + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-tabs@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-toast@1.2.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/rect@1.1.1': {} + '@radix-ui/react-toggle-group@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toggle': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-toggle@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-toolbar@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-separator': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toggle-group': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-tooltip@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popper': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-callback-ref@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-controllable-state@1.2.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-effect-event@0.0.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-escape-keydown@1.1.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-is-hydrated@0.1.1(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-layout-effect@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-previous@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-rect@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/rect': 1.1.2 + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-size@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-visually-hidden@1.2.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/rect@1.1.2': {} '@react-grab/cli@0.1.34': dependencies: @@ -8706,11 +8903,11 @@ snapshots: tailwindcss: 4.2.4 vite: 7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) - '@tanstack/react-virtual@3.13.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@tanstack/react-virtual@3.13.24(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@tanstack/virtual-core': 3.14.0 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) '@tanstack/virtual-core@3.14.0': {} @@ -8734,15 +8931,15 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@babel/runtime': 7.29.7 '@testing-library/dom': 10.4.1 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': dependencies: @@ -8938,17 +9135,17 @@ snapshots: prosemirror-transform: 1.12.0 prosemirror-view: 1.41.8 - '@tiptap/react@3.22.5(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@tiptap/react@3.22.5(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) '@tiptap/pm': 3.22.5 - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) '@types/use-sync-external-store': 0.0.6 fast-equals: 5.4.0 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - use-sync-external-store: 1.6.0(react@19.2.5) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + use-sync-external-store: 1.6.0(react@19.2.7) optionalDependencies: '@tiptap/extension-bubble-menu': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) '@tiptap/extension-floating-menu': 3.22.5(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) @@ -8992,24 +9189,24 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/cacheable-request@6.0.3': dependencies: @@ -9200,11 +9397,11 @@ snapshots: dependencies: '@types/node': 25.6.0 - '@types/react-dom@19.2.3(@types/react@19.2.14)': + '@types/react-dom@19.2.3(@types/react@19.2.17)': dependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.17 - '@types/react@19.2.14': + '@types/react@19.2.17': dependencies: csstype: 3.2.3 @@ -9369,8 +9566,7 @@ snapshots: '@xmldom/xmldom@0.8.13': {} - '@xmldom/xmldom@0.9.10': - optional: true + '@xmldom/xmldom@0.9.10': {} '@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287)': dependencies: @@ -9398,7 +9594,7 @@ snapshots: dependencies: '@xterm/xterm': 6.1.0-beta.287 - '@xterm/addon-webgl@0.20.0-beta.286(patch_hash=c32044ff5e9bfa651090eedbec19a13b2343fca3cc163fa8eec04c97880a3e19)(@xterm/xterm@6.1.0-beta.287)': + '@xterm/addon-webgl@0.20.0-beta.286(patch_hash=1d4557ed6d14437403023085c037053bbc6b8566c4b08f1f712232eb3269ca78)(@xterm/xterm@6.1.0-beta.287)': dependencies: '@xterm/xterm': 6.1.0-beta.287 @@ -9550,9 +9746,9 @@ snapshots: dependencies: tweetnacl: 0.14.5 - bippy@0.5.40(react@19.2.5): + bippy@0.5.40(react@19.2.7): dependencies: - react: 19.2.5 + react: 19.2.7 body-parser@2.2.2: dependencies: @@ -9741,14 +9937,14 @@ snapshots: clsx@2.1.1: {} - cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -10259,10 +10455,10 @@ snapshots: transitivePeerDependencies: - supports-color - emoji-picker-react@4.19.1(react@19.2.5): + emoji-picker-react@4.19.1(react@19.2.7): dependencies: flairup: 1.0.0 - react: 19.2.5 + react: 19.2.7 emoji-regex@10.6.0: {} @@ -10588,24 +10784,11 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 - fs-extra@11.3.4: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.2.1 - universalify: 2.0.1 - - fs-extra@11.3.5: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.2.1 - universalify: 2.0.1 - fs-extra@11.3.6: dependencies: graceful-fs: 4.2.11 jsonfile: 6.2.1 universalify: 2.0.1 - optional: true fs-extra@7.0.1: dependencies: @@ -11247,9 +11430,9 @@ snapshots: dependencies: yallist: 4.0.0 - lucide-react@0.577.0(react@19.2.5): + lucide-react@0.577.0(react@19.2.7): dependencies: - react: 19.2.5 + react: 19.2.7 lz-string@1.5.0: {} @@ -12019,7 +12202,7 @@ snapshots: parse-json@5.2.0: dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 error-ex: 1.3.4 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 @@ -12089,7 +12272,6 @@ snapshots: '@xmldom/xmldom': 0.9.10 base64-js: 1.5.1 xmlbuilder: 15.1.1 - optional: true pngjs@5.0.0: {} @@ -12251,68 +12433,68 @@ snapshots: quick-lru@5.1.1: {} - radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + radix-ui@1.6.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-accessible-icon': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-alert-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-aspect-ratio': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-avatar': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-checkbox': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context-menu': 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-form': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-hover-card': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-menubar': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-one-time-password-field': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-password-toggle-field': 0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-progress': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-radio-group': 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-slider': 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-switch': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-toast': 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-toolbar': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-accessible-icon': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-accordion': 1.2.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-alert-dialog': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-arrow': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-aspect-ratio': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-avatar': 1.2.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-checkbox': 1.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collapsible': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context-menu': 2.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dialog': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dropdown-menu': 2.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-form': 0.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-hover-card': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-label': 2.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-menu': 2.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-menubar': 1.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-navigation-menu': 1.2.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-one-time-password-field': 0.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-password-toggle-field': 0.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popover': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popper': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-progress': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-radio-group': 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-scroll-area': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-select': 2.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-separator': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slider': 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-switch': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-tabs': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toast': 1.2.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toggle': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toggle-group': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toolbar': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-tooltip': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-escape-keydown': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) range-parser@1.2.1: {} @@ -12323,46 +12505,46 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 - react-colorful@5.7.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + react-colorful@5.7.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) - react-dom@19.2.5(react@19.2.5): + react-dom@19.2.7(react@19.2.7): dependencies: - react: 19.2.5 + react: 19.2.7 scheduler: 0.27.0 - react-grab@0.1.34(react@19.2.5): + react-grab@0.1.34(react@19.2.7): dependencies: '@react-grab/cli': 0.1.34 - bippy: 0.5.40(react@19.2.5) + bippy: 0.5.40(react@19.2.7) optionalDependencies: - react: 19.2.5 + react: 19.2.7 - react-i18next@17.0.8(i18next@26.3.1(typescript@7.0.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@7.0.2): + react-i18next@17.0.8(i18next@26.3.1(typescript@7.0.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2): dependencies: '@babel/runtime': 7.29.7 html-parse-stringify: 3.0.1 i18next: 26.3.1(typescript@7.0.2) - react: 19.2.5 - use-sync-external-store: 1.6.0(react@19.2.5) + react: 19.2.7 + use-sync-external-store: 1.6.0(react@19.2.7) optionalDependencies: - react-dom: 19.2.5(react@19.2.5) + react-dom: 19.2.7(react@19.2.7) typescript: 7.0.2 react-is@17.0.2: {} - react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.5): + react-markdown@10.1.0(@types/react@19.2.17)(react@19.2.7): dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 - '@types/react': 19.2.14 + '@types/react': 19.2.17 devlop: 1.1.0 hast-util-to-jsx-runtime: 2.3.6 html-url-attributes: 3.0.1 mdast-util-to-hast: 13.2.1 - react: 19.2.5 + react: 19.2.7 remark-parse: 11.0.0 remark-rehype: 11.1.2 unified: 11.0.5 @@ -12373,34 +12555,34 @@ snapshots: react-refresh@0.18.0: {} - react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.5): + react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7): dependencies: - react: 19.2.5 - react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.5) + react: 19.2.7 + react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.7) tslib: 2.8.1 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.17 - react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.5): + react-remove-scroll@2.7.2(@types/react@19.2.17)(react@19.2.7): dependencies: - react: 19.2.5 - react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.5) - react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.5) + react: 19.2.7 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.17)(react@19.2.7) + react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.7) tslib: 2.8.1 - use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.5) - use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.5) + use-callback-ref: 1.3.3(@types/react@19.2.17)(react@19.2.7) + use-sidecar: 1.1.3(@types/react@19.2.17)(react@19.2.7) optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.17 - react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.5): + react-style-singleton@2.2.3(@types/react@19.2.17)(react@19.2.7): dependencies: get-nonce: 1.0.1 - react: 19.2.5 + react: 19.2.7 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.17 - react@19.2.5: {} + react@19.2.7: {} read-binary-file-arch@1.0.6: dependencies: @@ -12689,7 +12871,7 @@ snapshots: shadcn@4.7.0(@types/node@25.6.0)(typescript@7.0.2): dependencies: '@babel/core': 7.29.7 - '@babel/parser': 7.29.3 + '@babel/parser': 7.29.7 '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7) '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7) '@dotenvx/dotenvx': 1.66.0 @@ -12703,7 +12885,7 @@ snapshots: diff: 8.0.4 execa: 9.6.1 fast-glob: 3.3.3 - fs-extra: 11.3.5 + fs-extra: 11.3.6 fuzzysort: 3.1.0 https-proxy-agent: 7.0.6 kleur: 4.1.5 @@ -12788,7 +12970,7 @@ snapshots: simple-update-notifier@2.0.0: dependencies: - semver: 7.7.4 + semver: 7.8.1 sisteransi@1.0.5: {} @@ -12814,10 +12996,10 @@ snapshots: smol-toml@1.6.1: {} - sonner@2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + sonner@2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) source-map-js@1.2.1: {} @@ -13137,24 +13319,24 @@ snapshots: dependencies: punycode: 2.3.1 - use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.5): + use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.7): dependencies: - react: 19.2.5 + react: 19.2.7 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.17 - use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.5): + use-sidecar@1.1.3(@types/react@19.2.17)(react@19.2.7): dependencies: detect-node-es: 1.1.0 - react: 19.2.5 + react: 19.2.7 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.17 - use-sync-external-store@1.6.0(react@19.2.5): + use-sync-external-store@1.6.0(react@19.2.7): dependencies: - react: 19.2.5 + react: 19.2.7 utf8-byte-length@1.0.5: {} @@ -13356,10 +13538,10 @@ snapshots: zod@4.4.3: {} - zustand@5.0.13(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)): + zustand@5.0.13(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)): optionalDependencies: - '@types/react': 19.2.14 - react: 19.2.5 - use-sync-external-store: 1.6.0(react@19.2.5) + '@types/react': 19.2.17 + react: 19.2.7 + use-sync-external-store: 1.6.0(react@19.2.7) zwitch@2.0.4: {} diff --git a/pty-exhaustion-agent-session-leak.md b/pty-exhaustion-agent-session-leak.md new file mode 100644 index 00000000000..64cbb3d93ce --- /dev/null +++ b/pty-exhaustion-agent-session-leak.md @@ -0,0 +1,178 @@ +# Design Doc: PTY Exhaustion from Leaked Agent Sessions + +**Date:** 2026-07-13 +**Status:** Root cause identified; fixes proposed +**Severity:** P0 — machine-wide outage. Once the pty cap is hit, _nothing_ on the host can open a terminal (Orca, Ghostty, IDEs, `ssh`, agent spawns all fail with "cannot allocate pty"). +**Source refs:** file:line references below are against the main checkout at `~/orca/orca` as of 2026-07-13. + +--- + +## 1. Summary + +An Orca desktop instance exhausted the macOS pseudo-terminal limit (`kern.tty.ptmx_max = 511`). Investigation on the live machine found **526 allocated ptys**, of which **~476 belonged to three `Orca Helper` processes** — hundreds of idle `login → zsh → codex resume ` chains, some alive for almost 4 days, all at 0% CPU. Sessions the user had explicitly closed in the UI were still running, and closed sessions kept _coming back_ in bulk waves after restarts. + +The root cause is **two independent defects that compound into a self-perpetuating leak**: + +- **Bug A (teardown):** Closing a session tab never kills the underlying pty. Tab close is pure renderer-state cleanup; the actual `pty.kill` only happens as a side effect of a mounted `TerminalPane` unmounting. For "cold-parked" tabs in background worktrees — whose panes are unmounted by design — closing the tab silently orphans the live process chain. +- **Bug B (resume):** Closing a tab never removes the session's record from the persisted resume list (`sleepingAgentSessionsByPaneKey`), and the record staleness check is a no-op (it compares `capturedAt - updatedAt`, which is always ≈ 0, instead of `now - updatedAt`). Every workspace open / app restart therefore bulk-launches `codex resume` for the entire orphaned backlog — and a periodic capture timer re-persists all of them, so the backlog only grows. + +Every restart makes it worse: Bug B resurrects the dead-but-remembered sessions (each burning a fresh pty), Bug A guarantees closing them doesn't actually free anything, and the capture loop re-persists the ever-larger set. + +--- + +## 2. Observed incident (live-machine evidence) + +Measurements taken 2026-07-13 on the affected desktop: + +| Metric | Value | +| ------------------------------------------------- | ----------------------------------------------------------------------- | +| Allocated ptys (`/dev/ttys*`) | 526 (cap: `kern.tty.ptmx_max = 511`) | +| `login` + `zsh` session pairs | 506 | +| Sessions parented to installed `Orca.app` helpers | 476 (helper pids 53960: **377**, 31662: 58, 1510: 41) | +| Sessions from dev-build Orca instances | ~25 | +| `codex` agent processes | ~280 (~270 **unique** session IDs; a handful resumed 2–3× concurrently) | +| `claude` agent processes | ~38 | +| Processes using >1% CPU | 7 of 763 — everything else fully idle | + +Key observations: + +- **`Orca Helper` pid 53960 alone held 376 open ptmx master fds.** Every leaked chain was still fully parented: `Orca Helper → /usr/bin/login → zsh → node → codex`. Nothing was orphaned to launchd — the app-side master fd was simply never closed. +- **Working directories were overwhelmingly finished PR-review worktrees** (`review-p1-pr-7511-issue-7026` alone had 79 codex processes; `review-p0-pr-8370-issue-8212` had 76; `review-p0-pr-8279-issue-8260` had 67). +- **Start times cluster in same-second waves** — batches on Fri Jul 10 (~15:41–18:14) and large bursts on Sun Jul 12 (14:03, 14:27, 14:36–14:40, 15:09). These are bulk resumes on workspace open / app restart, not user-opened sessions. +- **The user had closed these tabs.** The tabs were gone from the UI; the processes survived (Bug A) and were then re-resumed in later waves (Bug B). +- Nearly all resumed with `codex --dangerously-bypass-approvals-and-sandbox resume ` — i.e., they idle at an interactive prompt and never exit on their own. + +--- + +## 3. Background: pty ownership architecture + +- Terminal sessions are spawned in an Electron utility/helper process which holds the **pty master** (hence the ptmx fds on `Orca Helper`). +- A **daemon** hosts sessions so they survive app restarts for warm reattach: it is forked `detached: true` + `unref()` and deliberately outlives the app (`daemon/daemon-init.ts:306-319`; comment at `:741` — "sessions stay alive for warm reattach"). +- Background (non-active) worktrees are **"cold-parked"**: their `TerminalPane` React components unmount, the transport calls `detach()` (`pty-transport.ts:929` — "keep the PTY exit observer alive"), and pane-less byte watchers observe the still-running pty. +- Agent sessions are additionally tracked in a persisted map, `sleepingAgentSessionsByPaneKey` (`src/renderer/src/store/slices/agent-status.ts:121`), so they can be resumed (`codex resume `) after sleep/quit. + +Keep-alive-on-detach is **intentional**. The defects are in what is supposed to end a session's life. + +--- + +## 4. Bug A: closing a tab never kills the pty + +### The close path contains no kill + +`closeTerminalTab()` (`terminal-tab-actions.ts:152`) → `closeLocalTerminalTabState` → `state.closeTab()` (`store/slices/terminals.ts:1192-1381`). `closeTab` scrubs ~20 per-tab maps — including `delete ptyIdsByTabId[tabId]` (`terminals.ts:1217-1218`) — but issues **no `pty.kill`, no shutdown, no IPC**. It just _forgets the pty id_. + +### The only kill is a React-unmount side effect + +The pty dies only when a _mounted_ `TerminalPane` unmounts with the tab gone: `shouldDetachPaneTransportOnUnmount()` (`use-terminal-pane-lifecycle.ts:456`) returns false → unmount cleanup (`use-terminal-pane-lifecycle.ts:1725-1826`, kill branch at `:1807`) → `transport.destroy()` (`pty-transport.ts:1031`) → `window.api.pty.kill(id)` → main `pty:kill` (`ipc/pty.ts:4953`) → `provider.shutdown(id, {immediate: true})`. + +This works for the **active** worktree. But for a **cold-parked** tab there is no mounted pane; closing it runs `syncParkedTerminalTabWatchers` → `disposeParkedTabWatchers` (`terminal-parked-watcher-registry.ts:53-63`), which stops the watchers and **never kills the pty**. The `login → zsh → codex` chain is orphaned with nothing pointing at it. **This is the zombie source.** + +### Nothing else ever reaps it + +Every existing reaper is scoped to an event _other than_ tab close: + +| Mechanism | Why it doesn't help | +| --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Daemon `detach` handler | Logging no-op — "full implementation would track tokens" (`daemon/daemon-server.ts:508-512`). No idle timeout exists anywhere. | +| `reapSession` (`daemon/terminal-host.ts:215`) | Fires only on the child's natural exit or explicit kill; an idle interactive `codex` never exits. | +| App-quit `killAllPty()` (`ipc/pty.ts:5191`), reload `killOrphanedPtys` (`ipc/pty.ts:2851`) | Both guarded by `if (localProvider instanceof LocalPtyProvider)` — false for the daemon adapter, so daemon-hosted sessions are excluded _by design_ (warm reattach). | +| Worktree removal (`runtime/worktree-teardown.ts:39`, `killAllProcessesForWorktree`) | Works, but only on full worktree delete. | +| Sleep flow (`sidebar/sleep-worktree-flow.ts:148` → `shutdownWorktreeTerminals`, `terminals.ts:2331`, kill at `:2665`) | Works, but only when the user explicitly sleeps the worktree. | + +### The codebase already knows close ≠ kill + +`kill-all-terminal-surfaces.ts` performs daemon `killAll()`, then `closeTerminalTab({force: true})` per tab, **then an explicit `window.api.pty.kill(ptyId)` loop over `ptyIdsByTabId`** (`:140-147`, `:188-190`) — direct evidence that tab close alone is known not to kill. There is even a manual mop-up UI: **Manage Sessions → "Kill orphan terminals"** for sessions "that have no tab in this Orca instance" (`status-bar/ResourceUsageStatusSegment.tsx:~1114-1155`) — a hand-operated workaround for exactly this leak. + +--- + +## 5. Bug B: closed sessions are resurrected in bulk forever + +### The resume list never learns about tab close + +Agent sessions are recorded in `sleepingAgentSessionsByPaneKey` (`agent-status.ts:121`; record shape in `src/shared/agent-session-resume.ts:38-59`, with `state`, `capturedAt`, `updatedAt`, and `origin: 'worktree-sleep' | 'quit' | 'live'`). Records are written: + +- **Live:** on every non-done agent-status update, an `origin: 'live'` record is captured (`agent-status.ts:1492-1544`). +- **Quit + periodic:** `captureAllSleepingAgentSessions` writes an `origin: 'quit'` record for _every currently-live pane_ (`agent-status.ts:2312-2350`), called on quit (`App.tsx:1367`) **and on a periodic interval** (`App.tsx:1394`). + +`closeTab` (`terminals.ts:1192-1381`) calls `dropAgentStatusByTabPrefix` (`terminals.ts:1894-2010`), which removes the agent-_status_ entry but **never touches `sleepingAgentSessionsByPaneKey`**. There is no concept of "user closed this tab" in the resume store. The orphaned record persists to disk with the workspace session state (`workspace-session-sleeping-agents.ts` → `workspace-session.ts:393`). + +### The staleness gate is a no-op + +On workspace/worktree open, `resumeSleepingAgentSessionsForWorktree` (`resume-sleeping-agent-session.ts:143-218`) launches `codex resume ` (argv built in `agent-session-resume.ts:195-220`; full command in `tui-agent-startup.ts:189-238`; launched via `sleeping-agent-session-launch.ts:65-128`) for every record unless a filter rejects it: + +- `isPassiveCompletedHibernationEvidence` (`sleeping-agent-pane-ownership.ts:16-18`): only skips records that are `done` **and** not `live`/`quit` origin. Orphaned records are `live`/`quit`, so they pass. +- `isInvalidWorktreeActivationRecord` (`resume-sleeping-agent-session.ts:131-141`): rejects records where `state !== 'done' && (capturedAt - updatedAt) > 30 min` (`AGENT_STATUS_STALE_AFTER_MS`, `shared/agent-status-types.ts:256`). **But live records are captured with `capturedAt === updatedAt`** (`agent-status.ts:1501`) and quit records stamp `capturedAt = Date.now()` immediately after the last update — so the difference is always ≈ 0. The check should compare against **`now`**; as written, a record from three days ago is perpetually "fresh." +- `recordPaneIsOwnedByPreservedPane`: skips only if the pane still exists in the restored layout — but the user closed the tab, so it doesn't. + +Result: orphaned records are neither passive, nor stale, nor pane-owned → **all of them get `codex resume`'d**, dozens in the same second. Triggers: folder-workspace open (`worktree-activation.ts:229`), worktree open (`:363`), startup hydration of the active worktree (`Terminal.tsx:1062-1075`), and mobile wake (`wake-sleeping-agents-in-background.ts:165-212`, wired at `useIpcEvents.ts:836`). + +### The feedback loop + +1. Workspace opens → every orphaned record is resumed → N fresh ptys, N idle `codex` processes. +2. Resumed sessions idle at a prompt and never reach `done`, so done-cleanup (`agent-status.ts:1541-1544`) never runs. +3. The periodic/quit capture (`agent-status.ts:2312-2350`) re-persists a record for every one of them. +4. User closes the tabs → Bug A leaves the processes alive; Bug B leaves the records in place. +5. Next open/restart → goto 1, with a strictly larger backlog. + +Distinct historical session IDs produce distinct claim keys, so per-claim dedup doesn't collapse them — which is exactly why one worktree accumulated 79 live codex processes across ~270 unique session IDs machine-wide. + +--- + +## 6. Proposed fixes + +### Fix 1 (primary, Bug A): make tab close actually kill the pty + +In `closeTab` (`terminals.ts:1192`) / `closeTerminalTab` (`terminal-tab-actions.ts:152`), issue `window.api.pty.kill()` for the tab's pty ids **before** `delete ptyIdsByTabId[tabId]`, mirroring the explicit kill loop in `kill-all-terminal-surfaces.ts:140-147`. This closes the parked-pane gap directly: the kill no longer depends on a mounted `TerminalPane` unmounting. Cover the split-pane close paths (`use-terminal-pane-lifecycle.ts`, `TerminalPaneOverlayLayer.tsx`) as well. + +Design decision needed: if warm-reattach semantics should be preserved for _some_ tab closes, distinguish "close tab" (kill) from "sleep/detach" (keep alive) explicitly in the UI action — today the distinction exists in the code (Sleep uses `keepIdentifiers: true`, `sleep-worktree-flow.ts:148`) but plain tab close falls into an unintended third state: forgotten but alive. + +### Fix 2 (primary, Bug B): remove resume records on tab close + +Have `closeTab` (and the split-pane close paths) delete the closed panes' entries from `sleepingAgentSessionsByPaneKey`. A user closing a tab is expressing "I'm done with this session" — it must not remain resumable-by-default. + +### Fix 3 (secondary, Bug B): origin-aware wall-clock expiry + +Do not globally expire resume records by wall time: intentional `worktree-sleep` +checkpoints may remain valid for days. A defensive expiry can apply only to +unowned `live` or `quit` records after their origin and ownership are validated +at every wake entry point. Explicit tab close must delete its records +immediately and remains the primary fix. + +### Fix 4 (secondary, Bug A): daemon-side reaper + +Implement the daemon `detach` handler (`daemon-server.ts:508-512`) and an idle reaper for sessions that are detached with **no owning tab**. The daemon deliberately outlives the app and is excluded from every `instanceof LocalPtyProvider` sweep, so it needs its own lifecycle policy — otherwise any future client-side bookkeeping bug leaks forever again. + +### Defense in depth + +- Surface a pty-pressure warning (the data already exists for the "Kill orphan terminals" feature) when app-owned ptys exceed a threshold (e.g. 300), since exhaustion is a machine-wide outage. +- The `--dangerously-bypass-approvals-and-sandbox` resume flag amplifies blast radius: hundreds of unattended, sandbox-less agents idling for days. Consider whether bulk background resume should ever use it. + +--- + +## 7. Verification plan + +1. **Repro (pre-fix):** in a background worktree, open an agent session, switch to another worktree (parking the pane), close the tab → `ps` shows the `login/zsh/codex` chain still alive and the helper still holds its ptmx fd. Restart Orca → the session is resumed again. +2. **Post-fix:** same steps → chain dies on tab close (Fix 1); record removed so restart resumes nothing (Fix 2). Origin-aware defensive expiry is verified separately when implemented (Fix 3). +3. **Regression:** worktree Sleep → wake still resumes sessions; quit → relaunch still warm-reattaches sessions whose tabs were _not_ closed; daemon warm-reattach unaffected. +4. **Soak:** count `Orca Helper` ptmx fds (`lsof -p | grep -c ptmx`) across a day of open/close/restart cycles — should stay flat. + +## 8. Immediate mitigation (ops, no code) + +- Use **Manage Sessions → "Kill orphan terminals"** or exact-session termination + to release the leaked ptys. A normal Orca.app restart is insufficient because + the persistent terminal daemon deliberately survives for warm reattach. +- `sudo sysctl kern.tty.ptmx_max=999` raises the cap to its macOS hard max — buys headroom only, hours at current leak rate. +- Restart the terminal daemon only when exact-session cleanup is unavailable; + this terminates all daemon-backed sessions, including legitimate in-flight work. + +--- + +## Appendix: diagnostic commands used + +```sh +ls /dev/ttys* | wc -l && sysctl kern.tty.ptmx_max # allocation vs cap +ps -ax -o pid,tty,etime,comm | grep -E 'ttys[0-9]+' # who holds ptys +lsof -a -d cwd -c codex | awk '{print $NF}' | sort | uniq -c | sort -rn # per-worktree counts +lsof -p | grep -c ptmx # master fds per Orca Helper +ps -ax -o args | grep -oE '[0-9a-f-]{36}' | sort | uniq -c # duplicate session resumes +``` diff --git a/resources/win32/bin/orca.cmd b/resources/win32/bin/orca.cmd index 33ad35ad97c..3da08e250db 100644 --- a/resources/win32/bin/orca.cmd +++ b/resources/win32/bin/orca.cmd @@ -1,28 +1,21 @@ @echo off setlocal set "SCRIPT_DIR=%~dp0" -for %%I in ("%SCRIPT_DIR%..") do set "RESOURCES_DIR=%%~fI" -REM Why: once %%~fI canonicalizes RESOURCES_DIR it no longer ends with a slash, -REM so Windows batch needs an explicit "\.." segment here. Without it the CLI -REM launcher resolves APP_DIR back to resources/ and `orca open` cannot find -REM Orca.exe on packaged Windows installs. -for %%I in ("%RESOURCES_DIR%\..") do set "APP_DIR=%%~fI" -set "ELECTRON=%APP_DIR%\Orca.exe" +set "LAUNCHER=%SCRIPT_DIR%orca.exe" -if not exist "%ELECTRON%" ( - echo Unable to locate Orca.exe next to "%RESOURCES_DIR%" 1>&2 +if not exist "%LAUNCHER%" ( + echo Unable to locate the native Orca CLI launcher at "%LAUNCHER%" 1>&2 exit /b 1 ) -REM Why: Orca packages the CLI entrypoint outside app.asar so the public shell -REM command can execute it directly with ELECTRON_RUN_AS_NODE instead of -REM depending on a separately installed Node CLI. -set "CLI=%RESOURCES_DIR%\app.asar.unpacked\out\cli\index.js" +REM Why: cmd.exe reparses %%* and can execute/truncate embedded newlines. The +REM native launcher is the supported path for orchestration message bodies. +if /I "%~1"=="orchestration" if /I "%~2"=="send" goto :unsafe_body +if /I "%~1"=="orchestration" if /I "%~2"=="reply" goto :unsafe_body -set "ORCA_NODE_OPTIONS=%NODE_OPTIONS%" -set "ORCA_NODE_REPL_EXTERNAL_MODULE=%NODE_REPL_EXTERNAL_MODULE%" -set NODE_OPTIONS= -set NODE_REPL_EXTERNAL_MODULE= -set ELECTRON_RUN_AS_NODE=1 +"%LAUNCHER%" %* +exit /b %ERRORLEVEL% -"%ELECTRON%" "%CLI%" %* +:unsafe_body +echo orca.cmd cannot safely forward orchestration message bodies. Use "%LAUNCHER%" instead. 1>&2 +exit /b 2 diff --git a/skills/computer-use/SKILL.md b/skills/computer-use/SKILL.md index 4ee3491b0a1..e3b3a65bd8c 100644 --- a/skills/computer-use/SKILL.md +++ b/skills/computer-use/SKILL.md @@ -37,6 +37,8 @@ orca computer click --app com.spotify.client --element-index 42 --json Use the fresh state returned by each action for the next element index. Element indexes are the numeric labels shown in the tree; they may be sparse when noisy sections are omitted, so never infer valid indexes from `elementCount` or "Visible elements." Element indexes are short-lived and go stale after delays, navigation, focus changes, scrolling, window changes, or app re-rendering. +In `--json` output, read the accessibility tree and action indexes from `result.snapshot.treeText`; `elementCount` is only a count and must not be used to infer indexes. + ## App Selectors Prefer bundle IDs from `list-apps`; names are acceptable when unambiguous. Use `pid:` only when bundle ID or name matching is ambiguous. diff --git a/skills/orca-cli/SKILL.md b/skills/orca-cli/SKILL.md index e4e82d97265..0231463e4a4 100644 --- a/skills/orca-cli/SKILL.md +++ b/skills/orca-cli/SKILL.md @@ -15,7 +15,7 @@ description: >- # Orca CLI -Use `orca` when Orca's running editor/runtime is the source of truth. On Linux, use `orca-ide` wherever this file says `orca`. +Use `orca` when Orca's running editor/runtime is the source of truth. Inside Orca-managed terminals, `orca` always resolves to the Orca CLI on every platform. In any other shell on Linux, use `orca-ide` wherever this file says `orca` — outside Orca's terminals, bare `orca` on Linux is usually the GNOME Orca screen reader (`/usr/bin/orca`), and running it starts speech on the user's machine. **Dev builds (`pnpm dev`):** after `pnpm build:cli`, the dev CLI is exposed as `orca-dev` (the global shim points at this checkout's wrapper + out/cli). Inside a dev Orca's terminals use `orca-dev emulator ...` (or `./config/scripts/orca-dev.mjs emulator ...` for worktree-local invocation that does not depend on the /usr/local/bin symlink). Plain `orca` targets any installed production Orca. The app's own agent preambles use `orca-dev` automatically in dev mode. @@ -24,7 +24,9 @@ Use plain shell tools when Orca state does not matter. ## Start Here ```bash -command -v orca || command -v orca-ide +# Prefer orca-ide first: on Linux, a bare `orca` hit outside an Orca-managed +# terminal is likely the GNOME screen reader, not the Orca CLI. +command -v orca-ide || command -v orca orca status --json orca worktree ps --json orca terminal list --json @@ -55,11 +57,15 @@ Use `--no-parent` and omit `--base-branch` for independent top-level handoffs un Custom Codex model/effort handoff: -`worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. For requests such as `gpt-5.5 xhigh`, create the independent worktree, launch the requested Codex command there, wait only for TUI readiness if needed to avoid losing input, send the prompt, and stop: +`worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. For requests such as `gpt-5.5 xhigh`, create the independent worktree, launch the requested Codex command there, wait only for TUI readiness if needed to avoid losing input, send the prompt, and stop. + +**Extra first terminal:** when no repo default-terminal configuration supplies a primary terminal, bare `worktree create` (no `--agent`) opens a fallback shell before the later `terminal create --command ...` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever the built-in launcher is enough. When custom argv forces the two-step path, target the agent handle only; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. + +The create result's `worktree.id` already contains both pieces Orca needs: `::`. Copy that whole value into the next command; do not shorten it to the repo id. ```bash orca worktree create --name --no-parent --json -orca terminal create --worktree id: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort="xhigh"' --json +orca terminal create --worktree id::: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort="xhigh"' --json orca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json orca terminal send --terminal --text "" --enter --json ``` @@ -74,6 +80,8 @@ orca terminal send --terminal --text "" --enter --json An Orca worktree is Orca's tracked view of a repo checkout, its metadata, terminals, browser tabs, and UI state. +Think of its id as a two-part address: `::`. For example, `repo-123::/Users/me/orca/fix-login` means “the `fix-login` checkout inside repo `repo-123`.” Always copy the complete `id` field from `orca worktree create --json` or `orca worktree list --json`; `repo-123` alone identifies only the repo. + Common commands: ```bash @@ -91,23 +99,24 @@ orca worktree create --repo id: --name related-task --parent-worktree ac orca worktree create --repo id: --name folder-child --parent-worktree folder: --json orca worktree create --name child-task --agent codex --prompt "hi" --json orca worktree create --name independent-task --no-parent --json -orca worktree set --worktree id: --display-name "My Task" --json +orca worktree set --worktree id::: --display-name "My Task" --json orca worktree set --worktree active --comment "reproduced bug; testing fix" --json orca worktree set --worktree active --workspace-status in-review --json -orca worktree rm --worktree id: --force --json +orca worktree rm --worktree id::: --force --json ``` Selectors: -- `id:`, `name:`, `path:`, `branch:`, `issue:` +- `id:::`, `name:`, `path:`, `branch:`, `issue:` +- The full id is the exact `::` value returned by `orca worktree create --json` or `orca worktree list --json`; a bare repo id is not a worktree id. - `active` / `current` for the enclosing Orca-managed worktree from the shell cwd -- For `worktree create --parent-worktree` only, folder/worktree parent context keys are also valid: `folder:`, `worktree:`, `id:folder:`, `id:worktree:` +- For `worktree create --parent-worktree` only, folder/worktree parent context keys are also valid: `folder:`, `worktree:::`, `id:folder:`, `id:worktree:::` Lineage rules: - When creating from inside an Orca-managed worktree or folder context, Orca infers the current parent context when it can. - Use `--parent-worktree active` when the child worktree relationship should be explicit. -- Use `--parent-worktree folder:` or `--parent-worktree worktree:` when a folder or worktree parent context should be explicit. +- Use `--parent-worktree folder:` or `--parent-worktree worktree:::` when a folder or worktree parent context should be explicit. - Use `--no-parent` only when the new work is independent. - `--no-parent` only controls Orca lineage; it does not choose the Git base. For independent top-level work, omit `--base-branch` so Orca uses the repo default base, or explicitly pass the repo default base. Never base it on the current feature branch unless the user asks for stacked work or "branch from current". - If `--repo` is omitted, Orca infers the repo from the current Orca worktree when possible. @@ -121,13 +130,15 @@ orca worktree create --name task --setup skip --json orca worktree create --name task --run-hooks --json ``` -- `--agent ` launches that agent in the first terminal; `--prompt ` sends initial work to it. +- `--agent ` launches that agent **in the first terminal** (Orca docs: *"`--agent` launches the selected agent in the first terminal"*); `--prompt ` sends initial work to it. Known ids include `claude`, `codex`, `omp`, `pi`, `grok`, and other installed TUI agents. +- **Prefer agent-first create for agent workers.** `orca worktree create --agent --prompt "..."` puts the agent in the worktree's first terminal without adding a separate fallback shell for that worker. Repo setup or default-terminal settings may still add tabs or splits. Without configured default tabs, the bare-create fallback shell plus a later `terminal create --command ` is an anti-pattern for ordinary agent worktrees — use `--agent` instead of “create worktree, then open agent.” Configured default tabs are intentional surfaces; never treat one as disposable without verifying that it is an unused shell. +- After create, use exactly one agent handle: `startupTerminal.handle` from the create response when present, or the matching result from `orca terminal list --worktree id::: --json` (or `name:`) when the response omits it. If a handle later returns `terminal_handle_stale`, re-list it; never dual-send to old and replacement handles. - `--setup run|skip|inherit` controls repo setup hooks. Default is `inherit`, which follows the repo's setup policy. - `--run-hooks` is a legacy alias for `--setup run`; it also reveals/activates the new worktree. - `--agent`, `--activate`, and `--run-hooks` reveal the new worktree. Plain create stays in the background. -- Let Orca choose setup terminal placement from repo settings, including tab vs split behavior. Do not manually create extra setup terminals. -- If an older installed CLI rejects `--agent`, `--prompt`, or `--setup`, create the worktree normally, then run `orca terminal create --worktree --command "codex"` and `orca terminal send` if a prompt is needed. -- `worktree create` creates a new checkout. For a fresh agent in the current checkout, use `orca terminal create --worktree active --command "codex" --json`. +- Let Orca choose setup terminal placement from repo settings, including tab vs split behavior. Do not manually create extra setup terminals when `--agent` already owns the first tab. +- If an older installed CLI rejects `--agent`, `--prompt`, or `--setup`, create the worktree normally, then run `orca terminal create --worktree --command ""` and `orca terminal send` if a prompt is needed. This can leave a fallback shell when no default tabs are configured; close it only after confirming it is unused. +- `worktree create` creates a new checkout. For a fresh agent in the **current** checkout (no new worktree), use `orca terminal create --worktree active --command "codex" --json` — that path does not create a second worktree shell. ## Worktree Comments @@ -148,7 +159,7 @@ Card status uses `--workspace-status `; defaults are `todo`, `in-progress`, Common commands: ```bash -orca terminal list --worktree id: --json +orca terminal list --worktree id::: --json orca terminal show --terminal --json orca terminal read --terminal --json orca terminal read --terminal --cursor --limit 1000 --json @@ -157,7 +168,7 @@ orca terminal send --terminal --text "continue" --enter --json orca terminal send --text "echo hello" --enter --json orca terminal wait --terminal --for exit --timeout-ms 5000 --json orca terminal wait --terminal --for tui-idle --timeout-ms 300000 --json -orca terminal stop --worktree id: --json +orca terminal stop --worktree id::: --json orca terminal create --json orca terminal create --title "Worker" --json orca terminal create --worktree active --command "codex" --json @@ -173,10 +184,10 @@ Terminal rules: - `--terminal` is optional for most commands; omitted means the active terminal in the current worktree. - Use `terminal read` before `terminal send` unless the next input is obvious. - Use `terminal send` only for direct terminal input or one-off prompts where no task state, inbox, or reply tracking is needed. -- For structured coordination, invoke the `orchestration` skill; it uses `orca orchestration ...` commands for messages, handoffs, task DAGs, dispatches, inbox/reply flows, and coordinator loops. -- Use `terminal create --worktree active --command ""` for a fresh agent in the current worktree. Use `worktree create --agent ` only for a separate checkout. -- Use `terminal wait --for tui-idle` for agent CLIs such as Claude Code, Gemini, and Codex; always pass `--timeout-ms`. -- Terminal handles are runtime-scoped. If Orca restarts or returns `terminal_handle_stale`, reacquire with `terminal list`. +- For structured coordination, invoke the `orchestration` skill; it uses `orca orchestration ...` commands for messages, handoffs, task DAGs, dispatches, inbox/reply flows, and coordinator loops. A receiving agent can run `orca orchestration check --unread --inject` to render its unread mail in agent-readable form; this checks the caller's inbox and does not remotely deliver input to another terminal. +- Use `terminal create --worktree active --command ""` for a fresh agent in the current worktree. Use `worktree create --agent ` only for a separate checkout (agent in the first terminal — do not also `terminal create` the same agent). +- Use `terminal wait --for tui-idle` for agent CLIs such as Claude Code, Gemini, Codex, OMP, Pi, and Grok; always pass `--timeout-ms`. +- Terminal handles are runtime-scoped. Use `startupTerminal.handle` as the sole agent handle when `worktree create --agent` returns it; if Orca restarts, omits the handle, or returns `terminal_handle_stale`, reacquire with `terminal list` and continue with the replacement only. - For long output, use cursor reads. After a limited tail preview, page from `oldestCursor`; after a cursor read, continue with `nextCursor` while `limited` is true and `nextCursor !== latestCursor`. - `--direction horizontal` splits left/right. `--direction vertical` splits top/bottom. diff --git a/skills/orca-emulator/SKILL.md b/skills/orca-emulator/SKILL.md index 129e7164943..e3edd22c708 100644 --- a/skills/orca-emulator/SKILL.md +++ b/skills/orca-emulator/SKILL.md @@ -107,7 +107,7 @@ Most support `--worktree ` and explicit `--device ` or `--e ## Targeting devices & worktrees - Default: current worktree's active emulator (resolved from shell cwd or Orca context). -- Explicit worktree: `--worktree id:abc123` or `--worktree active`. +- Explicit worktree: `--worktree id:` or `--worktree active`. The full id is the exact `::` value returned by `orca worktree list --json`; a bare repo id is not valid here. - Explicit device: `--device "iPhone 16 Pro"` or `--device ` (after `list`). - Orca-generated emulator id (for stability, like browserPageId): use `--emulator ` returned by list (recommended for scripts that persist ids). diff --git a/skills/orchestration/SKILL.md b/skills/orchestration/SKILL.md index 90aeb093c09..583fc2ad661 100644 --- a/skills/orchestration/SKILL.md +++ b/skills/orchestration/SKILL.md @@ -53,7 +53,7 @@ Do not use orchestration merely because the user says "hand off", "handoff", "ha ## Ownership -Orchestration messages and tasks are runtime-global. Completion authority comes from the active dispatch context: `taskId` + `dispatchId` + assignee handle. +Orchestration messages and tasks are runtime-global. Lifecycle authority comes from the payload `taskId` + `dispatchId` of the active dispatch, verified against the dispatched pane. Terminal handles are routing metadata — a pane can receive a new handle after restart — so never accept or reject lifecycle provenance by comparing handles. Send `worker_done` and `heartbeat` from the worker's own terminal; the runtime ignores them when sent from a different pane. Classify inherited context before sending lifecycle messages: @@ -79,7 +79,7 @@ orca orchestration dispatch-show --task --json ```bash orca orchestration send --to --subject [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json] -orca orchestration check [--terminal ] [--unread] [--types ] [--inject] [--wait] [--timeout-ms ] [--json] +orca orchestration check [--terminal ] [--unread|--peek|--all] [--types ] [--inject] [--wait] [--timeout-ms ] [--json] orca orchestration reply --id --body [--from ] [--json] orca orchestration ask --to --question [--options ] [--timeout-ms ] [--from ] [--json] orca orchestration inbox [--limit ] [--json] @@ -88,15 +88,19 @@ orca orchestration inbox [--limit ] [--json] Rules: - Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal. +- `check` and `check --unread` return unread matches and mark them read. Use `--peek` for unread matches without consuming them; use `--all` for read and unread history without consuming anything. If an older CLI rejects `--peek` as an unknown flag, use `--all` and filter unread rows yourself. +- Message **one** live agent handle per worker. Use `startupTerminal.handle` from the create response when present; if it is missing or later returns `terminal_handle_stale`, re-resolve with `orca terminal list --worktree ... --json` and continue with the replacement only. +- `orca orchestration check --unread --inject --json` renders unread mail for the agent terminal that runs it; it does not remotely wake another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt. - While supervising workers manually, use `check --wait --types worker_done,escalation,decision_gate --timeout-ms ` instead of sleep/poll loops. Reply to `decision_gate` messages with `orca orchestration reply --id --body --json`, then keep waiting. - Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop. - Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet. - Use `ask` when a worker needs a blocking answer from the coordinator; it waits for the reply and returns the answer directly. - `check --wait` returns one message at a time. If N workers may finish together, loop N times and dispatch newly ready tasks after each completion. -- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, and `@worktree:`. +- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`. - Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `decision_gate`, and `heartbeat`. - Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups. - `worker_done` must target the concrete coordinator handle from the live preamble. It is completion authority for one dispatch; group fanout would create false lifecycle mail in unrelated terminals. +- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides. - `heartbeat` is also dispatch-scoped. Send it only to the concrete coordinator handle with both `taskId` and `dispatchId`; use `status` for broad progress updates. ## Tasks And Dispatch @@ -105,7 +109,7 @@ A task is the work item, a dispatch assigns it to a terminal, and a gate blocks ```bash orca orchestration task-create --spec [--deps ] [--parent ] [--json] -orca orchestration task-list [--status ] [--ready] [--json] +orca orchestration task-list [--status ] [--ready] [--brief] [--json] orca orchestration task-update --id --status [--result ] [--json] orca orchestration dispatch --task --to [--from ] [--inject] [--json] orca orchestration dispatch-show --task [--json] @@ -118,6 +122,7 @@ Dispatch rules: - `--inject` sends the task spec plus preamble into a recognized agent CLI so it can report `worker_done`. - If the target is a bare shell, omit `--inject`, dispatch for tracking if needed, then send the prompt manually with `orca terminal send --terminal --text --enter --json`. - After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed. +- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag. ## Gates And Coordinator @@ -159,11 +164,15 @@ orca terminal send --terminal --text "" --enter --json Custom Codex model/effort handoff: -`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop: +`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop. + +Note: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. + +Use the exact full `::` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree. ```bash orca worktree create --name --no-parent --json -orca terminal create --worktree id: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort="xhigh"' --json +orca terminal create --worktree id: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort="xhigh"' --json orca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json orca terminal send --terminal --text "" --enter --json ``` @@ -186,12 +195,15 @@ Reuse an idle agent in the required worktree only if the prompt allows reuse; ot ```bash orca worktree create --name --agent codex --json -orca terminal list --worktree id: --json +# or: --agent claude | omp | pi | grok | ... +# Read from startupTerminal.handle in the create response. orca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json orca orchestration dispatch --task --to --inject --json ``` -For new-worktree workers, read the id from `worktree create`, then use `terminal list` to get the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `. `--agent` reveals the new worktree and launches the selected agent in its first terminal, so do not create a separate startup terminal. Do not run `worktree create` when the task must stay in the current worktree. +For new-worktree workers, read the id and `startupTerminal.handle` from `worktree create`. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `. + +**Agent-first (required for ordinary agent workers):** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Repo setup or default-terminal settings may still add tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree. Use `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble. @@ -214,7 +226,7 @@ Wait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding ## Agent Guidance -- Workers with a valid live preamble must send `worker_done` exactly once, even on failure: +- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal, even on failure: `orca orchestration send --to --type worker_done --subject "" --body "<3-sentence summary: what you did, what you found, what's left>" --payload '{"taskId":"","dispatchId":"","filesModified":["path/a"],"reportPath":""}' --json` - After sending `worker_done`, end your turn and idle at the agent prompt. Do not poll or keep calling `orca orchestration check`; the coordinator re-engages you with a fresh preamble + TASK block delivered as new terminal input. - For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs: diff --git a/src/cli/args.ts b/src/cli/args.ts index aa1289867f6..68fbba773a6 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -13,6 +13,9 @@ export type CommandSpec = { // or handler registrations. aliases?: string[][] argumentMode?: 'parsed' | 'passthrough' + // Why: irreversibly destroys persistent state — typo recovery must not steer a + // benign mistake into one of these via the agent nextSteps channel. #6303 + destructive?: boolean summary: string usage: string allowedFlags: string[] diff --git a/src/cli/command-suggestion.test.ts b/src/cli/command-suggestion.test.ts index b5c70e021c6..9e6e34164c5 100644 --- a/src/cli/command-suggestion.test.ts +++ b/src/cli/command-suggestion.test.ts @@ -6,7 +6,11 @@ import { levenshtein, suggestCommands, unknownCommandData } from './command-sugg const specs: CommandSpec[] = [ { path: ['worktree', 'rm'], - aliases: [['worktree', 'remove']], + aliases: [ + ['worktree', 'remove'], + ['worktree', 'delete'] + ], + destructive: true, summary: 'Remove a worktree', usage: 'orca worktree rm', allowedFlags: [] @@ -22,6 +26,15 @@ const specs: CommandSpec[] = [ summary: 'Send input', usage: 'orca terminal send', allowedFlags: [] + }, + { + // A destructive command outside the delete-family, to prove the guard keys + // off the spec flag rather than a hardcoded verb list. + path: ['emulator', 'kill'], + destructive: true, + summary: 'Kill the emulator', + usage: 'orca emulator kill', + allowedFlags: [] } ] @@ -67,6 +80,37 @@ describe('suggestCommands', () => { const result = suggestCommands(specs, ['terminal', 'sen']) expect(result[0]).toBe('terminal send') }) + + it('never suggests a destructive command for a benign non-destructive typo', () => { + // `worktree move` sits distance 2 from `worktree remove`; without the + // guard it would sole-suggest an irreversible delete on blind retry. #6303 + const result = suggestCommands(specs, ['worktree', 'move']) + expect(result).not.toContain('worktree remove') + expect(result).not.toContain('worktree rm') + expect(result).not.toContain('worktree delete') + }) + + it('still suggests remove for a near-miss of a destructive verb', () => { + const result = suggestCommands(specs, ['worktree', 'remov']) + expect(result).toContain('worktree rm') + expect(result).toContain('worktree remove') + }) + + it('still suggests delete for a near-miss of the delete alias', () => { + expect(suggestCommands(specs, ['worktree', 'delet'])).toContain('worktree delete') + }) + + it('guards destructive commands outside the delete-family via the spec flag', () => { + // `emulator ball` is a benign token, distance 2 from the flagged `emulator + // kill` — close enough to otherwise rank, so the guard must exclude it. + expect(suggestCommands(specs, ['emulator', 'ball'])).not.toContain('emulator kill') + // A genuine near-miss of the destructive verb still recovers. + expect(suggestCommands(specs, ['emulator', 'kil'])).toContain('emulator kill') + }) + + it('still recovers non-destructive near-misses', () => { + expect(suggestCommands(specs, ['worktree', 'lst'])).toContain('worktree list') + }) }) describe('unknownCommandData', () => { @@ -82,4 +126,10 @@ describe('unknownCommandData', () => { expect(data.suggestions).toEqual([]) expect(data.nextSteps).toEqual([]) }) + + it('does not route a benign typo into a destructive nextStep', () => { + const data = unknownCommandData(specs, ['worktree', 'move']) + expect(data.suggestions).not.toContain('worktree remove') + expect(data.nextSteps.join(' ')).not.toContain('remove') + }) }) diff --git a/src/cli/command-suggestion.ts b/src/cli/command-suggestion.ts index ccc3712b40b..530d9baf78a 100644 --- a/src/cli/command-suggestion.ts +++ b/src/cli/command-suggestion.ts @@ -6,6 +6,42 @@ import { specPaths } from './args' const SUGGESTION_THRESHOLD = 3 const MAX_SUGGESTIONS = 3 +// Why: a close typo of a destructive verb (`remov`→`remove`) still signals that +// intent, but `move` (distance 2 from `remove`) does not — keep this at 1 so +// genuine recovery works while unrelated verbs stay locked out. #6303 +const DESTRUCTIVE_INTENT_THRESHOLD = 1 + +function finalToken(path: string[]): string { + return path.at(-1) ?? '' +} + +// Why: destructiveness is declared on the spec (single source of truth); the +// intent verbs are the final tokens of every destructive path/alias so the guard +// tracks the registry instead of a hand-maintained list. +function destructiveVerbs(specs: CommandSpec[]): Set { + const verbs = new Set() + for (const spec of specs) { + if (spec.destructive) { + for (const path of specPaths(spec)) { + verbs.add(finalToken(path)) + } + } + } + return verbs +} + +// Why: deletion is irreversible and suggestions flow into agents' recovery +// channel (--json nextSteps), so only unlock destructive candidates when the +// input token is itself a near-miss of a destructive verb. #6303 +function intendsDestruction(inputToken: string, verbs: Set): boolean { + for (const verb of verbs) { + if (levenshtein(inputToken, verb) <= DESTRUCTIVE_INTENT_THRESHOLD) { + return true + } + } + return false +} + export type CommandErrorData = { suggestions: string[] nextSteps: string[] @@ -47,9 +83,15 @@ function rankByDistance(scored: { label: string; distance: number }[]): string[] // Why: same-depth matching avoids suggesting parent groups or unrelated commands. export function suggestCommands(specs: CommandSpec[], commandPath: string[]): string[] { const input = commandPath.join(' ') + // Why: only surface destructive commands when the user actually reached for one; + // otherwise a benign typo could recover into an irreversible action. #6303 + const allowDestructive = intendsDestruction(finalToken(commandPath), destructiveVerbs(specs)) const seen = new Set() const scored: { label: string; distance: number }[] = [] for (const spec of specs) { + if (spec.destructive && !allowDestructive) { + continue + } const candidates = specPaths(spec).map((path) => commandPath.length === 1 ? path.slice(0, 1) : path ) diff --git a/src/cli/format.test.ts b/src/cli/format.test.ts index a9df654cbde..7117b169512 100644 --- a/src/cli/format.test.ts +++ b/src/cli/format.test.ts @@ -99,7 +99,7 @@ describe('formatCliError', () => { message: 'Parent selector was not found.', data: { nextSteps: [ - 'Pass a valid --parent-worktree selector such as folder:, worktree:, id:, branch:, issue:, path:, or active/current.', + 'Pass a valid --parent-worktree selector such as folder:, worktree:, id:::, branch:, issue:, path:, or active/current.', 'Retry with --no-parent to create without lineage.', 123 ] @@ -111,7 +111,7 @@ describe('formatCliError', () => { expect(formatCliError(error)).toBe( [ 'Parent selector was not found.', - 'Next step: Pass a valid --parent-worktree selector such as folder:, worktree:, id:, branch:, issue:, path:, or active/current.', + 'Next step: Pass a valid --parent-worktree selector such as folder:, worktree:, id:::, branch:, issue:, path:, or active/current.', 'Next step: Retry with --no-parent to create without lineage.' ].join('\n') ) diff --git a/src/cli/format.ts b/src/cli/format.ts index 80c621418e8..9d608a5b0f4 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -168,6 +168,7 @@ export function formatCliStatus(status: CliStatusResult): string { return [ `appRunning: ${status.app.running}`, `pid: ${status.app.pid ?? 'none'}`, + `desktopWindowStatus: ${status.app.desktopWindowStatus ?? 'unknown'}`, `runtimeState: ${status.runtime.state}`, `runtimeReachable: ${status.runtime.reachable}`, `runtimeId: ${status.runtime.runtimeId ?? 'none'}`, diff --git a/src/cli/handlers/core.test.ts b/src/cli/handlers/core.test.ts new file mode 100644 index 00000000000..ac5444566d9 --- /dev/null +++ b/src/cli/handlers/core.test.ts @@ -0,0 +1,133 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })) + +// The claude-teams handler spawns `claude` via node:child_process; mock it so we +// can inspect the child env without launching a real process. +vi.mock('node:child_process', () => ({ spawn: spawnMock })) + +// Keep the socket runtime client out of the import graph; only the error type +// and serveOrcaApp binding are referenced by the module under test. +vi.mock('../runtime-client', () => ({ + RuntimeClientError: class RuntimeClientError extends Error { + readonly code: string + constructor(code: string, message: string) { + super(message) + this.code = code + } + }, + serveOrcaApp: vi.fn() +})) + +import { CORE_HANDLERS } from './core' +import type { HandlerContext } from '../dispatch' +import type { RuntimeClient } from '../runtime-client' + +type SpawnEnv = Record + +// Minimal child stub: the handler only awaits `exit`, so resolve it on the next +// microtask to complete the spawned-process promise deterministically. +function mockClaudeChild(): { once: (event: string, cb: (...args: unknown[]) => void) => unknown } { + const child = { + once(event: string, cb: (...args: unknown[]) => void) { + if (event === 'exit') { + queueMicrotask(() => cb(0, null)) + } + return child + } + } + return child +} + +describe('orca claude-teams CLI handler', () => { + const isWindows = process.platform === 'win32' + let previousRunAsNode: string | undefined + let previousPaneKey: string | undefined + let previousExitCode: typeof process.exitCode + + const callMock = vi.fn() + const client = { call: callMock } as unknown as RuntimeClient + + function runClaudeTeams(): Promise { + const ctx: HandlerContext = { + flags: new Map(), + client, + cwd: '/tmp/repo', + json: false, + rawArgs: [] + } + return CORE_HANDLERS['claude-teams'](ctx) + } + + beforeEach(() => { + spawnMock.mockReset() + spawnMock.mockImplementation(() => mockClaudeChild()) + callMock.mockReset() + callMock.mockResolvedValue({ + result: { + launch: { env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', PATH: '/shim:/usr/bin' } } + } + }) + previousRunAsNode = process.env.ELECTRON_RUN_AS_NODE + previousPaneKey = process.env.ORCA_PANE_KEY + previousExitCode = process.exitCode + // The `orca` launcher runs Orca's Electron binary as Node, so the CLI process + // itself carries ELECTRON_RUN_AS_NODE=1. Reproduce that inherited flag here. + process.env.ELECTRON_RUN_AS_NODE = '1' + process.env.ORCA_PANE_KEY = 'tab-1:leaf-1' + }) + + afterEach(() => { + if (previousRunAsNode === undefined) { + delete process.env.ELECTRON_RUN_AS_NODE + } else { + process.env.ELECTRON_RUN_AS_NODE = previousRunAsNode + } + if (previousPaneKey === undefined) { + delete process.env.ORCA_PANE_KEY + } else { + process.env.ORCA_PANE_KEY = previousPaneKey + } + process.exitCode = previousExitCode + }) + + // Guarded to non-Windows: the handler early-returns unsupported_platform on + // win32, so the leak path never runs there. + it.skipIf(isWindows)( + 'does not leak ELECTRON_RUN_AS_NODE into the spawned claude child', + async () => { + await runClaudeTeams() + + expect(spawnMock).toHaveBeenCalledWith('claude', expect.any(Array), expect.any(Object)) + const spawnEnv = spawnMock.mock.calls.at(-1)?.[2].env as SpawnEnv + expect(spawnEnv.ELECTRON_RUN_AS_NODE).toBeUndefined() + + // The prepareLaunch request env is built from the same helper, so it must + // be sanitized too. + const prepareLaunchEnv = (callMock.mock.calls[0][1] as { env: SpawnEnv }).env + expect(prepareLaunchEnv.ELECTRON_RUN_AS_NODE).toBeUndefined() + } + ) + + it.skipIf(isWindows)( + 'still forwards non-Electron parent env and prepareLaunch env to claude', + async () => { + const previousMarker = process.env.ORCA_TEST_MARKER + process.env.ORCA_TEST_MARKER = 'keep-me' + try { + await runClaudeTeams() + } finally { + if (previousMarker === undefined) { + delete process.env.ORCA_TEST_MARKER + } else { + process.env.ORCA_TEST_MARKER = previousMarker + } + } + + const spawnEnv = spawnMock.mock.calls.at(-1)?.[2].env as SpawnEnv + expect(spawnEnv.ORCA_TEST_MARKER).toBe('keep-me') + expect(spawnEnv.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBe('1') + expect(spawnEnv.PATH).toBe('/shim:/usr/bin') + } + ) +}) diff --git a/src/cli/handlers/core.ts b/src/cli/handlers/core.ts index 63a18484f0b..145540bb627 100644 --- a/src/cli/handlers/core.ts +++ b/src/cli/handlers/core.ts @@ -2,10 +2,16 @@ import { spawn } from 'node:child_process' import type { CommandHandler } from '../dispatch' import { formatCliStatus, formatStatus, printResult } from '../format' import { RuntimeClientError, serveOrcaApp } from '../runtime-client' +import { stripElectronRunAsNode } from '../runtime/launch' function envRecord(): Record { + // Why: the `orca` launcher runs Orca's Electron binary as Node, so this CLI + // process carries ELECTRON_RUN_AS_NODE=1. Strip it before it reaches the + // spawned `claude` (and any nested Electron it launches), which would + // otherwise be forced into headless plain-Node mode. + const env = stripElectronRunAsNode(process.env) return Object.fromEntries( - Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined) + Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined) ) } diff --git a/src/cli/handlers/orchestration-lifecycle-rejection.test.ts b/src/cli/handlers/orchestration-lifecycle-rejection.test.ts new file mode 100644 index 00000000000..269dfd7a47a --- /dev/null +++ b/src/cli/handlers/orchestration-lifecycle-rejection.test.ts @@ -0,0 +1,45 @@ +import { afterEach, expect, it, vi } from 'vitest' + +const callMock = vi.fn() +const originalExitCode = process.exitCode +vi.mock('../format', () => ({ printResult: vi.fn() })) +vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() })) + +import { printResult } from '../format' +import { ORCHESTRATION_HANDLERS } from './orchestration' + +afterEach(() => { + process.exitCode = originalExitCode +}) + +it('prints a lifecycle rejection and exits unsuccessfully', async () => { + const response = { + result: { + message: { id: 'msg_rejected' }, + lifecycle: { + action: 'rejected' as const, + code: 'sender_not_assignee', + reason: 'dispatch ctx_1 expected the assigned pane' + } + } + } + callMock.mockResolvedValueOnce(response) + + await ORCHESTRATION_HANDLERS['orchestration send']({ + flags: new Map([ + ['from', 'term_foreign'], + ['to', 'term_coord'], + ['subject', 'done'], + ['type', 'worker_done'] + ]), + client: { call: callMock }, + cwd: '/tmp/repo', + json: false + } as never) + + expect(process.exitCode).toBe(1) + const formatter = vi.mocked(printResult).mock.calls[0]?.[2] + expect(formatter?.(response.result)).toBe( + 'Rejected msg_rejected: dispatch ctx_1 expected the assigned pane' + ) +}) diff --git a/src/cli/handlers/orchestration.test.ts b/src/cli/handlers/orchestration.test.ts index d19d486e209..2a8f6d44dfb 100644 --- a/src/cli/handlers/orchestration.test.ts +++ b/src/cli/handlers/orchestration.test.ts @@ -14,6 +14,7 @@ vi.mock('../selectors', () => ({ getTerminalHandle: getTerminalHandleMock })) import { ORCHESTRATION_HANDLERS } from './orchestration' import { RuntimeClientError } from '../runtime-client' +import { printResult } from '../format' function staleHandleError(): RuntimeClientError { return new RuntimeClientError('terminal_handle_stale', 'terminal_handle_stale') @@ -135,6 +136,21 @@ describe('orchestration send structured payload flags', () => { }) }) + it('forwards multiline message bodies without normalization', async () => { + const body = 'paragraph one line one\nparagraph one line two\n\nparagraph two' + + await invokeSend( + new Map([ + ['from', 'term_worker'], + ['to', 'term_coord'], + ['subject', 'multiline'], + ['body', body] + ]) + ) + + expect(callMock).toHaveBeenCalledWith('orchestration.send', expect.objectContaining({ body })) + }) + it('rejects mixing raw payload with structured payload flags', async () => { await expect( invokeSend( @@ -213,7 +229,7 @@ describe('orchestration send structured payload flags', () => { }) }) - it('continues to use ORCA_TERMINAL_HANDLE as worker lifecycle sender authority', async () => { + it('sends lifecycle messages from ORCA_TERMINAL_HANDLE without a liveness probe', async () => { process.env.ORCA_TERMINAL_HANDLE = 'term_worker_env' await invokeSend( @@ -238,6 +254,50 @@ describe('orchestration send structured payload flags', () => { }) }) + it.each(['worker_done', 'heartbeat'] as const)( + 'never probes or remints a %s sender even when a pane key is set', + async (type) => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker_env' + process.env.ORCA_PANE_KEY = 'tab_worker:leaf_worker' + + await invokeSend( + new Map([ + ['to', 'term_coord'], + ['subject', 'update'], + ['type', type] + ]) + ) + + // Why: pre-payload-authority runtimes only complete a worker_done whose + // sender equals the recorded (equally stale) assignee handle, and + // coordinator replies route to the sender row the worker's env-handle + // `check` actually reads — so lifecycle sends must stay env-verbatim. + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith( + 'orchestration.send', + expect.objectContaining({ from: 'term_worker_env' }) + ) + } + ) + + it('passes ORCA_PANE_KEY as the sender pane identity', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker_env' + process.env.ORCA_PANE_KEY = 'tab_worker:leaf_worker' + + await invokeSend( + new Map([ + ['to', 'term_coord'], + ['subject', 'done'], + ['type', 'worker_done'] + ]) + ) + + expect(callMock).toHaveBeenCalledWith( + 'orchestration.send', + expect.objectContaining({ senderPaneKey: 'tab_worker:leaf_worker' }) + ) + }) + it('reports sender resolution failure instead of raw no_active_terminal', async () => { getTerminalHandleMock.mockRejectedValue( new RuntimeClientError('no_active_terminal', 'no_active_terminal') @@ -257,6 +317,26 @@ describe('orchestration send structured payload flags', () => { }) expect(callMock).not.toHaveBeenCalled() }) + + it.each(['worker_done', 'heartbeat'] as const)( + 'does not resolve an identity-less %s sender from the active terminal', + async (type) => { + getTerminalHandleMock.mockResolvedValue('term_active_coordinator') + + await expect( + invokeSend( + new Map([ + ['to', 'term_coord'], + ['subject', 'update'], + ['type', type] + ]) + ) + ).rejects.toMatchObject({ code: 'no_active_sender_terminal' }) + + expect(getTerminalHandleMock).not.toHaveBeenCalled() + expect(callMock).not.toHaveBeenCalled() + } + ) }) describe('orchestration dispatch coordinator handle', () => { @@ -631,20 +711,24 @@ describe('orchestration timeout flag validation', () => { expect(callMock).not.toHaveBeenCalled() }) - it('passes a parsed check timeout into the RPC payload', async () => { + it('passes a parsed check timeout and peek mode into the RPC payload', async () => { process.env.ORCA_TERMINAL_HANDLE = 'term_worker' callMock.mockResolvedValue({ result: { messages: [], count: 0 } }) await invokeCheck( new Map([ ['wait', true], + ['peek', true], ['timeout-ms', '250'] ]) ) + // Why: --peek rides with unread:false so pre-peek runtimes fall back to + // the non-consuming all mode instead of the destructive mark-read default. expect(callMock).toHaveBeenCalledWith('orchestration.check', { terminal: 'term_worker', - unread: undefined, + unread: false, + peek: true, all: undefined, types: undefined, inject: undefined, @@ -653,6 +737,86 @@ describe('orchestration timeout flag validation', () => { }) }) + it('filters already-read rows from a peek response for pre-peek runtimes', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + callMock.mockResolvedValue({ + result: { + messages: [ + { id: 'msg_old', from_handle: 'a', subject: 'seen', read: 1 }, + { id: 'msg_new', from_handle: 'a', subject: 'fresh', read: 0 } + ], + count: 2, + formatted: 'banners built from all rows' + } + }) + vi.mocked(printResult).mockClear() + + await invokeCheck(new Map([['peek', true]])) + + const response = vi.mocked(printResult).mock.calls[0]?.[0] as { + result: { messages: { id: string }[]; count: number; formatted?: string } + } + expect(response.result.messages.map((m) => m.id)).toEqual(['msg_new']) + expect(response.result.count).toBe(1) + // Why: the pre-peek runtime built `formatted` from all rows, including + // the read one the filter just removed. + expect(response.result.formatted).toBeUndefined() + }) + + it('rejects combined read modes before calling the runtime', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + callMock.mockClear() + + await expect( + invokeCheck( + new Map([ + ['unread', true], + ['peek', true] + ]) + ) + ).rejects.toMatchObject({ + code: 'invalid_argument', + message: expect.stringContaining('read mode') + }) + expect(callMock).not.toHaveBeenCalled() + }) + + it('warns when a pre-peek runtime returned a full 100-row page', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + const rows = Array.from({ length: 100 }, (_, i) => ({ + id: `msg_${i}`, + from_handle: 'a', + subject: `s${i}`, + read: i === 0 ? 0 : 1 + })) + callMock.mockResolvedValue({ result: { messages: rows, count: 100 } }) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await invokeCheck(new Map([['peek', true]])) + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('newest 100 messages')) + errorSpy.mockRestore() + }) + + it('fails --peek --wait against a runtime that returned only read rows', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + callMock.mockResolvedValue({ + result: { + messages: [{ id: 'msg_old', from_handle: 'a', subject: 'seen', read: 1 }], + count: 1 + } + }) + + await expect( + invokeCheck( + new Map([ + ['peek', true], + ['wait', true] + ]) + ) + ).rejects.toMatchObject({ code: 'peek_wait_unsupported' }) + }) + it.each(invalidTimeoutValues)('rejects invalid ask --timeout-ms: %s', async (_label, value) => { const flags = new Map([ ['to', 'term_coord'], @@ -697,3 +861,53 @@ describe('orchestration timeout flag validation', () => { ) }) }) + +describe('orchestration task-list brief output', () => { + it('requests server-side brief and falls back client-side for older runtimes', async () => { + callMock.mockReset().mockResolvedValue({ + result: { + // No spec_truncated field — the pre-brief-runtime signature. + tasks: [{ id: 'task_1', spec: `First line\n${'detail '.repeat(40)}`, status: 'ready' }], + count: 1 + } + }) + vi.mocked(printResult).mockClear() + + await ORCHESTRATION_HANDLERS['orchestration task-list']({ + flags: new Map([['brief', true]]), + client: { call: callMock }, + json: true + } as never) + + expect(callMock).toHaveBeenCalledWith( + 'orchestration.taskList', + expect.objectContaining({ brief: true }) + ) + const response = vi.mocked(printResult).mock.calls[0]?.[0] as { + result: { tasks: { spec: string; spec_truncated: boolean }[] } + } + expect(response.result.tasks[0].spec).toHaveLength(160) + expect(response.result.tasks[0].spec_truncated).toBe(true) + }) + + it('passes server-abbreviated rows through untouched', async () => { + const serverTasks = [ + { id: 'task_1', spec: 'already brief…', status: 'ready', spec_truncated: true } + ] + callMock.mockReset().mockResolvedValue({ result: { tasks: serverTasks, count: 1 } }) + vi.mocked(printResult).mockClear() + + await ORCHESTRATION_HANDLERS['orchestration task-list']({ + flags: new Map([['brief', true]]), + client: { call: callMock }, + json: true + } as never) + + const response = vi.mocked(printResult).mock.calls[0]?.[0] as { + result: { tasks: { spec: string; spec_truncated: boolean }[] } + } + // Why: re-abbreviating a server-truncated spec would flip spec_truncated + // back to false (the truncated text fits the cap). + expect(response.result.tasks).toBe(serverTasks) + }) +}) diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts index 4006c809cd1..fc285c56440 100644 --- a/src/cli/handlers/orchestration.ts +++ b/src/cli/handlers/orchestration.ts @@ -8,12 +8,13 @@ import { } from '../flags' import { RuntimeClientError } from '../runtime-client' import { getTerminalHandle } from '../selectors' +import { abbreviateOrchestrationTasks } from '../../shared/orchestration-task-summary' // Why: 15 s is well under Claude Code's empirical ~2 min Bash-tool silence // budget and generates only ~40 lines per 10 min wait — enough to assure the // parent process the subprocess is alive without flooding logs. See design // doc §3.4. -const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000 +const DEFAULT_KEEPALIVE_INTERVAL_MS = 15_000 function getLifecycleGroupRecipientError(type: 'worker_done' | 'heartbeat'): string { return `${type} messages must be sent to a concrete coordinator terminal handle, not a group address.` } @@ -21,33 +22,36 @@ function getLifecycleGroupRecipientError(type: 'worker_done' | 'heartbeat'): str // Why: test-only escape hatch so subprocess tests can verify the feature in // under 10 s rather than needing a full 15 s silence window. Production users // should never set this — there is no surface documentation. A bogus value -// falls back to the default rather than disabling the heartbeat. -function resolveHeartbeatIntervalMs(): number { - const raw = process.env.ORCA_HEARTBEAT_INTERVAL_MS +// falls back to the default rather than disabling the keepalive. +function resolveKeepaliveIntervalMs(): number { + const raw = process.env.ORCA_KEEPALIVE_INTERVAL_MS ?? process.env.ORCA_HEARTBEAT_INTERVAL_MS if (!raw) { - return DEFAULT_HEARTBEAT_INTERVAL_MS + return DEFAULT_KEEPALIVE_INTERVAL_MS } const parsed = Number(raw) if (!Number.isFinite(parsed) || parsed <= 0) { - return DEFAULT_HEARTBEAT_INTERVAL_MS + return DEFAULT_KEEPALIVE_INTERVAL_MS } return parsed } -function startCheckHeartbeat(deadlineMs: number | undefined): () => void { +function startCheckKeepalive(deadlineMs: number | undefined): () => void { const startedAt = Date.now() const interval = setInterval(() => { const payload = { + _keepalive: true, + // Why: retain the old marker for scripts filtering merged stderr while + // callers migrate to the unambiguous _keepalive field. _heartbeat: true, elapsedMs: Date.now() - startedAt, deadlineMs: deadlineMs ?? null } // Why: `process.stderr.write` is line-flushed per-call in Node, whereas a - // fully-buffered writer would hold all heartbeat lines until exit and + // fully-buffered writer would hold all keepalive lines until exit and // silently defeat the whole point of the ping. Subprocess test asserts // this by reading stderr incrementally. See §3.4. process.stderr.write(`${JSON.stringify(payload)}\n`) - }, resolveHeartbeatIntervalMs()) + }, resolveKeepaliveIntervalMs()) if (typeof interval.unref === 'function') { interval.unref() } @@ -73,8 +77,19 @@ type MessageSummary = { type?: string body?: string payload?: string | null + read?: number } +type LifecycleSendRejection = { + action: 'rejected' + code: string + reason: string +} + +type OrchestrationSendResult = + | { message: { id: string }; lifecycle?: LifecycleSendRejection } + | { messages: { id: string }[]; recipients: number } + function getOptionalStructuredMessagePayload( flags: Map ): string | undefined { @@ -142,7 +157,11 @@ async function resolveOrchestrationTerminalHandle( // coordinator preambles. const live = await isLiveTerminalHandle(envHandle, client) if (!live) { - return await resolveStaleOrchestrationSender(client) + const reminted = await resolveOrchestrationPaneTerminalHandle(client) + if (reminted) { + return reminted + } + throwNoActiveSenderTerminal() } } return envHandle @@ -268,16 +287,6 @@ function getClientErrorMessage(err: unknown): string | undefined { return typeof message === 'string' ? message : undefined } -async function resolveStaleOrchestrationSender( - client: Parameters[0]['client'] -): Promise { - const paneHandle = await resolveOrchestrationPaneTerminalHandle(client) - if (paneHandle) { - return paneHandle - } - throwNoActiveSenderTerminal() -} - async function resolveCoordinatorTerminalHandle( flags: Map, cwd: string, @@ -348,10 +357,24 @@ export const ORCHESTRATION_HANDLERS: Record = { const type = getOptionalStringFlag(flags, 'type') rejectLifecycleGroupRecipient(type, to) + if ( + (type === 'worker_done' || type === 'heartbeat') && + !getOptionalStringFlag(flags, 'from') && + !process.env.ORCA_TERMINAL_HANDLE + ) { + // Why: focus is not lifecycle authority; injected dispatches carry an + // explicit worker handle so an identity-less subprocess must fail closed. + throwNoActiveSenderTerminal() + } + + // Why: lifecycle senders keep ORCA_TERMINAL_HANDLE verbatim — no liveness + // probe (terminal.show throws runtime_unavailable in the exact mid-restart + // window worker_done must survive) and no pane remint (pre-payload- + // authority runtimes require from === the equally stale assignee_handle, + // and coordinator replies route to the sender row while the worker's own + // `check` reads its env-handle inbox). const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') - const result = await client.call< - { message: { id: string } } | { messages: { id: string }[]; recipients: number } - >('orchestration.send', { + const result = await client.call('orchestration.send', { from, to, subject: getRequiredStringFlag(flags, 'subject'), @@ -360,10 +383,21 @@ export const ORCHESTRATION_HANDLERS: Record = { priority: getOptionalStringFlag(flags, 'priority'), threadId: getOptionalStringFlag(flags, 'thread-id'), payload: getOptionalStructuredMessagePayload(flags), + // Why: the 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, devMode: isDevCliInvocation() }) + if ('message' in result.result && result.result.lifecycle?.action === 'rejected') { + // Why: a persisted diagnostic is not a successful lifecycle signal; + // the non-zero status prevents workers from treating it as completion. + process.exitCode = 1 + } printResult(result, json, (r) => { if ('message' in r) { + if (r.lifecycle?.action === 'rejected') { + return `Rejected ${r.message.id}: ${r.lifecycle.reason}` + } return `Sent ${r.message.id}` } return `Sent ${r.messages.length} messages to ${r.recipients} recipients` @@ -372,17 +406,27 @@ export const ORCHESTRATION_HANDLERS: Record = { 'orchestration check': async ({ flags, client, cwd, json }) => { const wait = flags.has('wait') + const peek = flags.has('peek') + // Why: enforce mode exclusivity client-side too — an older runtime strips + // the unknown `peek` param and would otherwise execute --unread --peek as + // a 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 terminal = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'terminal') // Why: Claude Code's Bash tool auto-backgrounds subprocesses that produce // no output for ~2 min (shorter on the non-interactive path). Emit a - // heartbeat line to stderr every HEARTBEAT_INTERVAL_MS while the wait is + // keepalive line to stderr every KEEPALIVE_INTERVAL_MS while the wait is // active so the parent process can see the subprocess is still alive. // Stderr rather than stdout so stdout stays a single final JSON payload, // and JSON-shaped rather than `# …` so `2>&1 | jq` pipelines still work // (jq refuses `#`-prefixed lines). See design doc §3.4. - const stopHeartbeat = wait ? startCheckHeartbeat(timeoutMs) : null + const stopKeepalive = wait ? startCheckKeepalive(timeoutMs) : null type CheckResult = { messages: MessageSummary[] count: number @@ -392,7 +436,12 @@ export const ORCHESTRATION_HANDLERS: Record = { try { result = await client.call('orchestration.check', { terminal, - unread: flags.has('unread') ? true : undefined, + // Why: --peek also sends unread:false so runtimes that predate the + // peek param (which their non-strict schema strips) degrade to the + // non-consuming all-messages mode instead of the destructive + // mark-read default; the read filter below restores peek semantics. + unread: flags.has('unread') ? true : peek ? false : undefined, + peek: peek ? true : undefined, all: flags.has('all') ? true : undefined, types: getOptionalStringFlag(flags, 'types'), inject: flags.has('inject') ? true : undefined, @@ -400,7 +449,41 @@ export const ORCHESTRATION_HANDLERS: Record = { timeoutMs }) } finally { - stopHeartbeat?.() + 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 in a peek response are the pre-peek-runtime signature + // (its schema stripped `peek` and it ran the all mode). Such a runtime + // returned instead of blocking, so honoring --wait is impossible — + // failing beats silently returning empty before the deadline. + 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 the newest 100 rows, so a + // full page means older unread messages may have been cut off. Warn on + // stderr so stdout stays a single JSON payload. + 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 + // when the read filter removed any so output matches the peek set. + ...(removedReadRows ? { formatted: undefined } : {}), + messages: unreadOnly, + count: unreadOnly.length + } + } } printResult(result, json, (r) => { if (r.formatted) { @@ -476,6 +559,7 @@ export const ORCHESTRATION_HANDLERS: Record = { }, 'orchestration task-list': async ({ flags, client, json }) => { + const brief = flags.has('brief') const result = await client.call<{ tasks: { id: string @@ -485,13 +569,26 @@ export const ORCHESTRATION_HANDLERS: Record = { status: string assignee_handle?: string | null dispatch_id?: string | null + spec_truncated?: boolean }[] count: number }>('orchestration.taskList', { status: getOptionalStringFlag(flags, 'status'), - ready: flags.has('ready') ? true : undefined + ready: flags.has('ready') ? true : undefined, + brief: brief ? true : undefined }) - printResult(result, json, (r) => { + // Why: current runtimes abbreviate server-side (rows carry + // spec_truncated) so full specs never cross the wire; older runtimes + // strip the brief param and need the 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 'No tasks.' } diff --git a/src/cli/help.ts b/src/cli/help.ts index 494e2969915..4da8ca88891 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -236,9 +236,9 @@ Common Commands: Selectors: --repo Registered repo selector such as id:, name:, or path: - --worktree Worktree selector such as id:, name:, branch:, issue:, path:, or active/current + --worktree Worktree selector such as id:::, name:, branch:, issue:, path:, or active/current --terminal Runtime-issued terminal handle returned by \`orca terminal list --json\` - --parent-worktree Parent worktree selector such as id:, branch:, issue:, path:, or active/current + --parent-worktree Parent worktree selector such as id:::, branch:, issue:, path:, or active/current --no-parent Force no parent lineage for unrelated worktree creation/update Terminal Send Options: @@ -446,7 +446,7 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string { return '--parent-current Use the current linked issue as parent' } if (command === 'worktree create' && flag === 'parent-worktree') { - return '--parent-worktree Parent selector such as active/current, id:, branch:, issue:, path:, folder:, or worktree:' + return '--parent-worktree Parent selector such as active/current, id:::, branch:, issue:, path:, folder:, or worktree:' } if (command === 'orchestration task-create' && flag === 'task-title') { return '--task-title Concise title for the orchestration task' @@ -501,7 +501,7 @@ export function formatFlagHelp(flag: string): string { 'no-screenshot': '--no-screenshot Skip screenshot capture after the operation', pages: '--pages Number of scroll pages', 'parent-worktree': - '--parent-worktree Parent worktree selector such as id:, branch:, issue:, path:, or active/current', + '--parent-worktree Parent worktree selector such as id:::, branch:, issue:, path:, or active/current', path: '--path Path argument for the command', prompt: '--prompt Prompt text for agent-backed commands', query: '--query Search text for matching refs', @@ -525,7 +525,7 @@ export function formatFlagHelp(flag: string): string { 'to-x': '--to-x Destination window-local x coordinate', 'to-y': '--to-y Destination window-local y coordinate', worktree: - '--worktree Worktree selector such as id:, name:, branch:, issue:, path:, or active/current', + '--worktree Worktree selector such as id:::, name:, branch:, issue:, path:, or active/current', workspace: '--workspace Existing worktree selector for automation runs', 'workspace-status': '--workspace-status Board status id (defaults: todo, in-progress, in-review, completed)', diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 2309b7a77f5..d464c17af83 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -446,7 +446,7 @@ describe('orca root help', () => { expect(createHelp).not.toContain('folderWorkspaceId') expect(createHelp).toContain('folder:') expect(createHelp).toContain('folder:') - expect(createHelp).toContain('worktree:') + expect(createHelp).toContain('worktree:') expect(createHelp).toContain( '--no-parent only affects Orca lineage; omit --base-branch to use the repo default base' ) @@ -499,6 +499,9 @@ describe('orca cli worktree awareness', () => { delete process.env.ORCA_USER_DATA_PATH delete process.env.ORCA_WORKSPACE_ID delete process.env.ORCA_WORKTREE_ID + // Isolate the pane key so claude-teams tests that set it don't leak a + // senderPaneKey into later orchestration.send assertions. + delete process.env.ORCA_PANE_KEY serveOrcaAppMock.mockReset() getDefaultUserDataPathMock.mockClear() addEnvironmentFromPairingCodeMock.mockReset() @@ -756,6 +759,9 @@ describe('orca cli worktree awareness', () => { expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( 'current is a local cwd shortcut and cannot be resolved against a remote runtime.' ) + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + 'id:::' + ) expect(process.exitCode).toBe(1) process.exitCode = priorExitCode @@ -1886,7 +1892,7 @@ describe('orca cli worktree awareness', () => { message: 'Parent selector was not found.', data: { nextSteps: [ - 'Pass a valid --parent-worktree selector such as folder:, worktree:, id:, branch:, issue:, path:, or active/current.', + 'Pass a valid --parent-worktree selector such as folder:, worktree:, id:::, branch:, issue:, path:, or active/current.', 'Retry with --no-parent to create without lineage.' ] } diff --git a/src/cli/runtime-client.test.ts b/src/cli/runtime-client.test.ts index 7243d19fa94..c11292453c1 100644 --- a/src/cli/runtime-client.test.ts +++ b/src/cli/runtime-client.test.ts @@ -2,13 +2,19 @@ import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { createServer, type Socket } from 'node:net' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { RuntimeClient, RuntimeRpcFailureError } from './runtime-client' +import { launchOrcaApp } from './runtime/launch' + +vi.mock('./runtime/launch', () => ({ + launchOrcaApp: vi.fn() +})) const servers = new Set>() const sockets = new Set() afterEach(async () => { + vi.mocked(launchOrcaApp).mockClear() for (const socket of sockets) { socket.destroy() } @@ -170,7 +176,7 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { expect(status.result.graph.state).toBe('unavailable') }) - it('openOrca succeeds immediately when the runtime is already reachable', async () => { + it('openOrca activates the app even when a desktop runtime is already reachable', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) const endpoint = join(userDataPath, 'runtime.sock') const server = createServer((socket) => { @@ -204,6 +210,87 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { expect(status.result.runtime.state).toBe('ready') expect(status.result.runtime.reachable).toBe(true) + expect(launchOrcaApp).toHaveBeenCalledOnce() + }) + + it('openOrca waits for a reachable headless runtime to expose a desktop window', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) + const endpoint = join(userDataPath, 'runtime.sock') + let statusRequests = 0 + const server = createServer((socket) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + socket.once('data', (data) => { + const request = JSON.parse(String(data).trim()) as { id: string } + statusRequests += 1 + const available = statusRequests > 1 + socket.write( + `${JSON.stringify({ + id: request.id, + ok: true, + result: { + runtimeId: 'runtime-1', + rendererGraphEpoch: available ? 1 : 0, + graphStatus: available ? 'reloading' : 'ready', + authoritativeWindowId: available ? 1 : 0, + desktopWindowStatus: available ? 'available' : 'initializing', + liveTabCount: 0, + liveLeafCount: 0 + }, + _meta: { runtimeId: 'runtime-1' } + })}\n` + ) + }) + }) + servers.add(server) + await new Promise((resolve) => server.listen(endpoint, resolve)) + writeMetadata(userDataPath, endpoint) + + const client = new RuntimeClient(userDataPath, 100) + const status = await client.openOrca(1_000) + + expect(launchOrcaApp).toHaveBeenCalledOnce() + expect(status.result.app.desktopWindowStatus).toBe('available') + expect(statusRequests).toBeGreaterThan(1) + }) + + it('openOrca fails explicitly when the serve owner cannot promote safely', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) + const endpoint = join(userDataPath, 'runtime.sock') + const server = createServer((socket) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + socket.once('data', (data) => { + const request = JSON.parse(String(data).trim()) as { id: string } + socket.write( + `${JSON.stringify({ + id: request.id, + ok: true, + result: { + runtimeId: 'runtime-1', + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: 0, + desktopWindowStatus: 'blocked', + liveTabCount: 1, + liveLeafCount: 1 + }, + _meta: { runtimeId: 'runtime-1' } + })}\n` + ) + }) + }) + servers.add(server) + await new Promise((resolve) => server.listen(endpoint, resolve)) + writeMetadata(userDataPath, endpoint) + + const client = new RuntimeClient(userDataPath, 100) + + await expect(client.openOrca(100)).rejects.toMatchObject({ + code: 'desktop_activation_blocked' + }) + // A blocked runtime can't promote, so we bail before spawning the app. + expect(launchOrcaApp).not.toHaveBeenCalled() }) it('times out if the runtime never responds', async () => { diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index f9211173be2..db0744aaa86 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -2,7 +2,7 @@ import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types' import { parsePairingCode, type PairingOffer } from '../../shared/pairing' import { launchOrcaApp } from './launch' import { getDefaultUserDataPath, readMetadata } from './metadata' -import { getCliStatus } from './status' +import { getCliStatus, resolveDesktopWindowStatus } from './status' import { sendRequest } from './transport' import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types' import { sendWebSocketRequest } from './websocket-transport' @@ -115,7 +115,13 @@ export class RuntimeClient { // that this client machine has a local Orca desktop process. app: { running: false, - pid: null + pid: null, + // Why: reuse the shared resolver so remote status honors the same + // authoritativeWindowId fallback as local status for old runtimes. + ...(() => { + const desktopWindowStatus = resolveDesktopWindowStatus(response.result) + return desktopWindowStatus ? { desktopWindowStatus } : {} + })() }, runtime: { state: graphState === 'ready' ? 'ready' : 'graph_not_ready', @@ -169,15 +175,27 @@ export class RuntimeClient { async openOrca(timeoutMs = 15_000): Promise> { const initial = await this.getCliStatus() - if (initial.result.runtime.reachable) { + if (this.remotePairing) { return initial } + // Why: a blocked runtime can't open a window, so spawning the app would + // only hit the single-instance lock and exit — bail before launching. + if (initial.result.app.desktopWindowStatus === 'blocked') { + throwDesktopActivationBlocked() + } launchOrcaApp() + if (initial.result.app.desktopWindowStatus === 'available') { + return initial + } + const startedAt = Date.now() while (Date.now() - startedAt < timeoutMs) { const status = await this.getCliStatus() - if (status.result.runtime.reachable) { + if (status.result.app.desktopWindowStatus === 'blocked') { + throwDesktopActivationBlocked() + } + if (status.result.app.desktopWindowStatus === 'available') { return status } await delay(250) @@ -185,11 +203,18 @@ export class RuntimeClient { throw new RuntimeClientError( 'runtime_open_timeout', - 'Timed out waiting for Orca to start. Run the Orca app manually and try again.' + 'Timed out waiting for an Orca desktop window. The runtime may still be running headlessly.' ) } } +function throwDesktopActivationBlocked(): never { + throw new RuntimeClientError( + 'desktop_activation_blocked', + 'Orca is running headlessly, but it cannot open a desktop window safely because the persistent terminal provider is unavailable. Quit Orca normally and start the app again; do not use open -n.' + ) +} + function resolveRemotePairing( userDataPath: string, pairingCode: string | null, diff --git a/src/cli/runtime/launch.test.ts b/src/cli/runtime/launch.test.ts index 05138e2f993..5a4fd2b7f25 100644 --- a/src/cli/runtime/launch.test.ts +++ b/src/cli/runtime/launch.test.ts @@ -1,6 +1,7 @@ import { EventEmitter } from 'node:events' import { resolve } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing' const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() @@ -18,6 +19,57 @@ class FakeChildProcess extends EventEmitter { unref = vi.fn() } +const RECIPE_JSON = JSON.stringify({ + schemaVersion: 1, + pairingCode: encodePairingOffer({ + v: PAIRING_OFFER_VERSION, + endpoint: 'wss://sandbox.example.com', + deviceToken: 'token', + publicKeyB64: 'public-key' + }), + projectRoot: '/workspace/repo' +}) +const SERVE_INSTALL_STATUS = '[serve] orca CLI install: installed' +const SSH_PRIVATE_KEY = 'TOP-SECRET-PRIVATE-KEY' +const SSH_AUTHORIZATION = 'Bearer TOP-SECRET-AUTHORIZATION' +const SSH_PASSPHRASE = 'TOP-SECRET-PASSPHRASE' +const SSH_COOKIE = 'session=TOP-SECRET-COOKIE' +const SSH_RECIPE_JSON = JSON.stringify({ + schemaVersion: 1, + connection: { + type: 'ssh', + target: { + label: 'Sandbox', + host: 'sandbox.example.com', + port: 22, + username: 'root' + }, + projectRoot: '/workspace/repo' + }, + userData: { + credentials: { + privateKey: SSH_PRIVATE_KEY, + authorization: SSH_AUTHORIZATION, + passphrase: SSH_PASSPHRASE, + cookie: SSH_COOKIE + } + } +}) +const INVALID_SSH_RECIPE_JSON = SSH_RECIPE_JSON.replace('/workspace/repo', 'relative/repo') +const IGNORED_NON_RECIPE_STDOUT = '[serve] ignored non-recipe stdout' + +function startRecipeJsonServer() { + const child = new FakeChildProcess() + spawnMock.mockReturnValue(child) + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const result = serveOrcaApp({ + recipeJson: true, + projectRoot: '/workspace/repo' + }) + return { child, result, stdoutSpy, stderrSpy } +} + describe('serveOrcaApp', () => { beforeEach(() => { spawnMock.mockReset() @@ -133,10 +185,7 @@ describe('serveOrcaApp', () => { projectRoot: '/workspace/repo' }) queueMicrotask(() => { - child.stdout.emit( - 'data', - '{"schemaVersion":1,"pairingCode":"orca://pair?code=abc","projectRoot":"/workspace/repo"}\n' - ) + child.stdout.emit('data', `${RECIPE_JSON}\n`) }) await expect(result).resolves.toBe(0) @@ -157,12 +206,92 @@ describe('serveOrcaApp', () => { stdio: ['ignore', 'pipe', 'inherit'] }) ) - expect(writeSpy).toHaveBeenCalledWith( - '{"schemaVersion":1,"pairingCode":"orca://pair?code=abc","projectRoot":"/workspace/repo"}\n' - ) + expect(writeSpy).toHaveBeenCalledWith(`${RECIPE_JSON}\n`) expect(child.unref).toHaveBeenCalled() }) + it('waits past startup status lines for valid recipe JSON', async () => { + const { child, result, stdoutSpy, stderrSpy } = startRecipeJsonServer() + queueMicrotask(() => { + child.stdout.emit( + 'data', + `${SERVE_INSTALL_STATUS}\n${INVALID_SSH_RECIPE_JSON}\n${SSH_RECIPE_JSON}\n${RECIPE_JSON.slice(0, 40)}` + ) + child.stdout.emit('data', `${RECIPE_JSON.slice(40)}\n`) + }) + + await expect(result).resolves.toBe(0) + + expect(stderrSpy).toHaveBeenNthCalledWith(1, `${IGNORED_NON_RECIPE_STDOUT}\n`) + expect(stderrSpy).toHaveBeenNthCalledWith(2, `${IGNORED_NON_RECIPE_STDOUT}\n`) + expect(stderrSpy).toHaveBeenNthCalledWith(3, `${IGNORED_NON_RECIPE_STDOUT}\n`) + for (const secret of [SSH_PRIVATE_KEY, SSH_AUTHORIZATION, SSH_PASSPHRASE, SSH_COOKIE]) { + expect(stderrSpy).not.toHaveBeenCalledWith(expect.stringContaining(secret)) + } + expect(stdoutSpy).toHaveBeenCalledTimes(1) + expect(stdoutSpy).toHaveBeenCalledWith(`${RECIPE_JSON}\n`) + expect(child.unref).toHaveBeenCalledOnce() + }) + + it('preserves UTF-8 recipe JSON split across Buffer chunks', async () => { + const { child, result, stdoutSpy } = startRecipeJsonServer() + const unicodeRecipeJson = RECIPE_JSON.replace('/workspace/repo', '/workspace/café') + const recipeBuffer = Buffer.from(`${unicodeRecipeJson}\n`) + const splitIndex = recipeBuffer.indexOf(Buffer.from('é')) + 1 + queueMicrotask(() => { + child.stdout.emit('data', recipeBuffer.subarray(0, splitIndex)) + child.stdout.emit('data', recipeBuffer.subarray(splitIndex)) + }) + + await expect(result).resolves.toBe(0) + + expect(stdoutSpy).toHaveBeenCalledWith(`${unicodeRecipeJson}\n`) + expect(child.unref).toHaveBeenCalledOnce() + }) + + it('rejects when the server exits without valid recipe JSON', async () => { + const { child, result, stdoutSpy, stderrSpy } = startRecipeJsonServer() + const secrets = ['UPPER-SECRET', 'SLASH-SECRET', 'LEGACY-SECRET', 'PRIVATE-SECRET'] + const untrustedLines = [ + 'ORCA://pair?code=UPPER-SECRET', + 'orca://pair/?code=SLASH-SECRET', + 'orca://pair#LEGACY-SECRET', + '"embedded privateKey PRIVATE-SECRET"', + '{privateKey:"PRIVATE-SECRET"}' + ].join('\n') + queueMicrotask(() => { + child.stdout.emit('data', `${untrustedLines}\n`) + child.emit('exit', 0, null) + child.emit('close', 0, null) + }) + + await expect(result).rejects.toMatchObject({ + code: 'runtime_serve_failed', + message: 'Orca serve exited before printing valid recipe JSON with code 0.' + }) + expect(stdoutSpy).not.toHaveBeenCalled() + expect(stderrSpy).toHaveBeenCalledTimes(5) + expect(stderrSpy).toHaveBeenCalledWith(`${IGNORED_NON_RECIPE_STDOUT}\n`) + for (const secret of secrets) { + expect(stderrSpy).not.toHaveBeenCalledWith(expect.stringContaining(secret)) + } + expect(child.unref).not.toHaveBeenCalled() + }) + + it('accepts valid recipe JSON at exit without a trailing newline', async () => { + const { child, result, stdoutSpy } = startRecipeJsonServer() + queueMicrotask(() => { + child.emit('exit', 0, null) + child.stdout.emit('data', RECIPE_JSON) + child.emit('close', 0, null) + }) + + await expect(result).resolves.toBe(0) + + expect(stdoutSpy).toHaveBeenCalledWith(`${RECIPE_JSON}\n`) + expect(child.unref).toHaveBeenCalledOnce() + }) + it('uses a shell when a Windows npm command shim is the Electron executable', async () => { const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { value: 'win32' }) diff --git a/src/cli/runtime/launch.ts b/src/cli/runtime/launch.ts index 29b5d561b16..94791306c95 100644 --- a/src/cli/runtime/launch.ts +++ b/src/cli/runtime/launch.ts @@ -1,7 +1,14 @@ import { spawn as spawnProcess, type SpawnOptions } from 'node:child_process' import { dirname, resolve } from 'node:path' +import { StringDecoder } from 'node:string_decoder' +import { + getEphemeralVmRecipeResultConnection, + parseEphemeralVmRecipeResult +} from '../../shared/ephemeral-vm-recipes' import { RuntimeClientError } from './types' +const IGNORED_NON_RECIPE_STDOUT = '[serve] ignored non-recipe stdout' + export function launchOrcaApp(): void { const overrideCommand = process.env.ORCA_OPEN_COMMAND if (typeof overrideCommand === 'string' && overrideCommand.trim().length > 0) { @@ -155,7 +162,7 @@ function waitForRecipeJson(child: ReturnType): Promise): Promise { - process.stdout.write(`${line}\n`) - finish() + const writeIgnoredRecipeStdout = (): void => { + // Why: non-readiness child stdout is untrusted and cannot be safely + // redacted, including schema-valid results with arbitrary user data. + process.stderr.write(`${IGNORED_NON_RECIPE_STDOUT}\n`) } - const onData = (chunk: Buffer | string): void => { - output += chunk.toString() - const newlineIndex = output.indexOf('\n') - if (newlineIndex === -1) { + const processRecipeOutputLine = (line: string): void => { + const normalizedLine = line.endsWith('\r') ? line.slice(0, -1) : line + if (!normalizedLine.trim()) { return } - emitLine(output.slice(0, newlineIndex)) + const parsed = parseEphemeralVmRecipeResult(normalizedLine) + if (!parsed.ok) { + writeIgnoredRecipeStdout() + return + } + if (getEphemeralVmRecipeResultConnection(parsed.result).type !== 'orca-server') { + writeIgnoredRecipeStdout() + return + } + process.stdout.write(`${normalizedLine.trim()}\n`) + finish() + } + const stdoutDecoder = new StringDecoder('utf8') + const onData = (chunk: Buffer | string): void => { + output += typeof chunk === 'string' ? chunk : stdoutDecoder.write(chunk) + while (!settled) { + const newlineIndex = output.indexOf('\n') + if (newlineIndex === -1) { + return + } + const line = output.slice(0, newlineIndex) + output = output.slice(newlineIndex + 1) + processRecipeOutputLine(line) + } } const onError = (error: Error): void => { finish(error) } - const onExit = (code: number | null, signal: NodeJS.Signals | null): void => { + const onClose = (code: number | null, signal: NodeJS.Signals | null): void => { if (settled) { return } - const trimmed = output.trim() - if (trimmed) { - emitLine(trimmed) + output += stdoutDecoder.end() + if (output.trim()) { + processRecipeOutputLine(output) + } + if (settled) { return } finish( new RuntimeClientError( 'runtime_serve_failed', typeof code === 'number' - ? `Orca serve exited before printing recipe JSON with code ${code}.` - : `Orca serve exited before printing recipe JSON via ${signal}.` + ? `Orca serve exited before printing valid recipe JSON with code ${code}.` + : `Orca serve exited before printing valid recipe JSON via ${signal}.` ) ) } child.stdout?.on('data', onData) child.once('error', onError) - child.once('exit', onExit) + // Why: `exit` can precede the final piped stdout data. `close` waits until + // stdio closes so a last recipe chunk is not mistaken for missing output. + child.once('close', onClose) }) } @@ -232,7 +266,7 @@ function resolveForegroundOrcaExecutable(): string { ) } -function stripElectronRunAsNode(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { +export function stripElectronRunAsNode(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const next = { ...env } delete next.ELECTRON_RUN_AS_NODE return next diff --git a/src/cli/runtime/status.ts b/src/cli/runtime/status.ts index dff8c231fc9..189297bf05f 100644 --- a/src/cli/runtime/status.ts +++ b/src/cli/runtime/status.ts @@ -35,10 +35,12 @@ export async function getCliStatus( throw new RuntimeRpcFailureError(response) } const graphState = response.result.graphStatus + const desktopWindowStatus = resolveDesktopWindowStatus(response.result) return buildCliStatusResponse({ app: { running: true, - pid: metadata.pid + pid: metadata.pid, + ...(desktopWindowStatus ? { desktopWindowStatus } : {}) }, runtime: { state: graphState === 'ready' ? 'ready' : 'graph_not_ready', @@ -68,6 +70,19 @@ export async function getCliStatus( } } +export function resolveDesktopWindowStatus( + status: RuntimeStatus +): CliStatusResult['app']['desktopWindowStatus'] { + if (status.desktopWindowStatus) { + return status.desktopWindowStatus + } + // Why: older desktop runtimes predate the explicit status but a positive + // Electron id still proves that a real window owns the graph. + return status.authoritativeWindowId !== null && status.authoritativeWindowId > 0 + ? 'available' + : undefined +} + function buildCliStatusResponse(result: CliStatusResult): RuntimeRpcSuccess { return { id: 'local-status', diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index 6a60c97b310..364fc05c508 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -2,7 +2,7 @@ import { createServer, type Server } from 'node:http' import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { WebSocketServer } from 'ws' import { encodePairingOffer, type PairingOffer } from '../../shared/pairing' import { @@ -13,6 +13,7 @@ import { publicKeyToBase64 } from '../../shared/e2ee-crypto' import { RuntimeClient } from './client' +import { launchOrcaApp } from './launch' import { addEnvironmentFromPairingCode } from './environments' import { RuntimeClientError } from './types' import { @@ -20,6 +21,10 @@ import { RUNTIME_PROTOCOL_VERSION } from '../../shared/protocol-version' +vi.mock('./launch', () => ({ + launchOrcaApp: vi.fn() +})) + type TestRuntime = { endpoint: string publicKeyB64: string @@ -31,6 +36,7 @@ describe('CLI remote WebSocket transport', () => { const servers: TestRuntime[] = [] afterEach(async () => { + vi.mocked(launchOrcaApp).mockClear() await Promise.all(servers.splice(0).map((server) => server.close())) }) @@ -79,6 +85,28 @@ describe('CLI remote WebSocket transport', () => { expect(status.result.runtime.runtimeId).toBe('runtime-ws-2') }) + it('does not launch a local desktop app for remote-paired open', async () => { + const runtime = await startTestRuntime('runtime-remote-headless', { + desktopWindowStatus: 'initializing' + }) + servers.push(runtime) + const client = new RuntimeClient( + '/tmp/unused', + 5_000, + encodePairingOffer({ + v: 2, + endpoint: runtime.endpoint, + deviceToken: runtime.deviceToken, + publicKeyB64: runtime.publicKeyB64 + }) + ) + + const status = await client.openOrca() + + expect(status.result.app.desktopWindowStatus).toBe('initializing') + expect(launchOrcaApp).not.toHaveBeenCalled() + }) + it('connects through a saved environment selector', async () => { const runtime = await startTestRuntime('runtime-env-1') servers.push(runtime) @@ -128,6 +156,7 @@ async function startTestRuntime( statusOverrides: { runtimeProtocolVersion?: number minCompatibleRuntimeClientVersion?: number + desktopWindowStatus?: 'available' | 'openable' | 'initializing' | 'blocked' } = {} ): Promise { const serverKeyPair = generateKeyPair() @@ -177,6 +206,7 @@ async function startTestRuntime( rendererGraphEpoch: 1, graphStatus: 'ready', authoritativeWindowId: null, + desktopWindowStatus: statusOverrides.desktopWindowStatus, liveTabCount: 0, liveLeafCount: 0, runtimeProtocolVersion: diff --git a/src/cli/selectors.ts b/src/cli/selectors.ts index a00f86ec1a6..008ab2eb764 100644 --- a/src/cli/selectors.ts +++ b/src/cli/selectors.ts @@ -1,4 +1,4 @@ -import { isAbsolute, relative, resolve as resolvePath } from 'node:path' +import { resolve as resolvePath } from 'node:path' import type { ComputerAppQuery, RuntimeWorktreeListResult, @@ -39,18 +39,10 @@ function assertLocalCwdWorktreeSelector(selector: string, client: RuntimeClient) // server, so cwd-derived worktree selectors are only valid locally. throw new RuntimeClientError( 'invalid_argument', - `${selector} is a local cwd shortcut and cannot be resolved against a remote runtime. Pass an explicit server-side worktree selector such as id:, name:, branch:, issue:, or path:.` + `${selector} is a local cwd shortcut and cannot be resolved against a remote runtime. Pass an explicit server-side worktree selector such as id:::, name:, branch:, issue:, or path:.` ) } -function isWithinPath(parentPath: string, childPath: string): boolean { - if (isPathInsideOrEqual(parentPath, childPath)) { - return true - } - const relativePath = relative(parentPath, childPath) - return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath)) -} - export async function resolveCurrentWorktreeSelector( cwd: string, client: RuntimeClient @@ -65,7 +57,10 @@ export async function resolveCurrentWorktreeSelector( let enclosingPathLength = -1 for (const worktree of worktrees.result.worktrees) { const worktreePath = resolvePath(worktree.path) - if (!isWithinPath(worktreePath, currentPath) || worktreePath.length <= enclosingPathLength) { + if ( + !isPathInsideOrEqual(worktreePath, currentPath) || + worktreePath.length <= enclosingPathLength + ) { continue } enclosingWorktree = worktree diff --git a/src/cli/specs/automations.ts b/src/cli/specs/automations.ts index e4d0b9640cc..84d462a0f26 100644 --- a/src/cli/specs/automations.ts +++ b/src/cli/specs/automations.ts @@ -90,6 +90,7 @@ export const AUTOMATION_COMMAND_SPECS: CommandSpec[] = [ }, { path: ['automations', 'remove'], + destructive: true, summary: 'Remove an Orca automation and its run history', usage: 'orca automations remove [--json]', allowedFlags: [...GLOBAL_FLAGS, 'id'], diff --git a/src/cli/specs/browser-advanced.ts b/src/cli/specs/browser-advanced.ts index c7acc92c9fe..695932fae97 100644 --- a/src/cli/specs/browser-advanced.ts +++ b/src/cli/specs/browser-advanced.ts @@ -29,6 +29,7 @@ export const BROWSER_ADVANCED_COMMAND_SPECS: CommandSpec[] = [ }, { path: ['cookie', 'delete'], + destructive: true, summary: 'Delete a cookie by name', usage: 'orca cookie delete --name [--domain ] [--url ] [--worktree ] [--json]', @@ -240,6 +241,7 @@ export const BROWSER_ADVANCED_COMMAND_SPECS: CommandSpec[] = [ }, { path: ['storage', 'local', 'clear'], + destructive: true, summary: 'Clear all localStorage', usage: 'orca storage local clear [--worktree ] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'worktree'] @@ -258,6 +260,7 @@ export const BROWSER_ADVANCED_COMMAND_SPECS: CommandSpec[] = [ }, { path: ['storage', 'session', 'clear'], + destructive: true, summary: 'Clear all sessionStorage', usage: 'orca storage session clear [--worktree ] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'worktree'] diff --git a/src/cli/specs/browser-basic.ts b/src/cli/specs/browser-basic.ts index a4bf31eea42..0d7d3a99c2d 100644 --- a/src/cli/specs/browser-basic.ts +++ b/src/cli/specs/browser-basic.ts @@ -195,6 +195,7 @@ export const BROWSER_BASIC_COMMAND_SPECS: CommandSpec[] = [ }, { path: ['tab', 'profile', 'delete'], + destructive: true, summary: 'Delete a browser session profile used by browser tabs', usage: 'orca tab profile delete --profile [--json]', allowedFlags: [...GLOBAL_FLAGS, 'profile'] diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts index e4f2ffe9f91..3c89c5ff3a5 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -111,11 +111,12 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ 'By default, Orca records the new worktree as a child of the caller context when it can infer one from the Orca terminal or current directory.', 'If --repo is omitted, Orca infers the repo from the current Orca-managed worktree.', 'Use --project with --host to create on a ready project host setup without spelling the backing repo id.', - 'For related work, use the inferred parent or pass --parent-worktree active, folder:, or worktree: to make the relationship explicit.', + 'For related work, use the inferred parent or pass --parent-worktree active, folder:, or worktree: to make the relationship explicit. Worktree ids are the full :: values returned by `orca worktree list --json`.', 'Use --no-parent when the new worktree should be independent of the current context.', '--no-parent only affects Orca lineage; omit --base-branch to use the repo default base, or pass the default base ref explicitly for independent top-level work.', 'By default this creates the worktree and its first terminal without switching the active Orca view.', 'Pass --agent to launch an agent in the first terminal; --prompt sends initial work to that agent.', + 'With --agent --json, read the new agent handle from result.agentTerminalHandle; older runtimes return only result.startupTerminal.handle, and may return neither for folder-based repos.', 'Repo-defined setup hooks follow the repository setup policy; pass --setup run to force them.', 'Pass --activate when the CLI caller intentionally wants to reveal the new worktree in the app.', 'Passing --run-hooks is kept as a legacy alias for --setup run and reveals the worktree.' @@ -164,6 +165,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ ['worktree', 'remove'], ['worktree', 'delete'] ], + destructive: true, summary: 'Remove a worktree from Orca and git', usage: 'orca worktree rm --worktree [--force] [--run-hooks] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'force', 'run-hooks'], diff --git a/src/cli/specs/environment.ts b/src/cli/specs/environment.ts index 2b10baff97e..7c4aecbc31a 100644 --- a/src/cli/specs/environment.ts +++ b/src/cli/specs/environment.ts @@ -23,6 +23,7 @@ export const ENVIRONMENT_COMMAND_SPECS: CommandSpec[] = [ }, { path: ['environment', 'rm'], + destructive: true, summary: 'Remove one saved Orca runtime environment', usage: 'orca environment rm --environment [--json]', allowedFlags: [...GLOBAL_FLAGS] diff --git a/src/cli/specs/orchestration.ts b/src/cli/specs/orchestration.ts index d3708a3dc77..bb63f261d00 100644 --- a/src/cli/specs/orchestration.ts +++ b/src/cli/specs/orchestration.ts @@ -26,6 +26,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ notes: [ 'On Windows PowerShell, quote group addresses such as --to "@all" or --to "@worktree:".', 'worker_done and heartbeat must target a concrete coordinator terminal handle; use status for broadcast updates.', + 'A worker_done with the active task/dispatch IDs completes that task only from the dispatched pane. When stable pane identity is unavailable, the sender handle must exactly match the dispatch assignee; injected preambles include the correct --from value.', 'Prefer --task-id/--dispatch-id/etc. over raw --payload JSON in worker commands; PowerShell strips JSON quotes easily.' ] }, @@ -33,17 +34,20 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ path: ['orchestration', 'check'], summary: 'Check messages for a terminal', usage: - 'orca orchestration check [--terminal ] [--unread | --all] [--types ] [--inject] [--wait] [--timeout-ms ] [--json]\n' + + 'orca orchestration check [--terminal ] [--unread | --peek | --all] [--types ] [--inject] [--wait] [--timeout-ms ] [--json]\n' + ' --unread (default): return only unread messages and mark them read.\n' + + ' --peek: return only unread messages without marking them read.\n' + ' --all: return every message for the handle; does not mark read.\n' + ' --wait: block until a matching message arrives or --timeout-ms expires.\n' + - ' Emits JSON heartbeat lines to stderr every 15s so the caller can\n' + - ' tell the process is alive. Filter with `grep -v _heartbeat` or\n' + - ' `jq "select(._heartbeat|not)"` when merging streams with 2>&1.', + ' Emits JSON keepalive lines to stderr every 15s so the caller can\n' + + ' tell the process is alive. `_keepalive` is unrelated to heartbeat\n' + + ' messages; `_heartbeat` remains as a deprecated compatibility alias.\n' + + ' Filter with `jq "select(._keepalive|not)"` when merging streams.', allowedFlags: [ ...GLOBAL_FLAGS, 'terminal', 'unread', + 'peek', 'all', 'types', 'inject', @@ -76,8 +80,9 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ { path: ['orchestration', 'task-list'], summary: 'List orchestration tasks', - usage: 'orca orchestration task-list [--status ] [--ready] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'status', 'ready'] + usage: 'orca orchestration task-list [--status ] [--ready] [--brief] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'status', 'ready', 'brief'], + notes: ['--brief collapses whitespace and caps each spec at 160 characters.'] }, { path: ['orchestration', 'task-update'], diff --git a/src/cli/specs/project.ts b/src/cli/specs/project.ts index f9070da9f40..989a83c9f77 100644 --- a/src/cli/specs/project.ts +++ b/src/cli/specs/project.ts @@ -101,6 +101,7 @@ export const PROJECT_COMMAND_SPECS: CommandSpec[] = [ }, { path: ['project', 'setup-delete'], + destructive: true, summary: 'Remove a project host setup', usage: 'orca project setup-delete --setup [--json]', allowedFlags: [...GLOBAL_FLAGS, 'setup'], diff --git a/src/main/agent-hooks/agent-hook-memory-sftp.test-fixture.ts b/src/main/agent-hooks/agent-hook-memory-sftp.test-fixture.ts new file mode 100644 index 00000000000..1dfaabf6c68 --- /dev/null +++ b/src/main/agent-hooks/agent-hook-memory-sftp.test-fixture.ts @@ -0,0 +1,87 @@ +import type { SFTPWrapper } from 'ssh2' + +export type AgentHookMemoryFileSystem = { + files: Map + dirs: Set + modes: Map +} + +export function createAgentHookMemorySftp(initialFiles: Record = {}): { + sftp: SFTPWrapper + fs: AgentHookMemoryFileSystem +} { + const fs: AgentHookMemoryFileSystem = { + files: new Map(Object.entries(initialFiles)), + dirs: new Set(['/']), + modes: new Map() + } + const missing = (path: string): { code: number; message: string } => ({ + code: 2, + message: `ENOENT ${path}` + }) + const sftp = { + readFile: (path: string, _encoding: string, done: (error: unknown, data?: string) => void) => { + const data = fs.files.get(path) + if (data === undefined) { + done(missing(path)) + return + } + done(null, data) + }, + writeFile: ( + path: string, + content: string, + options: string | { mode?: number }, + done: (error: unknown) => void + ) => { + fs.files.set(path, content) + if (typeof options !== 'string' && options.mode !== undefined) { + fs.modes.set(path, options.mode) + } + done(null) + }, + rename: (source: string, target: string, done: (error: unknown) => void) => { + const content = fs.files.get(source) + if (content === undefined) { + done(missing(source)) + return + } + fs.files.set(target, content) + fs.files.delete(source) + const mode = fs.modes.get(source) + if (mode !== undefined) { + fs.modes.set(target, mode) + fs.modes.delete(source) + } + done(null) + }, + unlink: (path: string, done: (error: unknown) => void) => { + fs.files.delete(path) + fs.modes.delete(path) + done(null) + }, + chmod: (path: string, mode: number, done: (error: unknown) => void) => { + fs.modes.set(path, mode) + done(null) + }, + stat: (path: string, done: (error: unknown, stats?: { mode: number }) => void) => { + if (!fs.files.has(path)) { + done(missing(path)) + return + } + done(null, { mode: fs.modes.get(path) ?? 0o100644 }) + }, + readdir: (path: string, done: (error: unknown, entries?: { filename: string }[]) => void) => { + if (!fs.dirs.has(path)) { + done(missing(path)) + return + } + done(null, []) + }, + mkdir: (path: string, done: (error: unknown) => void) => { + fs.dirs.add(path) + done(null) + } + } as unknown as SFTPWrapper + return { sftp, fs } +} diff --git a/src/main/agent-hooks/branch-rename-failure-output.test.ts b/src/main/agent-hooks/branch-rename-failure-output.test.ts new file mode 100644 index 00000000000..6df43fa457a --- /dev/null +++ b/src/main/agent-hooks/branch-rename-failure-output.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + __resetBranchRenameFailureOutputForTests, + readBranchRenameFailureOutputForDisplay, + rememberBranchRenameFailureOutput +} from './branch-rename-failure-output' + +const output = (label: string) => ({ + label, + exitCode: 1, + stdout: '', + stderr: `${label} failed` +}) + +beforeEach(() => { + __resetBranchRenameFailureOutputForTests() +}) + +describe('branch rename failure output store', () => { + it('returns the formatted capture for a remembered worktree', () => { + rememberBranchRenameFailureOutput('wt-1', output('Pi')) + expect(readBranchRenameFailureOutputForDisplay('wt-1')).toBe( + 'Pi exited with code 1.\n\n[stderr]\nPi failed' + ) + }) + + it('returns null for unknown worktrees and after clearing', () => { + expect(readBranchRenameFailureOutputForDisplay('wt-unknown')).toBeNull() + rememberBranchRenameFailureOutput('wt-1', output('Pi')) + rememberBranchRenameFailureOutput('wt-1', null) + expect(readBranchRenameFailureOutputForDisplay('wt-1')).toBeNull() + }) + + it('evicts the least recently recorded worktree beyond the cap', () => { + for (let index = 0; index < 33; index += 1) { + rememberBranchRenameFailureOutput(`wt-${index}`, output(`Agent${index}`)) + } + expect(readBranchRenameFailureOutputForDisplay('wt-0')).toBeNull() + expect(readBranchRenameFailureOutputForDisplay('wt-32')).not.toBeNull() + }) + + it('re-recording refreshes recency so the entry survives eviction', () => { + for (let index = 0; index < 32; index += 1) { + rememberBranchRenameFailureOutput(`wt-${index}`, output(`Agent${index}`)) + } + rememberBranchRenameFailureOutput('wt-0', output('Agent0-again')) + rememberBranchRenameFailureOutput('wt-new', output('AgentNew')) + expect(readBranchRenameFailureOutputForDisplay('wt-0')).not.toBeNull() + expect(readBranchRenameFailureOutputForDisplay('wt-1')).toBeNull() + }) +}) diff --git a/src/main/agent-hooks/branch-rename-failure-output.ts b/src/main/agent-hooks/branch-rename-failure-output.ts new file mode 100644 index 00000000000..8b8660484b7 --- /dev/null +++ b/src/main/agent-hooks/branch-rename-failure-output.ts @@ -0,0 +1,40 @@ +import { + formatAgentGenerationFailureOutputForDisplay, + type AgentGenerationFailureOutput +} from '../text-generation/agent-failure-output' + +// Why: the full CLI output is a diagnostic for the local user only. Keeping it +// in memory (never in worktree metadata) means nothing environment-identifying +// is persisted or synced to paired clients; a restart just loses the on-demand +// view while the sanitized excerpt badge survives. +const MAX_ENTRIES = 32 +const entriesByWorktreeId = new Map() + +export function rememberBranchRenameFailureOutput( + worktreeId: string, + output: AgentGenerationFailureOutput | null | undefined +): void { + // Delete-then-set keeps insertion order as recency so eviction drops the + // stalest worktree first. + entriesByWorktreeId.delete(worktreeId) + if (!output) { + return + } + entriesByWorktreeId.set(worktreeId, output) + while (entriesByWorktreeId.size > MAX_ENTRIES) { + const oldest = entriesByWorktreeId.keys().next().value + if (oldest === undefined) { + break + } + entriesByWorktreeId.delete(oldest) + } +} + +export function readBranchRenameFailureOutputForDisplay(worktreeId: string): string | null { + const entry = entriesByWorktreeId.get(worktreeId) + return entry ? formatAgentGenerationFailureOutputForDisplay(entry) : null +} + +export function __resetBranchRenameFailureOutputForTests(): void { + entriesByWorktreeId.clear() +} diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index e84cd3d0cf4..c1f46a602fb 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -185,6 +185,19 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { expect(onRenamed).toHaveBeenCalledWith(REPO_ID) }) + it('runs Git against the backing folder for a folder-workspace instance id', async () => { + // Why: instance ids carry a synthetic `::workspace:` suffix that is not + // a real directory. The Git cwd must resolve to the folder or `rev-parse` + // spawns against a nonexistent path (ENOENT). + const instanceId = `${WORKTREE_ID}::workspace:123e4567-e89b-12d3-a456-426614174000` + const { deps } = makeDeps({ resolveWorktreeIdForTab: () => instanceId }) + await maybeAutoRenameBranchOnFirstWork(workingEvent({ worktreeId: undefined }), deps) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['branch', '-m', 'you/fix-auth'], + expect.objectContaining({ cwd: '/repo/wt' }) + ) + }) + it('skips when no worktree can be resolved for the tab', async () => { const { deps } = makeDeps({ resolveWorktreeIdForTab: () => undefined }) await maybeAutoRenameBranchOnFirstWork(workingEvent({ worktreeId: undefined }), deps) @@ -300,11 +313,16 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { expect(setRenameError).toHaveBeenLastCalledWith(WORKTREE_ID, null) }) - it('records a user-facing error when branch-name generation fails', async () => { - generateBranchNameMock.mockResolvedValueOnce({ success: false, error: 'agent not ready' }) + it('records a user-facing error and the full CLI output when generation fails', async () => { + const failureOutput = { label: 'Pi', exitCode: 1, stdout: '', stderr: 'No API key found.' } + generateBranchNameMock.mockResolvedValueOnce({ + success: false, + error: 'agent not ready', + failureOutput + }) const { deps, setRenameError } = makeDeps() await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) - expect(setRenameError).toHaveBeenCalledWith(WORKTREE_ID, 'agent not ready') + expect(setRenameError).toHaveBeenCalledWith(WORKTREE_ID, 'agent not ready', failureOutput) }) it('records a user-facing error when no generation agent is configured', async () => { @@ -341,7 +359,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { generateBranchNameMock.mockResolvedValueOnce({ success: false, error: 'agent not ready' }) const { deps, setRenameError } = makeDeps() await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) - expect(setRenameError).toHaveBeenCalledWith(WORKTREE_ID, 'agent not ready') + expect(setRenameError).toHaveBeenCalledWith(WORKTREE_ID, 'agent not ready', null) // Second event: the user has since pushed the branch, so it settles benignly. // The stale "rename failed" badge must be cleared rather than stick forever. diff --git a/src/main/agent-hooks/first-work-branch-rename.ts b/src/main/agent-hooks/first-work-branch-rename.ts index fabf508b141..c6e7fb8d10e 100644 --- a/src/main/agent-hooks/first-work-branch-rename.ts +++ b/src/main/agent-hooks/first-work-branch-rename.ts @@ -4,14 +4,14 @@ // owns the orchestration: gate on the signal, enforce the safety guardrails, // summarize the prompt via the configured agent, and rename. import type { GlobalSettings, Repo } from '../../shared/types' -import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../shared/worktree-id' +import { getRepoIdFromWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree-id' import { parseWorkspaceKey } from '../../shared/workspace-scope' import { parsePaneKey } from '../../shared/stable-pane-id' import { - humanizeBranchSlug, isAutoGeneratedCreatureBranchName, stripConfiguredBranchPrefix } from '../../shared/branch-name-from-work' +import { deriveWorkspaceDisplayName } from '../../shared/display-name-from-work' import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key' import { computeBranchName, getConfiguredBranchPrefix } from '../ipc/worktree-logic' import { gitExecFileAsync } from '../git/runner' @@ -28,7 +28,9 @@ import { resolveTextGenerationParams } from '../text-generation/commit-message-text-generation' import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment' +import type { AgentGenerationFailureOutput } from '../text-generation/agent-failure-output' import { resolveGenerationTarget } from './first-work-generation-target' +import { runFolderWorkspaceTitleAutoRename } from './first-work-workspace-title-rename' export type FirstWorkBranchRenameEvent = { paneKey: string @@ -58,8 +60,14 @@ export type FirstWorkBranchRenameDeps = { /** Align the on-disk folder with the new branch leaf (best-effort, local-only). */ renameWorktreeFolder?: (worktreeId: string, newLeaf: string) => Promise /** Record (or clear with null) a user-facing auto-rename generation failure - * so the sidebar can show a "rename failed" badge instead of silent retries. */ - setRenameError: (worktreeId: string, error: string | null) => void + * so the sidebar can show a "rename failed" badge instead of silent retries. + * `failureOutput` carries the bounded full CLI output for local on-demand + * display; omitted/null replaces any stale capture. */ + setRenameError: ( + worktreeId: string, + error: string | null, + failureOutput?: AgentGenerationFailureOutput | null + ) => void /** Authoritative tab→worktree resolution from the session's tab map. */ resolveWorktreeIdForTab: (tabId: string) => string | undefined /** Invalidate caches + notify the renderer so the new branch name surfaces. */ @@ -180,7 +188,10 @@ async function runAutoRename( } const repo = deps.getRepo(getRepoIdFromWorktreeId(worktreeId)) - const parsed = splitWorktreeId(worktreeId) + // Why: worktreePath is a Git subprocess cwd. Folder-workspace instance IDs + // carry a synthetic `::workspace:` suffix that is not a real directory, + // so resolve to the backing folder or Git spawns against a nonexistent cwd. + const parsed = splitWorktreeIdForFilesystem(worktreeId) if (!repo || !parsed) { return stop('unresolved repo or worktree id') } @@ -249,7 +260,7 @@ async function runAutoRename( // event retry rather than permanently leaving the creature name. // A user-canceled generation isn't a failure to surface, so skip the badge. if (!generated.canceled) { - deps.setRenameError(worktreeId, generated.error) + deps.setRenameError(worktreeId, generated.error, generated.failureOutput ?? null) } return retry(`generation failed: ${generated.error}`) } @@ -307,7 +318,7 @@ async function runAutoRename( // Keep the sidebar name in sync with the branch — but only when it is still // the auto-generated creature name, so a name the user typed is left alone. const currentDisplayName = deps.getCurrentDisplayName(worktreeId) - const newDisplayName = humanizeBranchSlug(newBranchLeaf) + const newDisplayName = deriveWorkspaceDisplayName({ prompt, slug, resolvedLeaf: newBranchLeaf }) const updateDisplay = !currentDisplayName || isAutoGeneratedCreatureBranchName(currentDisplayName) if (updateDisplay) { deps.setDisplayName(worktreeId, newDisplayName) @@ -344,56 +355,3 @@ async function runAutoRename( ) return true } - -async function runFolderWorkspaceTitleAutoRename( - worktreeId: string, - prompt: string, - assistantMessage: string | undefined, - deps: FirstWorkBranchRenameDeps, - stop: (reason: string, clearError?: boolean) => true, - retry: (reason: string) => false -): Promise { - if (deps.isPendingFirstAgentMessageRename?.(worktreeId) !== true) { - return stop('folder workspace is not pending title rename', true) - } - const folderPath = deps.getFolderWorkspacePath?.(worktreeId) - if (!folderPath) { - return stop('folder workspace path unavailable') - } - - const settings = deps.getSettings() - const resolvedParams = resolveTextGenerationParams(settings, 'local', 'branchName', null) - if (!resolvedParams.ok) { - deps.setRenameError(worktreeId, resolvedParams.error) - return stop(`no generation agent: ${resolvedParams.error}`) - } - const target = await resolveGenerationTarget( - folderPath, - resolvedParams.params.agentId, - null, - deps - ) - if (!target) { - deps.setRenameError(worktreeId, 'Could not prepare the workspace-name generation environment.') - return retry('could not prepare generation environment') - } - - const generated = await generateBranchNameFromContext( - { firstPrompt: prompt, assistantMessage }, - resolvedParams.params, - target - ) - if (!generated.success) { - if (!generated.canceled) { - deps.setRenameError(worktreeId, generated.error) - } - return retry(`generation failed: ${generated.error}`) - } - - const newDisplayName = humanizeBranchSlug(generated.slug) - deps.setDisplayName(worktreeId, newDisplayName) - deps.setRenameError(worktreeId, null) - deps.onRenamed(worktreeId) - console.info(`[auto-branch-rename] renamed folder workspace title -> "${newDisplayName}"`) - return true -} diff --git a/src/main/agent-hooks/first-work-folder-rename.test.ts b/src/main/agent-hooks/first-work-folder-rename.test.ts index f495f91b5e7..e9a88921943 100644 --- a/src/main/agent-hooks/first-work-folder-rename.test.ts +++ b/src/main/agent-hooks/first-work-folder-rename.test.ts @@ -8,6 +8,7 @@ import { const REPO = { id: 'repo1', path: '/repos/orca', connectionId: null } as unknown as Repo const SETTINGS = { nestWorkspaces: false, workspaceDir: '/ws' } as unknown as GlobalSettings const OLD_ID = 'repo1::/ws/cunner' +const FOLDER_WORKSPACE_ID = 'repo1::/ws/cunner::workspace:12345678-1234-1234-1234-123456789abc' function makeDeps(overrides: Partial = {}): FirstWorkFolderRenameDeps { return { @@ -50,6 +51,25 @@ describe('renameWorktreeFolderOnFirstWork', () => { ) }) + it('preserves the folder-workspace instance suffix in the migrated identity', async () => { + const deps = makeDeps() + const result = await renameWorktreeFolderOnFirstWork( + FOLDER_WORKSPACE_ID, + 'worktree-creation-spinner', + deps + ) + expect(result).toBe(true) + expect(deps.migrateWorktreeIdentity).toHaveBeenCalledWith( + FOLDER_WORKSPACE_ID, + 'repo1::/ws/worktree-creation-spinner::workspace:12345678-1234-1234-1234-123456789abc' + ) + expect(deps.notifyWorktreeRenamed).toHaveBeenCalledWith( + 'repo1', + FOLDER_WORKSPACE_ID, + 'repo1::/ws/worktree-creation-spinner::workspace:12345678-1234-1234-1234-123456789abc' + ) + }) + it('skips (no move) when the destination already exists', async () => { const deps = makeDeps({ pathExists: vi.fn(async () => true) }) expect(await renameWorktreeFolderOnFirstWork(OLD_ID, 'taken', deps)).toBe(false) diff --git a/src/main/agent-hooks/first-work-folder-rename.ts b/src/main/agent-hooks/first-work-folder-rename.ts index 8cc8f029b2e..c7050310834 100644 --- a/src/main/agent-hooks/first-work-folder-rename.ts +++ b/src/main/agent-hooks/first-work-folder-rename.ts @@ -6,7 +6,11 @@ // best-effort and local-only — remote/Windows/locked/dest-taken all degrade to // "folder kept" without disturbing the rename that already succeeded. import type { GlobalSettings, Repo } from '../../shared/types' -import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../shared/worktree-id' +import { + FOLDER_WORKSPACE_INSTANCE_SEPARATOR, + getRepoIdFromWorktreeId, + splitWorktreeIdForFilesystem +} from '../../shared/worktree-id' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { planWorktreeFolderRename } from '../ipc/worktree-folder-rename-target' @@ -35,7 +39,10 @@ export async function renameWorktreeFolderOnFirstWork( deps: FirstWorkFolderRenameDeps ): Promise { const repo = deps.getRepo(getRepoIdFromWorktreeId(worktreeId)) - const parsed = splitWorktreeId(worktreeId) + // Why: oldWorktreePath feeds an on-disk folder move. Resolve the synthetic + // `::workspace:` suffix to the backing folder; identity is migrated + // separately via the untouched worktreeId. + const parsed = splitWorktreeIdForFilesystem(worktreeId) if (!repo || !parsed) { return false } @@ -43,6 +50,9 @@ export async function renameWorktreeFolderOnFirstWork( repoId: repo.id, repoPath: repo.path, oldWorktreePath: parsed.worktreePath, + worktreeIdSuffix: worktreeId.includes(FOLDER_WORKSPACE_INSTANCE_SEPARATOR) + ? `${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}${worktreeId.split(FOLDER_WORKSPACE_INSTANCE_SEPARATOR).at(-1)}` + : undefined, newLeaf, settings: deps.getSettings(), platform: process.platform, diff --git a/src/main/agent-hooks/first-work-workspace-title-rename.ts b/src/main/agent-hooks/first-work-workspace-title-rename.ts new file mode 100644 index 00000000000..96bcdbde419 --- /dev/null +++ b/src/main/agent-hooks/first-work-workspace-title-rename.ts @@ -0,0 +1,65 @@ +import { deriveWorkspaceDisplayName } from '../../shared/display-name-from-work' +import { + generateBranchNameFromContext, + resolveTextGenerationParams +} from '../text-generation/commit-message-text-generation' +import { resolveGenerationTarget } from './first-work-generation-target' +import type { FirstWorkBranchRenameDeps } from './first-work-branch-rename' + +/** + * Non-git folder workspaces have no branch to rename; the first-work hook + * instead generates a workspace title. Shares the branch-name generation + * machinery and the caller's stop/retry verdict semantics. + */ +export async function runFolderWorkspaceTitleAutoRename( + worktreeId: string, + prompt: string, + assistantMessage: string | undefined, + deps: FirstWorkBranchRenameDeps, + stop: (reason: string, clearError?: boolean) => true, + retry: (reason: string) => false +): Promise { + if (deps.isPendingFirstAgentMessageRename?.(worktreeId) !== true) { + return stop('folder workspace is not pending title rename', true) + } + const folderPath = deps.getFolderWorkspacePath?.(worktreeId) + if (!folderPath) { + return stop('folder workspace path unavailable') + } + + const settings = deps.getSettings() + const resolvedParams = resolveTextGenerationParams(settings, 'local', 'branchName', null) + if (!resolvedParams.ok) { + deps.setRenameError(worktreeId, resolvedParams.error) + return stop(`no generation agent: ${resolvedParams.error}`) + } + const target = await resolveGenerationTarget( + folderPath, + resolvedParams.params.agentId, + null, + deps + ) + if (!target) { + deps.setRenameError(worktreeId, 'Could not prepare the workspace-name generation environment.') + return retry('could not prepare generation environment') + } + + const generated = await generateBranchNameFromContext( + { firstPrompt: prompt, assistantMessage }, + resolvedParams.params, + target + ) + if (!generated.success) { + if (!generated.canceled) { + deps.setRenameError(worktreeId, generated.error, generated.failureOutput ?? null) + } + return retry(`generation failed: ${generated.error}`) + } + + const newDisplayName = deriveWorkspaceDisplayName({ prompt, slug: generated.slug }) + deps.setDisplayName(worktreeId, newDisplayName) + deps.setRenameError(worktreeId, null) + deps.onRenamed(worktreeId) + console.info(`[auto-branch-rename] renamed folder workspace title -> "${newDisplayName}"`) + return true +} diff --git a/src/main/agent-hooks/hook-stdin-contract.ts b/src/main/agent-hooks/hook-stdin-contract.ts new file mode 100644 index 00000000000..a54a8e55f85 --- /dev/null +++ b/src/main/agent-hooks/hook-stdin-contract.ts @@ -0,0 +1,37 @@ +export type PosixHookEmptyPayloadPolicy = 'exit' | 'empty-object' + +export const POSIX_HOOK_STDIN_DRAIN_COMMAND = 'cat >/dev/null 2>&1 || :' + +// Why: every POSIX hook must own stdin before any no-op exit; sharing this +// prelude prevents agent templates from inventing different drain semantics. +export function buildPosixHookPayloadCapture( + emptyPayloadPolicy: PosixHookEmptyPayloadPolicy = 'exit' +): string[] { + const emptyPayloadLines = + emptyPayloadPolicy === 'empty-object' ? [" payload='{}'"] : [' exit 0'] + return ['payload=$(cat)', 'if [ -z "$payload" ]; then', ...emptyPayloadLines, 'fi'] +} + +export const WINDOWS_HOOK_STDIN_DRAIN_LABEL = 'orca_agent_hook_drain_stdin' +export const WINDOWS_HOOK_STDIN_DRAIN_COMMAND = '"%SystemRoot%\\System32\\more.com" >nul 2>nul' + +// Why: batch payloads stream directly to curl and cannot be buffered safely in +// environment variables, so guard failures share one EOF-draining epilogue. +export function buildWindowsHookEnvironmentGuardLines(): string[] { + const drainTarget = `goto :${WINDOWS_HOOK_STDIN_DRAIN_LABEL}` + return [ + `if "%ORCA_AGENT_HOOK_PORT%"=="" ${drainTarget}`, + `if "%ORCA_AGENT_HOOK_TOKEN%"=="" ${drainTarget}`, + `if "%ORCA_PANE_KEY%"=="" ${drainTarget}` + ] +} + +export function buildWindowsHookStdinDrainEpilogue(): string[] { + return [ + `:${WINDOWS_HOOK_STDIN_DRAIN_LABEL}`, + // Why: qualify the inbox reader because Windows searches the worktree for + // executables before PATH and hook payloads must not reach repo-local code. + WINDOWS_HOOK_STDIN_DRAIN_COMMAND, + 'exit /b 0' + ] +} diff --git a/src/main/agent-hooks/installer-utils.test.ts b/src/main/agent-hooks/installer-utils.test.ts index ba0dad4a0ab..66d4134f004 100644 --- a/src/main/agent-hooks/installer-utils.test.ts +++ b/src/main/agent-hooks/installer-utils.test.ts @@ -160,10 +160,8 @@ describe('createManagedCommandMatcher', () => { }) it('matches the guarded launcher form so wrapped commands sweep correctly', () => { - // Why: wrapPosixHookCommand wraps the launcher in `if [ -x ... ]; then ...; fi` - // so a stale entry no-ops instead of returning exit 127. The sweep on - // install() must still recognize the guarded form as managed, otherwise - // repeated installs would accumulate one guarded + one unguarded entry. + // Why: older guarded launchers used a single `-x` check. The sweep must + // still recognize them or reinstalling would retain a stale duplicate. expect( match( 'if [ -x "/Users/alice/Library/Application Support/Orca/agent-hooks/claude-hook.sh" ]; then /bin/sh "/Users/alice/Library/Application Support/Orca/agent-hooks/claude-hook.sh"; fi' @@ -176,6 +174,17 @@ describe('createManagedCommandMatcher', () => { expect(match(command)).toBe(true) }) + it('matches PowerShell and POSIX variants across Copilot platform switches', () => { + const matchPosix = createManagedCommandMatcher('copilot-hook.sh') + const matchPowerShell = createManagedCommandMatcher('copilot-hook.ps1') + + expect(matchPosix("& 'C:\\Users\\alice\\.orca\\agent-hooks\\copilot-hook.ps1'")).toBe(true) + expect( + matchPosix(wrapWindowsHookCommand('C:\\Users\\alice\\.orca\\agent-hooks\\copilot-hook.ps1')) + ).toBe(true) + expect(matchPowerShell("/bin/sh '/home/alice/.orca/agent-hooks/copilot-hook.sh'")).toBe(true) + }) + it('matches the legacy per-userData script path AND the new shared ~/.orca path', () => { // Why: install() must sweep old per-userData commands when migrating to // the shared ~/.orca script path, or stale launchers keep failing. @@ -285,13 +294,15 @@ describe('writeManagedScript', () => { describe('wrapPosixHookCommand', () => { it('produces a guarded command that no-ops when the script is missing', () => { const cmd = wrapPosixHookCommand('/does/not/exist.sh') - expect(cmd).toBe("if [ -x '/does/not/exist.sh' ]; then /bin/sh '/does/not/exist.sh'; fi") + expect(cmd).toBe( + "if [ -f '/does/not/exist.sh' ] && [ -r '/does/not/exist.sh' ] && [ -x '/does/not/exist.sh' ]; then /bin/sh '/does/not/exist.sh'; else cat >/dev/null 2>&1 || :; fi" + ) }) it('preserves spaces in the script path (Library/Application Support case)', () => { // Why: Electron's userData on macOS lives under "Application Support" with - // a space. The guard must keep the path quoted so `[ -x ]` and `/bin/sh` - // each see one argument. + // a space. The guard must keep the path quoted so each file test and + // `/bin/sh` see one argument. const cmd = wrapPosixHookCommand('/Users/a/Library/Application Support/Orca/agent-hooks/x.sh') expect(cmd).toContain("'/Users/a/Library/Application Support/Orca/agent-hooks/x.sh'") }) @@ -302,7 +313,7 @@ describe('wrapPosixHookCommand', () => { // /bin/sh as a single argument. const cmd = wrapPosixHookCommand("/path/with'quote/x.sh") expect(cmd).toBe( - "if [ -x '/path/with'\\''quote/x.sh' ]; then /bin/sh '/path/with'\\''quote/x.sh'; fi" + "if [ -f '/path/with'\\''quote/x.sh' ] && [ -r '/path/with'\\''quote/x.sh' ] && [ -x '/path/with'\\''quote/x.sh' ]; then /bin/sh '/path/with'\\''quote/x.sh'; else cat >/dev/null 2>&1 || :; fi" ) }) @@ -311,7 +322,7 @@ describe('wrapPosixHookCommand', () => { ORCA_COPILOT_HOOK_EVENT: 'UserPromptSubmit' }) expect(cmd).toBe( - "if [ -x '/does/not/exist.sh' ]; then ORCA_COPILOT_HOOK_EVENT='UserPromptSubmit' /bin/sh '/does/not/exist.sh'; fi" + "if [ -f '/does/not/exist.sh' ] && [ -r '/does/not/exist.sh' ] && [ -x '/does/not/exist.sh' ]; then ORCA_COPILOT_HOOK_EVENT='UserPromptSubmit' /bin/sh '/does/not/exist.sh'; else cat >/dev/null 2>&1 || :; fi" ) }) @@ -324,6 +335,35 @@ describe('wrapPosixHookCommand', () => { } ) + it.skipIf(process.platform === 'win32')( + 'drains stdin when a directory occupies the managed script path', + () => { + const scriptPath = join(tmpDir, 'directory-hook.sh') + mkdirSync(scriptPath) + const result = spawnSync('/bin/sh', ['-c', wrapPosixHookCommand(scriptPath)], { + input: Buffer.alloc(1_000_000, 'x') + }) + + expect(result.error).toBeUndefined() + expect(result.status).toBe(0) + } + ) + + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'drains stdin when the managed script is executable but unreadable', + () => { + const scriptPath = join(tmpDir, 'unreadable-hook.sh') + writeFileSync(scriptPath, '#!/bin/sh\nexit 0\n', 'utf-8') + chmodSync(scriptPath, 0o111) + const result = spawnSync('/bin/sh', ['-c', wrapPosixHookCommand(scriptPath)], { + input: Buffer.alloc(1_000_000, 'x') + }) + + expect(result.error).toBeUndefined() + expect(result.status).toBe(0) + } + ) + // Why: commit 4d618795 explicitly switched from `&& ... || true` (which // swallowed non-zero exits) to `if ... then ... fi` (which preserves the // script's exit code). This test guards against a future regression that @@ -350,13 +390,27 @@ function decodeWindowsHookCommand(command: string): string { return Buffer.from(encodedCommand!, 'base64').toString('utf16le') } +function expectedDecodedWindowsHookCommand(scriptPath: string): string { + const quoted = `'${scriptPath.replaceAll("'", "''")}'` + return `if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; exit 0` +} + describe('wrapWindowsHookCommand', () => { it('invokes the .cmd through an encoded PowerShell command', () => { const command = wrapWindowsHookCommand('C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd') expect(command).toMatch(qualifiedWindowsPowerShellCommand) expect(command).not.toMatch(/^powershell\b/i) expect(decodeWindowsHookCommand(command)).toBe( - "& 'C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd'; exit $LASTEXITCODE" + expectedDecodedWindowsHookCommand('C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd') + ) + }) + + it('scopes environment variables inside the encoded launcher', () => { + const command = wrapWindowsHookCommand('C:\\hooks\\copilot-hook.ps1', { + ORCA_COPILOT_HOOK_EVENT: 'UserPromptSubmit' + }) + expect(decodeWindowsHookCommand(command)).toContain( + "$env:ORCA_COPILOT_HOOK_EVENT = 'UserPromptSubmit'; if (Test-Path" ) }) @@ -367,7 +421,9 @@ describe('wrapWindowsHookCommand', () => { const cmd = wrapWindowsHookCommand('C:\\Users\\Jorge Silva\\.orca\\agent-hooks\\codex-hook.cmd') expect(cmd).toMatch(qualifiedWindowsPowerShellCommand) expect(decodeWindowsHookCommand(cmd)).toBe( - "& 'C:\\Users\\Jorge Silva\\.orca\\agent-hooks\\codex-hook.cmd'; exit $LASTEXITCODE" + expectedDecodedWindowsHookCommand( + 'C:\\Users\\Jorge Silva\\.orca\\agent-hooks\\codex-hook.cmd' + ) ) }) @@ -376,7 +432,7 @@ describe('wrapWindowsHookCommand', () => { expect(cmd).not.toContain('%ORCA_TEST%') expect(cmd).not.toContain('^') expect(decodeWindowsHookCommand(cmd)).toBe( - "& 'C:\\Users\\%ORCA_TEST%\\a^b\\codex-hook.cmd'; exit $LASTEXITCODE" + expectedDecodedWindowsHookCommand('C:\\Users\\%ORCA_TEST%\\a^b\\codex-hook.cmd') ) }) @@ -398,31 +454,52 @@ describe('wrapWindowsHookCommand', () => { }) describe('wrapWindowsCmdHookCommand', () => { - it('returns a bare .cmd path when cmd.exe can invoke it safely', () => { + it('keeps the safe-path launcher PowerShell-free and drains non-file paths', () => { const scriptPath = 'C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd' - expect(wrapWindowsCmdHookCommand(scriptPath)).toBe(scriptPath) + const command = wrapWindowsCmdHookCommand(scriptPath) + expect(command).toContain(`if exist "${scriptPath}\\."`) + expect(command).toContain(`if exist "${scriptPath}" (call "${scriptPath}")`) + expect(command).toContain('"%SystemRoot%\\System32\\more.com" >nul 2>nul') + expect(command).not.toMatch(/powershell/i) }) + it.skipIf(process.platform !== 'win32')( + 'drains stdin when a directory occupies the managed script path', + () => { + const scriptPath = 'directory-hook.cmd' + mkdirSync(join(tmpDir, scriptPath)) + const result = spawnSync('cmd.exe', ['/d', '/c', wrapWindowsCmdHookCommand(scriptPath)], { + cwd: tmpDir, + input: Buffer.alloc(1_000_000, 'x') + }) + + expect(result.error).toBeUndefined() + expect(result.status).toBe(0) + } + ) + it('falls back to the encoded launcher when cmd.exe would split or expand the path', () => { const scriptPath = 'C:\\Users\\Jane Doe\\%ORCA_TEST%\\codex-hook.cmd' const command = wrapWindowsCmdHookCommand(scriptPath) expect(command).toMatch(qualifiedWindowsPowerShellCommand) - expect(decodeWindowsHookCommand(command)).toBe(`& '${scriptPath}'; exit $LASTEXITCODE`) + expect(decodeWindowsHookCommand(command)).toBe(expectedDecodedWindowsHookCommand(scriptPath)) }) }) describe('wrapWindowsGitBashHookCommand', () => { - it('returns a forward-slash .cmd path when Git Bash can execute it safely', () => { + it('guards the forward-slash fast path and drains when missing', () => { expect( wrapWindowsGitBashHookCommand('C:\\Users\\alice\\.orca\\agent-hooks\\claude-hook.cmd') - ).toBe('C:/Users/alice/.orca/agent-hooks/claude-hook.cmd') + ).toBe( + "if [ -f 'C:/Users/alice/.orca/agent-hooks/claude-hook.cmd' ]; then 'C:/Users/alice/.orca/agent-hooks/claude-hook.cmd'; else cat >/dev/null 2>&1 || :; fi" + ) }) it('falls back to the encoded launcher when bash would split the path', () => { const scriptPath = 'C:\\Users\\Jane Doe\\.orca\\agent-hooks\\claude-hook.cmd' const command = wrapWindowsGitBashHookCommand(scriptPath) expect(command).toMatch(qualifiedWindowsPowerShellCommand) - expect(decodeWindowsHookCommand(command)).toBe(`& '${scriptPath}'; exit $LASTEXITCODE`) + expect(decodeWindowsHookCommand(command)).toBe(expectedDecodedWindowsHookCommand(scriptPath)) }) it('falls back to the encoded launcher when bash metacharacters are present', () => { @@ -430,7 +507,7 @@ describe('wrapWindowsGitBashHookCommand', () => { const command = wrapWindowsGitBashHookCommand(scriptPath) expect(command).toMatch(qualifiedWindowsPowerShellCommand) expect(command).not.toContain('& bob') - expect(decodeWindowsHookCommand(command)).toBe(`& '${scriptPath}'; exit $LASTEXITCODE`) + expect(decodeWindowsHookCommand(command)).toBe(expectedDecodedWindowsHookCommand(scriptPath)) }) }) diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index 86bb58607ca..6b9e93edad5 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -13,6 +13,10 @@ import { dirname, join } from 'node:path' import { randomUUID } from 'node:crypto' import type { AgentHookSource } from '../../shared/agent-hook-relay' import { grantDirAcl, isPermissionError } from '../win32-utils' +import { + POSIX_HOOK_STDIN_DRAIN_COMMAND, + WINDOWS_HOOK_STDIN_DRAIN_COMMAND +} from './hook-stdin-contract' export type HookCommandConfig = { type: 'command' @@ -86,12 +90,13 @@ export function readHooksJson(configPath: string): HooksConfig | null { export function createManagedCommandMatcher( scriptFileName: string ): (command: string | undefined) => boolean { - const scriptStem = scriptFileName.replace(/\.(?:cmd|sh)$/, '') - // Why: local Windows installs use .cmd, while SSH/POSIX installs and older - // entries use .sh. A platform switch should still sweep stale Orca hooks. + const scriptStem = scriptFileName.replace(/\.(?:cmd|ps1|sh)$/, '') + // Why: local Windows installs use .cmd or Copilot's .ps1, while SSH/POSIX + // installs use .sh. A platform switch must still sweep stale Orca hooks. const needles = [ `agent-hooks/${scriptFileName}`, `agent-hooks/${scriptStem}.cmd`, + `agent-hooks/${scriptStem}.ps1`, `agent-hooks/${scriptStem}.sh` ] return (command) => { @@ -123,24 +128,26 @@ export function getSharedManagedScriptPath(scriptFileName: string): string { return join(homedir(), '.orca', 'agent-hooks', scriptFileName) } +function quotePosixShellString(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'` +} + // Why: a stale managed hook entry (left over after the user wiped userData, // switched dev↔prod installs, or had a partial install fail) used to fire // `/bin/sh ""` on every tool call, which exits 127 and surfaces // as `PreToolUse hook (failed) error: hook exited with code 127` in the agent -// transcript. Wrapping the launcher in `if [ -x ... ]; then ...; fi` makes a -// missing/non-executable script a silent no-op so a broken install never -// poisons the user's session. Failures inside the script itself are -// unaffected — only the missing-script case short-circuits. +// transcript. Guarding for a regular readable executable file makes a broken +// install a silent no-op without hiding failures from a script that starts. export function wrapPosixHookCommand(scriptPath: string, env: Record = {}): string { // Why: POSIX single-quote escape so $, `, ", and \ in scriptPath are taken // literally — avoids a shell-injection footgun if a future caller passes an // arbitrary path. - const quoted = `'${scriptPath.replaceAll("'", "'\\''")}'` + const quoted = quotePosixShellString(scriptPath) const envPrefix = Object.entries(env) .map(([key, value]) => `${key}='${value.replaceAll("'", "'\\''")}'`) .join(' ') const invocation = envPrefix ? `${envPrefix} /bin/sh ${quoted}` : `/bin/sh ${quoted}` - return `if [ -x ${quoted} ]; then ${invocation}; fi` + return `if [ -f ${quoted} ] && [ -r ${quoted} ] && [ -x ${quoted} ]; then ${invocation}; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi` } function quotePowerShellString(value: string): string { @@ -154,11 +161,17 @@ function getWindowsPowerShellExecutablePath(): string { return `${systemRoot.replaceAll('\\', '/')}/System32/WindowsPowerShell/v1.0/powershell.exe` } -export function wrapWindowsHookCommand(scriptPath: string): string { - // Why: most Windows agents run hooks through Git Bash or another shell that - // mangles a raw backslash path. Codex has its own cmd.exe-safe fast path; the - // shared wrapper keeps the encoded launcher for every other agent. - const command = `& ${quotePowerShellString(scriptPath)}; exit $LASTEXITCODE` +export function wrapWindowsHookCommand( + scriptPath: string, + env: Record = {} +): string { + // Why: the encoded launcher protects paths across Windows hook shells and + // owns stdin when a stale config points at a missing managed script. + const quoted = quotePowerShellString(scriptPath) + const envPrefix = Object.entries(env) + .map(([key, value]) => `$env:${key} = ${quotePowerShellString(value)}; `) + .join('') + const command = `${envPrefix}if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; exit 0` const encodedCommand = Buffer.from(command, 'utf16le').toString('base64') return `${getWindowsPowerShellExecutablePath()} -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${encodedCommand}` } @@ -166,19 +179,23 @@ export function wrapWindowsHookCommand(scriptPath: string): string { export const WINDOWS_CMD_SAFE_PATH = /^[A-Za-z0-9_.:\\~-]+$/ export function wrapWindowsCmdHookCommand(scriptPath: string): string { - // Why: skip the ~360ms PowerShell interpreter startup when the path is safe - // for cmd.exe to invoke directly. The fallback keeps the encoded PowerShell - // launcher for paths with spaces or metacharacters. - return WINDOWS_CMD_SAFE_PATH.test(scriptPath) ? scriptPath : wrapWindowsHookCommand(scriptPath) + // Why: keep the safe-path fast path PowerShell-free while making a stale + // config entry own stdin; the `path\.` sentinel also rejects a directory + // that happens to occupy the managed .cmd path, including an empty one. + return WINDOWS_CMD_SAFE_PATH.test(scriptPath) + ? `if exist "${scriptPath}\\." (${WINDOWS_HOOK_STDIN_DRAIN_COMMAND}) else if exist "${scriptPath}" (call "${scriptPath}") else (${WINDOWS_HOOK_STDIN_DRAIN_COMMAND})` + : wrapWindowsHookCommand(scriptPath) } export const WINDOWS_GIT_BASH_SAFE_PATH = /^[A-Za-z0-9_.:/~-]+$/ export function wrapWindowsGitBashHookCommand(scriptPath: string): string { const bashPath = scriptPath.replaceAll('\\', '/') - // Why: Claude Code's Windows hook runner is Git Bash/MSYS. It can execute a - // bare .cmd via forward slashes, but shell metacharacters must stay encoded. - return WINDOWS_GIT_BASH_SAFE_PATH.test(bashPath) ? bashPath : wrapWindowsHookCommand(scriptPath) + // Why: Claude's Git Bash runner can execute a forward-slash .cmd directly; + // unsafe paths stay encoded and the fast path gains a missing-file drain. + return WINDOWS_GIT_BASH_SAFE_PATH.test(bashPath) + ? `if [ -f ${quotePosixShellString(bashPath)} ]; then ${quotePosixShellString(bashPath)}; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi` + : wrapWindowsHookCommand(scriptPath) } export function buildWindowsAgentHookPostCommand(source: AgentHookSource): string { @@ -300,7 +317,7 @@ export function writeManagedScript(scriptPath: string, content: string): void { try { writeScriptWithAclRetry(tmpPath, content) // Why: chmod before rename so the canonical path is never visible in a - // non-executable state; wrapPosixHookCommand's `[ -x ]` guard would + // unreadable/non-executable state; wrapPosixHookCommand's guards would // silently skip the hook in that window. if (process.platform !== 'win32') { chmodSync(tmpPath, 0o755) diff --git a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts new file mode 100644 index 00000000000..6a627227f7a --- /dev/null +++ b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts @@ -0,0 +1,366 @@ +// Why: stdin ownership is a cross-agent process contract; one executable +// matrix catches an unread early exit without duplicating template assertions. +import { describe, expect, it, vi } from 'vitest' +import { spawn } from 'node:child_process' +import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { SFTPWrapper } from 'ssh2' +import type * as osModule from 'node:os' + +const { homedirMock } = vi.hoisted(() => ({ + homedirMock: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ + app: { + getPath: () => '/tmp/orca-user-data' + } +})) + +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + homedir: homedirMock.mockImplementation(actual.homedir) + } +}) + +import { AntigravityHookService } from '../antigravity/hook-service' +import { ClaudeHookService } from '../claude/hook-service' +import { CodexHookService } from '../codex/hook-service' +import { CommandCodeHookService } from '../command-code/hook-service' +import { CopilotHookService } from '../copilot/hook-service' +import { CursorHookService } from '../cursor/hook-service' +import { DevinHookService } from '../devin/hook-service' +import { DroidHookService } from '../droid/hook-service' +import { GeminiHookService } from '../gemini/hook-service' +import { GrokHookService } from '../grok/hook-service' +import { KimiHookService } from '../kimi/hook-service' +import { openClaudeHookService } from '../openclaude/hook-service' +import { + wrapPosixHookCommand, + wrapWindowsCmdHookCommand, + wrapWindowsGitBashHookCommand, + wrapWindowsHookCommand +} from './installer-utils' +import { createAgentHookMemorySftp } from './agent-hook-memory-sftp.test-fixture' + +const REMOTE_HOME = '/home/dev' +const LARGE_PAYLOAD = Buffer.alloc(1_000_000, 'x') +const REMOTE_INSTALLERS = [ + { + agent: 'antigravity', + install: (sftp: SFTPWrapper) => new AntigravityHookService().installRemote(sftp, REMOTE_HOME) + }, + { + agent: 'claude', + install: (sftp: SFTPWrapper) => new ClaudeHookService().installRemote(sftp, REMOTE_HOME) + }, + { + agent: 'openclaude', + install: (sftp: SFTPWrapper) => openClaudeHookService.installRemote(sftp, REMOTE_HOME) + }, + { + agent: 'codex', + install: (sftp: SFTPWrapper) => new CodexHookService().installRemote(sftp, REMOTE_HOME) + }, + { + agent: 'command-code', + install: (sftp: SFTPWrapper) => new CommandCodeHookService().installRemote(sftp, REMOTE_HOME) + }, + { + agent: 'copilot', + install: (sftp: SFTPWrapper) => new CopilotHookService().installRemote(sftp, REMOTE_HOME) + }, + { + agent: 'cursor', + install: (sftp: SFTPWrapper) => new CursorHookService().installRemote(sftp, REMOTE_HOME) + }, + { + agent: 'devin', + install: (sftp: SFTPWrapper) => new DevinHookService().installRemote(sftp, REMOTE_HOME) + }, + { + agent: 'droid', + install: (sftp: SFTPWrapper) => new DroidHookService().installRemote(sftp, REMOTE_HOME) + }, + { + agent: 'gemini', + install: (sftp: SFTPWrapper) => new GeminiHookService().installRemote(sftp, REMOTE_HOME) + }, + { + agent: 'grok', + install: (sftp: SFTPWrapper) => new GrokHookService().installRemote(sftp, REMOTE_HOME) + }, + { + agent: 'kimi', + install: (sftp: SFTPWrapper) => new KimiHookService().installRemote(sftp, REMOTE_HOME) + } +] as const + +const LOCAL_INSTALLERS = [ + { agent: 'antigravity', install: () => new AntigravityHookService().install() }, + { agent: 'claude', install: () => new ClaudeHookService().install() }, + { agent: 'openclaude', install: () => openClaudeHookService.install() }, + { agent: 'codex', install: () => new CodexHookService().install() }, + { agent: 'command-code', install: () => new CommandCodeHookService().install() }, + { agent: 'copilot', install: () => new CopilotHookService().install() }, + { agent: 'cursor', install: () => new CursorHookService().install() }, + { agent: 'devin', install: () => new DevinHookService().install() }, + { agent: 'droid', install: () => new DroidHookService().install() }, + { agent: 'gemini', install: () => new GeminiHookService().install() }, + { agent: 'grok', install: () => new GrokHookService().install() }, + { agent: 'kimi', install: () => new KimiHookService().install() } +] as const + +type HookRun = { + exitCode: number | null + stdinErrors: NodeJS.ErrnoException[] +} + +function runHookProcess( + executable: string, + args: string[], + env: NodeJS.ProcessEnv +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(executable, args, { env, stdio: ['pipe', 'ignore', 'ignore'] }) + const stdinErrors: NodeJS.ErrnoException[] = [] + const timeout = setTimeout(() => { + child.kill('SIGKILL') + reject(new Error('hook did not finish after stdin closed')) + }, 10_000) + child.on('error', (error) => { + clearTimeout(timeout) + reject(error) + }) + child.stdin.on('error', (error: NodeJS.ErrnoException) => stdinErrors.push(error)) + child.on('close', (exitCode) => { + clearTimeout(timeout) + resolve({ exitCode, stdinErrors }) + }) + child.stdin.end(LARGE_PAYLOAD) + }) +} + +function hookEnvironment(extraEnv: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { + const env = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith('ORCA_')) + ) + return { + ...env, + HOME: REMOTE_HOME, + ORCA_AGENT_HOOK_ENDPOINT: '', + ...extraEnv + } +} + +function runPosixHook(command: string, extraEnv: NodeJS.ProcessEnv = {}): Promise { + return runHookProcess('/bin/sh', ['-c', command], hookEnvironment(extraEnv)) +} + +async function generatePosixScripts(): Promise> { + const scripts = new Map() + for (const entry of REMOTE_INSTALLERS) { + const memory = createAgentHookMemorySftp() + const status = await entry.install(memory.sftp) + expect(status.state, `${entry.agent} install status`).toBe('installed') + const generated = [...memory.fs.files.entries()].filter( + ([path]) => path.includes('/.orca/agent-hooks/') && path.endsWith('.sh') + ) + expect(generated, `${entry.agent} generated scripts`).toHaveLength(1) + scripts.set(entry.agent, generated[0][1]) + } + return scripts +} + +function withPlatform(platform: NodeJS.Platform, run: () => T): T { + const original = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + try { + return run() + } finally { + if (original) { + Object.defineProperty(process, 'platform', original) + } + } +} + +describe('Windows managed hook stdin structure', () => { + it('routes every batch guard to a shared drain epilogue', () => { + const home = mkdtempSync(join(tmpdir(), 'orca-hook-stdin-windows-')) + homedirMock.mockReturnValue(home) + const previousGrokHome = process.env.GROK_HOME + const previousKimiHome = process.env.KIMI_CODE_HOME + delete process.env.GROK_HOME + delete process.env.KIMI_CODE_HOME + try { + withPlatform('win32', () => { + for (const entry of LOCAL_INSTALLERS) { + expect(entry.install().state, `${entry.agent} install status`).toBe('installed') + } + }) + const hooksDir = join(home, '.orca', 'agent-hooks') + const fileNames = readdirSync(hooksDir) + const mainBatchScripts = fileNames.filter( + (name) => name.endsWith('-hook.cmd') && !name.startsWith('antigravity-') + ) + mainBatchScripts.push('antigravity-hook.cmd') + expect(mainBatchScripts).toHaveLength(10) + for (const fileName of mainBatchScripts) { + const script = readFileSync(join(hooksDir, fileName), 'utf8') + expect(script, `${fileName} port guard`).toContain( + 'if "%ORCA_AGENT_HOOK_PORT%"=="" goto :orca_agent_hook_drain_stdin' + ) + expect(script, `${fileName} token guard`).toContain( + 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" goto :orca_agent_hook_drain_stdin' + ) + expect(script, `${fileName} pane guard`).toContain( + 'if "%ORCA_PANE_KEY%"=="" goto :orca_agent_hook_drain_stdin' + ) + expect(script, `${fileName} drain epilogue`).toContain( + [ + ':orca_agent_hook_drain_stdin', + '"%SystemRoot%\\System32\\more.com" >nul 2>nul', + 'exit /b 0' + ].join('\r\n') + ) + } + + const copilot = readFileSync(join(hooksDir, 'copilot-hook.ps1'), 'utf8') + expect(copilot.indexOf('[Console]::In.ReadToEnd()')).toBeLessThan( + copilot.indexOf('if (-not $env:ORCA_AGENT_HOOK_PORT') + ) + const kimi = readFileSync(join(hooksDir, 'kimi-hook.sh'), 'utf8') + expect(kimi.indexOf('payload=$(cat)')).toBeLessThan(kimi.indexOf('exit 0')) + } finally { + homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir()) + if (previousGrokHome === undefined) { + delete process.env.GROK_HOME + } else { + process.env.GROK_HOME = previousGrokHome + } + if (previousKimiHome === undefined) { + delete process.env.KIMI_CODE_HOME + } else { + process.env.KIMI_CODE_HOME = previousKimiHome + } + rmSync(home, { recursive: true, force: true }) + } + }) + + it.skipIf(process.platform !== 'win32')( + 'executes every local script and missing-script launcher without a broken writer', + async () => { + const home = mkdtempSync(join(tmpdir(), 'orca-hook-stdin-windows-live-')) + homedirMock.mockReturnValue(home) + try { + for (const entry of LOCAL_INSTALLERS) { + expect(entry.install().state, `${entry.agent} install status`).toBe('installed') + } + const hooksDir = join(home, '.orca', 'agent-hooks') + const mainScripts = readdirSync(hooksDir).filter( + (name) => + name === 'antigravity-hook.cmd' || + name.endsWith('-hook.ps1') || + name.endsWith('-hook.sh') || + (name.endsWith('-hook.cmd') && !name.startsWith('antigravity-')) + ) + expect(mainScripts).toHaveLength(12) + for (const fileName of mainScripts) { + const scriptPath = join(hooksDir, fileName) + const executable = fileName.endsWith('.cmd') + ? 'cmd.exe' + : fileName.endsWith('.ps1') + ? join( + process.env.SystemRoot ?? 'C:\\Windows', + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe' + ) + : process.env.KIMI_SHELL_PATH || 'bash.exe' + const args = fileName.endsWith('.cmd') + ? ['/d', '/c', scriptPath] + : fileName.endsWith('.ps1') + ? ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath] + : [scriptPath] + const result = await runHookProcess(executable, args, hookEnvironment()) + expect(result.exitCode, `${fileName} exit code`).toBe(0) + expect(result.stdinErrors, `${fileName} stdin errors`).toHaveLength(0) + } + + const missingScript = 'C:\\missing\\orca-hook.cmd' + const launcherCases = [ + { + name: 'encoded PowerShell', + executable: 'cmd.exe', + args: ['/d', '/c', wrapWindowsHookCommand(missingScript)] + }, + { + name: 'cmd fast path', + executable: 'cmd.exe', + args: ['/d', '/c', wrapWindowsCmdHookCommand(missingScript)] + }, + { + name: 'Git Bash fast path', + executable: process.env.KIMI_SHELL_PATH || 'bash.exe', + args: ['-lc', wrapWindowsGitBashHookCommand(missingScript)] + } + ] + for (const launcher of launcherCases) { + const result = await runHookProcess(launcher.executable, launcher.args, hookEnvironment()) + expect(result.exitCode, `${launcher.name} exit code`).toBe(0) + expect(result.stdinErrors, `${launcher.name} stdin errors`).toHaveLength(0) + } + } finally { + homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir()) + rmSync(home, { recursive: true, force: true }) + } + } + ) +}) + +describe.skipIf(process.platform === 'win32')('managed hook stdin lifecycle', () => { + it('captures stdin before every possible whole-script success exit', async () => { + const scripts = await generatePosixScripts() + for (const [agent, script] of scripts) { + const captureIndex = script.indexOf('payload=$(cat)') + const firstExitIndex = script.indexOf('exit 0') + expect(captureIndex, `${agent} payload capture`).toBeGreaterThanOrEqual(0) + expect(firstExitIndex, `${agent} first success exit`).toBeGreaterThan(captureIndex) + } + }) + + it('accepts a large payload without Orca environment or a broken writer', async () => { + const scripts = await generatePosixScripts() + for (const [agent, script] of scripts) { + const extraEnv = + agent === 'command-code' + ? { + ORCA_AGENT_HOOK_PORT: '1', + ORCA_AGENT_HOOK_TOKEN: 'test-token', + ORCA_PANE_KEY: 'test-pane' + } + : {} + const result = await runPosixHook(script, extraEnv) + expect(result.exitCode, `${agent} exit code`).toBe(0) + expect(result.stdinErrors, `${agent} stdin errors`).toHaveLength(0) + } + }) + + it('drains before Claude skips hooks imported by Devin', async () => { + const script = (await generatePosixScripts()).get('claude') + expect(script).toBeDefined() + const result = await runPosixHook(script!, { DEVIN_PROJECT_DIR: '/tmp/devin-project' }) + expect(result.exitCode).toBe(0) + expect(result.stdinErrors).toHaveLength(0) + }) + + it('drains a large payload when the configured script is missing', async () => { + const result = await runPosixHook(wrapPosixHookCommand('/missing/orca-hook.sh')) + expect(result.exitCode).toBe(0) + expect(result.stdinErrors).toHaveLength(0) + }) +}) diff --git a/src/main/agent-hooks/managed-hook-timeout.test.ts b/src/main/agent-hooks/managed-hook-timeout.test.ts index dab3302a89b..a215210a577 100644 --- a/src/main/agent-hooks/managed-hook-timeout.test.ts +++ b/src/main/agent-hooks/managed-hook-timeout.test.ts @@ -41,93 +41,7 @@ import { DevinHookService } from '../devin/hook-service' import { DroidHookService } from '../droid/hook-service' import { KimiHookService } from '../kimi/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' - -type FakeFs = { - files: Map - dirs: Set - modes: Map -} - -function createFakeSftp(initialFiles: Record = {}): { - sftp: SFTPWrapper - fs: FakeFs -} { - const fs: FakeFs = { - files: new Map(Object.entries(initialFiles)), - dirs: new Set(['/']), - modes: new Map() - } - const noEntryError = (path: string): { code: number; message: string } => ({ - code: 2, - message: `ENOENT ${path}` - }) - - const sftp = { - readFile: (path: string, _enc: string, cb: (err: unknown, data?: string) => void): void => { - const v = fs.files.get(path) - if (v === undefined) { - cb(noEntryError(path)) - return - } - cb(null, v) - }, - writeFile: ( - path: string, - content: string, - options: string | { mode?: number }, - cb: (err: unknown) => void - ): void => { - fs.files.set(path, content) - if (typeof options !== 'string' && options.mode !== undefined) { - fs.modes.set(path, options.mode) - } - cb(null) - }, - rename: (src: string, dst: string, cb: (err: unknown) => void): void => { - const v = fs.files.get(src) - if (v === undefined) { - cb(noEntryError(src)) - return - } - fs.files.set(dst, v) - fs.files.delete(src) - const mode = fs.modes.get(src) - if (mode !== undefined) { - fs.modes.set(dst, mode) - fs.modes.delete(src) - } - cb(null) - }, - unlink: (path: string, cb: (err: unknown) => void): void => { - fs.files.delete(path) - fs.modes.delete(path) - cb(null) - }, - chmod: (path: string, mode: number, cb: (err: unknown) => void): void => { - fs.modes.set(path, mode) - cb(null) - }, - stat: (path: string, cb: (err: unknown, stats?: { mode: number }) => void): void => { - if (!fs.files.has(path)) { - cb(noEntryError(path)) - return - } - cb(null, { mode: fs.modes.get(path) ?? 0o100644 }) - }, - readdir: (path: string, cb: (err: unknown, list?: { filename: string }[]) => void): void => { - if (fs.dirs.has(path)) { - cb(null, []) - return - } - cb(noEntryError(path)) - }, - mkdir: (path: string, cb: (err: unknown) => void): void => { - fs.dirs.add(path) - cb(null) - } - } as unknown as SFTPWrapper - return { sftp, fs } -} +import { createAgentHookMemorySftp as createFakeSftp } from './agent-hook-memory-sftp.test-fixture' const REMOTE_HOME = '/home/dev' diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index 90726a2d7a7..8489048d0f5 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -244,7 +244,7 @@ describe('remote hook service installers', () => { ]) { const command = hooks.hooks[eventName]?.[0]?.hooks?.[0]?.command expect(command).toContain('/home/dev/.orca/agent-hooks/codex-hook.sh') - expect(command).toMatch(/^if \[ -x /) + expect(command).toMatch(/^if \[ -f /) } expect(fs.files.get('/home/dev/.orca/agent-hooks/codex-hook.sh')).toContain('#!/bin/sh') expect(fs.modes.get('/home/dev/.orca/agent-hooks/codex-hook.sh')).toBe(0o755) @@ -338,7 +338,7 @@ describe('remote hook service installers', () => { for (const eventName of ['BeforeAgent', 'AfterAgent', 'AfterTool', 'BeforeTool']) { const command = geminiConfig.hooks[eventName]?.[0]?.hooks?.[0]?.command expect(command).toContain('/home/dev/.orca/agent-hooks/gemini-hook.sh') - expect(command).toMatch(/^if \[ -x /) + expect(command).toMatch(/^if \[ -f /) } expect(geminiConfig.hooks.PreToolUse).toBeUndefined() @@ -398,7 +398,7 @@ describe('remote hook service installers', () => { const definition = commandCodeConfig.hooks[eventName]?.[0] const command = definition?.hooks?.[0]?.command expect(command).toContain('/home/dev/.orca/agent-hooks/command-code-hook.sh') - expect(command).toMatch(/^if \[ -x /) + expect(command).toMatch(/^if \[ -f /) } expect(commandCodeConfig.hooks.PreToolUse?.[0]?.matcher).toBe('.*') expect(commandCodeConfig.hooks.PostToolUse?.[0]?.matcher).toBe('.*') @@ -421,7 +421,7 @@ describe('remote hook service installers', () => { const definition = grokConfig.hooks[eventName]?.[0] const command = definition?.hooks?.[0]?.command expect(command).toContain('/home/dev/.orca/agent-hooks/grok-hook.sh') - expect(command).toMatch(/^if \[ -x /) + expect(command).toMatch(/^if \[ -f /) } // Why: Grok tool matchers are real regexes; bare `*` is invalid match-all. expect(grokConfig.hooks.PreToolUse?.[0]?.matcher).toBe('.*') @@ -443,14 +443,14 @@ describe('remote hook service installers', () => { const definition = devinConfig.hooks[eventName]?.[0] const command = definition?.hooks?.[0]?.command expect(command).toContain('/home/dev/.orca/agent-hooks/devin-hook.sh') - expect(command).toMatch(/^if \[ -x /) + expect(command).toMatch(/^if \[ -f /) } for (const eventName of ['PreToolUse', 'PostToolUse', 'PermissionRequest']) { const definition = devinConfig.hooks[eventName]?.[0] const command = definition?.hooks?.[0]?.command expect(definition?.matcher).toBeUndefined() expect(command).toContain('/home/dev/.orca/agent-hooks/devin-hook.sh') - expect(command).toMatch(/^if \[ -x /) + expect(command).toMatch(/^if \[ -f /) } expect(devin.fs.files.get('/home/dev/.orca/agent-hooks/devin-hook.sh')).toContain('/hook/devin') }) @@ -506,9 +506,9 @@ describe('remote hook service installers', () => { ]) { expect(config).toContain(`event = "${eventName}"`) } - // The command points at the POSIX managed script via the `[ -x ]` guard. + // The command points at the POSIX managed script via the regular-file guard. expect(config).toContain('/home/dev/.orca/agent-hooks/kimi-hook.sh') - expect(config).toMatch(/command = "if \[ -x /) + expect(config).toMatch(/command = "if \[ -f /) expect(fs.files.get('/home/dev/.orca/agent-hooks/kimi-hook.sh')).toContain('/hook/kimi') }) @@ -749,7 +749,7 @@ describe('remote hook service installers', () => { const definition = config.hooks[eventName]?.[0] const command = definition?.hooks?.[0]?.command expect(command).toContain('/home/dev/.orca/agent-hooks/droid-hook.sh') - expect(command).toMatch(/^if \[ -x /) + expect(command).toMatch(/^if \[ -f /) } // Tool/permission events carry a `*` matcher; lifecycle events do not. expect(config.hooks.PreToolUse?.[0]?.matcher).toBe('*') diff --git a/src/main/agent-hooks/server-pane-authority.test.ts b/src/main/agent-hooks/server-pane-authority.test.ts new file mode 100644 index 00000000000..7d622b5a8c9 --- /dev/null +++ b/src/main/agent-hooks/server-pane-authority.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../shared/stable-pane-id' +import { AgentHookServer, PANE_KEY_ALIASES_MAX } from './server' + +const SOURCE = makePaneKey('tab-source', '11111111-1111-4111-8111-111111111111') +const TARGET = makePaneKey('tab-target', '22222222-2222-4222-8222-222222222222') +const FINAL = makePaneKey('tab-final', '33333333-3333-4333-8333-333333333333') +const SIBLING = makePaneKey('tab-target', '44444444-4444-4444-8444-444444444444') + +describe('AgentHookServer pane authority', () => { + it('keeps physical hooks routed after the source tab closes and suppresses them after owner retire', () => { + const server = new AgentHookServer() + server.ingestTerminalStatus({ + paneKey: SOURCE, + tabId: 'tab-source', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'source' } + }) + + server.transferPaneAuthority(SOURCE, TARGET, 'pty-1') + server.dropStatusEntriesByTabPrefix('tab-source') + server.ingestTerminalStatus({ + paneKey: SOURCE, + tabId: 'tab-source', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'after source close' } + }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: TARGET, + tabId: 'tab-target', + prompt: 'after source close' + }) + ]) + + server.ingestTerminalStatus({ + paneKey: SIBLING, + tabId: 'tab-target', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'sibling' } + }) + server.retirePaneAuthority(TARGET) + server.ingestTerminalStatus({ + paneKey: SOURCE, + tabId: 'tab-source', + worktreeId: 'wt-1', + payload: { state: 'done', prompt: 'too late' } + }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: SIBLING, prompt: 'sibling' }) + ]) + }) + + it('persists one physical alias while chained transfers advance its owner', () => { + const server = new AgentHookServer() + const listener = vi.fn() + server.setPaneKeyAliasPersistenceListener(listener) + + server.transferPaneAuthority(SOURCE, TARGET, 'pty-1', 10) + server.transferPaneAuthority(TARGET, FINAL, 'pty-1', 20) + + expect(listener).toHaveBeenLastCalledWith([ + { + legacyPaneKey: SOURCE, + stablePaneKey: FINAL, + ptyId: 'pty-1', + updatedAt: 20 + } + ]) + }) + + it('requires live PTY ownership for a first transfer and trusts the chained alias afterward', () => { + const server = new AgentHookServer() + + server.ingestTerminalStatus({ + paneKey: SOURCE, + tabId: 'tab-source', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'unverified source' } + }) + + expect(server.canTransferPaneAuthority(SOURCE, undefined, () => false)).toBe(false) + expect(server.canTransferPaneAuthority(SOURCE, 'pty-1', () => false)).toBe(false) + expect( + server.canTransferPaneAuthority(SOURCE, 'pty-1', (paneKey, ptyId) => { + return paneKey === SOURCE && ptyId === 'pty-1' + }) + ).toBe(true) + + server.transferPaneAuthority(SOURCE, TARGET, 'pty-1') + expect(server.canTransferPaneAuthority(TARGET, undefined, () => false)).toBe(true) + expect(server.canTransferPaneAuthority(TARGET, 'pty-1', () => false)).toBe(true) + expect(server.canTransferPaneAuthority(TARGET, 'other-pty', () => false)).toBe(false) + }) + + it('does not treat registered or restored aliases as verified authority', () => { + const registered = new AgentHookServer() + registered.registerPaneKeyAlias('tab-source:0', SOURCE, 'pty-1') + + expect(registered.canTransferPaneAuthority(SOURCE, undefined, () => false)).toBe(false) + expect(registered.canTransferPaneAuthority(SOURCE, 'pty-1', () => false)).toBe(false) + expect( + registered.canTransferPaneAuthority(SOURCE, 'pty-1', (paneKey, ptyId) => { + return paneKey === 'tab-source:0' && ptyId === 'pty-1' + }) + ).toBe(true) + + const restored = new AgentHookServer() + restored.transferPaneAuthority(SOURCE, TARGET, 'pty-1', 10, { authorityVerified: false }) + expect(restored.canTransferPaneAuthority(TARGET, undefined, () => false)).toBe(false) + expect(restored.canTransferPaneAuthority(TARGET, 'pty-1', () => false)).toBe(false) + expect( + restored.canTransferPaneAuthority(TARGET, 'pty-1', (paneKey, ptyId) => { + return paneKey === TARGET && ptyId === 'pty-1' + }) + ).toBe(true) + restored.transferPaneAuthority(TARGET, FINAL, 'pty-1', 20) + expect(restored.canTransferPaneAuthority(FINAL, undefined, () => false)).toBe(true) + }) + + it('prefers a spawn-verified legacy alias over an earlier migration fallback', () => { + const server = new AgentHookServer() + server.registerPaneKeyAlias('tab-source:0', SOURCE, 'pty-1') + server.registerPaneKeyAlias('tab-source:1', SOURCE, 'pty-1', 20, { + authorityVerified: true + }) + + expect(server.canTransferPaneAuthority(SOURCE, undefined, () => false)).toBe(true) + expect(server.canTransferPaneAuthority(SOURCE, 'pty-1', () => false)).toBe(true) + }) + + it('bounds persisted aliases by evicting the oldest authority', () => { + const server = new AgentHookServer() + const listener = vi.fn() + server.setPaneKeyAliasPersistenceListener(listener) + + for (let index = 0; index <= PANE_KEY_ALIASES_MAX; index += 1) { + const suffix = index.toString(16).padStart(12, '0') + server.transferPaneAuthority( + makePaneKey(`source-${index}`, `00000000-0000-4000-8000-${suffix}`), + makePaneKey(`target-${index}`, `10000000-0000-4000-8000-${suffix}`), + `pty-${index}`, + index + 1 + ) + } + + const persisted = listener.mock.calls.at(-1)?.[0] + expect(persisted).toHaveLength(PANE_KEY_ALIASES_MAX) + expect(persisted).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ legacyPaneKey: expect.stringContaining('source-0:') }) + ]) + ) + }) +}) diff --git a/src/main/agent-hooks/server.claude-interactive-question.test.ts b/src/main/agent-hooks/server.claude-interactive-question.test.ts new file mode 100644 index 00000000000..a2c31a8f18b --- /dev/null +++ b/src/main/agent-hooks/server.claude-interactive-question.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' +import { makePaneKey } from '../../shared/stable-pane-id' +import { AgentHookServer } from './server' + +const PANE_KEY = makePaneKey('tab-question', '11111111-1111-4111-8111-111111111111') + +function ingestClaudeStatus( + server: AgentHookServer, + event: { + state: 'working' | 'waiting' + hookEventName: 'PermissionRequest' | 'PreToolUse' + toolName: string + toolUseId: string + } +): void { + server.ingestRemote( + { + paneKey: PANE_KEY, + tabId: 'tab-question', + worktreeId: 'worktree-question', + hookEventName: event.hookEventName, + toolUseId: event.toolUseId, + payload: { + state: event.state, + agentType: 'claude', + toolName: event.toolName + } + }, + 'connection-1' + ) +} + +describe('Claude interactive-question status transitions', () => { + it('clears an AskUserQuestion wait when later tool work starts', () => { + const server = new AgentHookServer() + + ingestClaudeStatus(server, { + state: 'waiting', + hookEventName: 'PreToolUse', + toolName: 'AskUserQuestion', + toolUseId: 'tool-question' + }) + ingestClaudeStatus(server, { + state: 'working', + hookEventName: 'PreToolUse', + toolName: 'Read', + toolUseId: 'tool-after-answer' + }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE_KEY, + state: 'working', + agentType: 'claude', + toolName: 'Read' + }) + ]) + }) + + it('keeps an actual permission request sticky during unrelated tool work', () => { + const server = new AgentHookServer() + + ingestClaudeStatus(server, { + state: 'waiting', + hookEventName: 'PermissionRequest', + toolName: 'Bash', + toolUseId: 'tool-needs-permission' + }) + ingestClaudeStatus(server, { + state: 'working', + hookEventName: 'PreToolUse', + toolName: 'Read', + toolUseId: 'tool-unrelated' + }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE_KEY, + state: 'waiting', + agentType: 'claude', + toolName: 'Bash' + }) + ]) + }) +}) diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 9fbb358dfae..7423be836dd 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -3330,6 +3330,30 @@ describe('AgentHookServer prompt-sent telemetry', () => { expect(trackMock).toHaveBeenCalledTimes(2) }) + it('includes Command Code prompt interaction keys in the IPC snapshot', () => { + const server = new AgentHookServer() + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + promptInteractionKey: 'command-code-transcript-user-1', + payload: { state: 'working', prompt: 'rerun', agentType: 'command-code' } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ + paneKey: PANE, + promptInteractionKey: 'command-code-transcript-user-1', + state: 'working', + prompt: 'rerun', + agentType: 'command-code' + }) + }) + it('dedupes Command Code direct prompt hooks followed by transcript-backed stop hooks', () => { const server = new AgentHookServer() @@ -3767,8 +3791,17 @@ describe('Claude hook normalization', () => { expect(result?.payload.toolInput).toBeUndefined() }) - it('PostToolUseFailure surfaces the error text as lastAssistantMessage', () => { - const result = _internals.normalizeHookPayload( + it('PostToolUseFailure clears stale tool fields until the next PreToolUse', () => { + _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'PreToolUse', + tool_name: 'Edit', + tool_input: { file_path: '/src/config.ts' } + }), + 'production' + ) + const failed = _internals.normalizeHookPayload( 'claude', buildBody({ hook_event_name: 'PostToolUseFailure', @@ -3778,9 +3811,27 @@ describe('Claude hook normalization', () => { }), 'production' ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.toolName).toBe('Edit') - expect(result?.payload.lastAssistantMessage).toBe('file is read-only') + expect(failed?.payload).toMatchObject({ + state: 'working', + lastAssistantMessage: 'file is read-only' + }) + expect(failed?.payload.toolName).toBeUndefined() + expect(failed?.payload.toolInput).toBeUndefined() + + const retry = _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'PreToolUse', + tool_name: 'Read', + tool_input: { file_path: '/src/config.ts' } + }), + 'production' + ) + expect(retry?.payload).toMatchObject({ + state: 'working', + toolName: 'Read', + toolInput: '/src/config.ts' + }) }) it('PreToolUse normalizes to working + tool fields', () => { @@ -4500,6 +4551,36 @@ describe('Cursor hook normalization', () => { expect(result?.payload.toolInput).toBe('/repo/src/app.ts') }) + it('postToolUseFailure surfaces the error and clears stale tool fields', () => { + _internals.normalizeHookPayload( + 'cursor', + buildBody({ + hook_event_name: 'preToolUse', + tool_name: 'Read', + tool_input: { file_path: '/repo/src/app.ts' } + }), + 'production' + ) + const failed = _internals.normalizeHookPayload( + 'cursor', + buildBody({ + hook_event_name: 'postToolUseFailure', + tool_name: 'Read', + tool_input: { file_path: '/repo/src/app.ts' }, + error_message: 'file not found' + }), + 'production' + ) + // Why: keeping toolName set would let the compact sidebar show the tool + // instead of the failure text, hiding the error from the user. + expect(failed?.payload).toMatchObject({ + state: 'working', + lastAssistantMessage: 'file not found' + }) + expect(failed?.payload.toolName).toBeUndefined() + expect(failed?.payload.toolInput).toBeUndefined() + }) + it('afterAgentResponse carries text into lastAssistantMessage', () => { const result = _internals.normalizeHookPayload( 'cursor', @@ -5042,13 +5123,15 @@ describe('Pi hook normalization', () => { expect(result?.payload.agentType).toBe('pi') }) - it('session_shutdown maps to done', () => { + it('session_shutdown leaves a running Pi status intact', () => { const result = _internals.normalizeHookPayload( 'pi', buildBody({ hook_event_name: 'session_shutdown' }), 'production' ) - expect(result?.payload.state).toBe('done') + // Why: Pi also emits shutdown when reloading or replacing its in-process + // session while the PTY stays alive; only agent_end proves turn completion. + expect(result).toBeNull() }) it('done preserves the cached lastAssistantMessage from a prior message_end', () => { @@ -5136,6 +5219,36 @@ describe('Copilot hook normalization', () => { expect(result?.payload.toolInput).toBe('pnpm test') }) + it('PostToolUseFailure surfaces the error and clears stale tool fields', () => { + _internals.normalizeHookPayload( + 'copilot', + buildBody({ + hook_event_name: 'PreToolUse', + toolName: 'bash', + toolInput: { command: 'pnpm test' } + }), + 'production' + ) + const failed = _internals.normalizeHookPayload( + 'copilot', + buildBody({ + hook_event_name: 'PostToolUseFailure', + toolName: 'bash', + toolInput: { command: 'pnpm test' }, + error_message: 'command not found' + }), + 'production' + ) + // Why: keeping toolName set would let the compact sidebar show the tool + // instead of the failure text, hiding the error from the user. + expect(failed?.payload).toMatchObject({ + state: 'working', + lastAssistantMessage: 'command not found' + }) + expect(failed?.payload.toolName).toBeUndefined() + expect(failed?.payload.toolInput).toBeUndefined() + }) + it('PreToolUse ask_user maps to blocked and surfaces the question', () => { _internals.normalizeHookPayload( 'copilot', diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 45fdf0362a6..513735bd679 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -28,6 +28,7 @@ import { HOOK_REQUEST_SLOWLORIS_MS, markClaudeLeadTurnInterrupted, MAX_PANE_KEY_LEN, + movePaneCacheState, normalizeHookPayload, parseFormEncodedBody, readRequestBody, @@ -59,6 +60,7 @@ import { import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id' import type { LegacyPaneKeyAliasEntry } from '../../shared/types' import { normalizeAgentProviderSession } from '../../shared/agent-session-resume' +import { isCommandCodeNewTurnWhileWorking } from '../../shared/command-code-turn-boundary' export type { AgentHookSource } @@ -88,6 +90,7 @@ type PaneKeyAliasEntry = { stablePaneKey: string ptyId: string | null updatedAt: number + authorityVerified: boolean } // Why: name of the on-disk cache that survives Orca restart. Lives next to @@ -127,6 +130,8 @@ const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 // gone. Bound the set so it can't grow one entry per tab close for the whole // session (it is otherwise only cleared at app quit). export const CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024 +export const CLOSED_AGENT_STATUS_PANE_KEYS_MAX = 1024 +export const PANE_KEY_ALIASES_MAX = 1024 type LastStatusFile = { version: number @@ -164,7 +169,9 @@ function equivalentInterruptAgentType( // Why: paneKey is `${tabId}:${leafUuid}` — validate the durable leaf suffix // at write/hydrate time so legacy numeric rows fail closed. export function isValidPaneKey(value: unknown): value is string { - return typeof value === 'string' && parsePaneKey(value) !== null + return ( + typeof value === 'string' && value.length <= MAX_PANE_KEY_LEN && parsePaneKey(value) !== null + ) } function sanitizeHydratedEntry( @@ -251,6 +258,7 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta receivedAt: entry.receivedAt, stateStartedAt: entry.stateStartedAt, ...(entry.providerSession ? { providerSession: entry.providerSession } : {}), + ...(entry.promptInteractionKey ? { promptInteractionKey: entry.promptInteractionKey } : {}), ...entry.payload } } @@ -314,6 +322,7 @@ function shouldKeepClaudePermissionVisible( if ( previous?.payload.agentType !== 'claude' || previous.payload.state !== 'waiting' || + previous.hookEventName !== 'PermissionRequest' || next.payload.agentType !== 'claude' || next.payload.state !== 'working' ) { @@ -325,9 +334,8 @@ function shouldKeepClaudePermissionVisible( if (isClaudePermissionResumingApprovedTool(previous, next)) { return false } - // Why: Claude can run subagents concurrently in one pane. Keep permission - // sticky unless the next hook has a source-level execution id that the - // PermissionRequest event itself does not expose. + // Why: only real permission requests stay sticky across concurrent subagent + // activity; interactive questions clear on the next working hook. return true } @@ -471,6 +479,7 @@ export class AgentHookServer { private promptSentDedupeByPaneKey = new Map() private promptSentHashSalt = randomBytes(16).toString('hex') private closedAgentStatusTabIds = new Set() + private closedAgentStatusPaneKeys = new Set() // Why: identity check — skip writes when the JSON-stringified contents // exactly match the last successful disk write. Cheap protection against // re-firing trailing timers when nothing changed. @@ -639,13 +648,32 @@ export class AgentHookServer { } private shouldSuppressClosedTabStatus(paneKey: string): boolean { - const tabId = parsePaneKey(paneKey)?.tabId + const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) + if ( + this.closedAgentStatusPaneKeys.has(paneKey) || + this.closedAgentStatusPaneKeys.has(ownerPaneKey) + ) { + return true + } + const tabId = parsePaneKey(ownerPaneKey)?.tabId if (!tabId) { return false } return this.closedAgentStatusTabIds.has(tabId) } + private markPaneClosedForAgentStatus(paneKey: string): void { + this.closedAgentStatusPaneKeys.delete(paneKey) + this.closedAgentStatusPaneKeys.add(paneKey) + while (this.closedAgentStatusPaneKeys.size > CLOSED_AGENT_STATUS_PANE_KEYS_MAX) { + const oldest = this.closedAgentStatusPaneKeys.keys().next().value + if (oldest === undefined) { + break + } + this.closedAgentStatusPaneKeys.delete(oldest) + } + } + private attachStatusTiming( payload: AgentHookEventPayload, now = Date.now() @@ -653,8 +681,22 @@ export class AgentHookServer { const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as | EnrichedAgentHookEventPayload | undefined + const commandCodeNewTurn = + previous !== undefined && + isCommandCodeNewTurnWhileWorking({ + agentType: payload.payload.agentType, + previousState: previous.payload.state, + incomingState: payload.payload.state, + previousPrompt: previous.payload.prompt, + incomingPrompt: payload.payload.prompt, + hasExplicitPrompt: payload.hasExplicitPrompt, + previousPromptInteractionKey: previous.promptInteractionKey, + incomingPromptInteractionKey: payload.promptInteractionKey + }) const stateStartedAt = - previous && previous.payload.state === payload.payload.state ? previous.stateStartedAt : now + previous && previous.payload.state === payload.payload.state && !commandCodeNewTurn + ? previous.stateStartedAt + : now return { ...payload, receivedAt: now, @@ -921,15 +963,67 @@ export class AgentHookServer { this.paneKeyAliasPersistenceListener?.(this.getPersistedPaneKeyAliases()) } + private boundPaneKeyAliases(): void { + while (this.legacyPaneKeyAliases.size > PANE_KEY_ALIASES_MAX) { + // Why: renderer-originated aliases are untrusted process-lifetime state; + // insertion-order eviction bounds both memory and per-message cleanup. + const oldestKey = this.legacyPaneKeyAliases.keys().next().value + if (!oldestKey) { + break + } + this.legacyPaneKeyAliases.delete(oldestKey) + } + } + + private getPhysicalPaneKeyForAuthority(paneKey: string, ptyId?: string): string { + const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) + let fallbackPaneKey = paneKey + for (const [physicalPaneKey, entry] of this.legacyPaneKeyAliases) { + if ( + entry.stablePaneKey === ownerPaneKey && + (!ptyId || !entry.ptyId || entry.ptyId === ptyId) + ) { + if (entry.authorityVerified) { + return physicalPaneKey + } + fallbackPaneKey = physicalPaneKey + } + } + return fallbackPaneKey + } + + canTransferPaneAuthority( + fromPaneKey: string, + ptyId: string | undefined, + ownsPty: (physicalPaneKey: string, ptyId: string) => boolean + ): boolean { + if (!isValidPaneKey(fromPaneKey)) { + return false + } + const ownerPaneKey = this.resolvePaneKeyAlias(fromPaneKey) + const physicalPaneKey = this.getPhysicalPaneKeyForAuthority(fromPaneKey, ptyId) + const alias = this.legacyPaneKeyAliases.get(physicalPaneKey) + if (ptyId) { + return Boolean( + (alias?.authorityVerified && alias.ptyId === ptyId) || + ownsPty(physicalPaneKey, ptyId) || + (ownerPaneKey !== physicalPaneKey && ownsPty(ownerPaneKey, ptyId)) + ) + } + // Why: hook status is renderer-originated evidence, not PTY ownership. + // ID-less moves are safe only after a prior verified transfer minted an alias. + return alias?.authorityVerified === true + } + registerPaneKeyAlias( legacyPaneKey: string, stablePaneKey: string, ptyId?: string, updatedAt = Date.now(), - options?: { overwriteExisting?: boolean } + options?: { overwriteExisting?: boolean; authorityVerified?: boolean } ): void { const legacy = parseLegacyNumericPaneKey(legacyPaneKey) - const stable = parsePaneKey(stablePaneKey) + const stable = isValidPaneKey(stablePaneKey) ? parsePaneKey(stablePaneKey) : null if (!legacy || !stable || legacy.tabId !== stable.tabId) { return } @@ -941,24 +1035,110 @@ export class AgentHookServer { typeof ptyId === 'string' && ptyId.trim().length > 0 ? ptyId.trim() : existing?.ptyId const normalizedUpdatedAt = Number.isFinite(updatedAt) && updatedAt > 0 ? updatedAt : (existing?.updatedAt ?? Date.now()) + const authorityVerified = options?.authorityVerified ?? false if ( existing && existing.stablePaneKey === stablePaneKey && existing.ptyId === (normalizedPtyId ?? null) && - existing.updatedAt === normalizedUpdatedAt + existing.updatedAt === normalizedUpdatedAt && + existing.authorityVerified === authorityVerified ) { return } this.legacyPaneKeyAliases.set(legacy.paneKey, { stablePaneKey, ptyId: normalizedPtyId ?? null, - updatedAt: normalizedUpdatedAt + updatedAt: normalizedUpdatedAt, + authorityVerified }) + this.boundPaneKeyAliases() if (normalizedPtyId) { this.notifyPaneKeyAliasPersistenceListener() } } + transferPaneAuthority( + fromPaneKey: string, + toPaneKey: string, + ptyId?: string, + updatedAt = Date.now(), + options?: { authorityVerified?: boolean } + ): void { + if (!isValidPaneKey(fromPaneKey) || !isValidPaneKey(toPaneKey)) { + return + } + const previousOwnerPaneKey = this.resolvePaneKeyAlias(fromPaneKey) + const physicalPaneKey = this.getPhysicalPaneKeyForAuthority(fromPaneKey, ptyId) + const existing = this.legacyPaneKeyAliases.get(physicalPaneKey) + const normalizedPtyId = ptyId?.trim() || existing?.ptyId || null + const hadStatus = this.state.lastStatusByPaneKey.has(previousOwnerPaneKey) + movePaneCacheState(this.state, previousOwnerPaneKey, toPaneKey) + const movedStatus = this.state.lastStatusByPaneKey.get(toPaneKey) as + | EnrichedAgentHookEventPayload + | undefined + if (movedStatus) { + const owner = parsePaneKey(toPaneKey) + this.state.lastStatusByPaneKey.set(toPaneKey, { + ...movedStatus, + paneKey: toPaneKey, + tabId: owner?.tabId + }) + } + if (this.runtimeObservedStatusPaneKeys.delete(previousOwnerPaneKey)) { + this.runtimeObservedStatusPaneKeys.add(toPaneKey) + } + const promptDedupe = this.promptSentDedupeByPaneKey.get(previousOwnerPaneKey) + if (promptDedupe !== undefined) { + this.promptSentDedupeByPaneKey.delete(previousOwnerPaneKey) + this.promptSentDedupeByPaneKey.set(toPaneKey, promptDedupe) + } + this.clearAssistantMessageRetry(previousOwnerPaneKey) + // Why: the live process keeps posting the physical source key after detach; + // persist one chain-safe mapping to whichever surface currently owns it. + this.legacyPaneKeyAliases.set(physicalPaneKey, { + stablePaneKey: toPaneKey, + ptyId: normalizedPtyId, + updatedAt, + authorityVerified: options?.authorityVerified ?? true + }) + this.boundPaneKeyAliases() + this.closedAgentStatusPaneKeys.delete(toPaneKey) + this.notifyPaneKeyAliasPersistenceListener() + if (hadStatus) { + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + } + } + + retirePaneAuthority(paneKey: string): void { + const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) + const paneKeys = new Set([paneKey, ownerPaneKey]) + let aliasChanged = false + for (const [physicalPaneKey, entry] of this.legacyPaneKeyAliases) { + if (physicalPaneKey === paneKey || entry.stablePaneKey === ownerPaneKey) { + this.legacyPaneKeyAliases.delete(physicalPaneKey) + paneKeys.add(physicalPaneKey) + paneKeys.add(entry.stablePaneKey) + aliasChanged = true + } + } + const hadStatus = [...paneKeys].some((key) => this.state.lastStatusByPaneKey.has(key)) + for (const key of paneKeys) { + this.markPaneClosedForAgentStatus(key) + this.clearAssistantMessageRetry(key) + clearPaneCacheState(this.state, key) + this.runtimeObservedStatusPaneKeys.delete(key) + this.promptSentDedupeByPaneKey.delete(key) + } + if (aliasChanged) { + this.notifyPaneKeyAliasPersistenceListener() + } + if (hadStatus) { + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + } + } + clearPaneKeyAliasesForPty( ptyId: string, options?: { shouldClearStablePaneKey?: (paneKey: string) => boolean } @@ -1014,10 +1194,9 @@ export class AgentHookServer { if (!stablePaneKey) { return body } - // Why: pre-migration live shells keep posting their immutable numeric - // ORCA_PANE_KEY. The reattach path proves the UUID leaf once, then this - // bridge lets hook caches and renderer state use only the stable key. - return { ...record, paneKey: stablePaneKey } + // Why: migrated and detached shells keep posting an immutable physical + // pane key; normalize both pane and tab identity to the current owner. + return { ...record, paneKey: stablePaneKey, tabId: parsePaneKey(stablePaneKey)?.tabId } } ingestTerminalStatus(event: { @@ -1027,7 +1206,8 @@ export class AgentHookServer { connectionId?: string | null payload: ParsedAgentStatusPayload }): void { - const paneKey = this.resolvePaneKeyAlias(event.paneKey.trim()) + const physicalPaneKey = event.paneKey.trim() + const paneKey = this.resolvePaneKeyAlias(physicalPaneKey) const parsedPaneKey = parsePaneKey(paneKey) if (paneKey.length === 0) { track('agent_hook_unattributed', { reason: 'empty_pane_key' }) @@ -1036,11 +1216,16 @@ export class AgentHookServer { if (paneKey.length > MAX_PANE_KEY_LEN || !parsedPaneKey) { return } - const tabId = + const reportedTabId = event.tabId !== undefined && event.tabId.trim().length > 0 ? event.tabId.trim() : undefined - if (tabId !== undefined && tabId !== parsedPaneKey.tabId) { + if ( + paneKey === physicalPaneKey && + reportedTabId !== undefined && + reportedTabId !== parsedPaneKey.tabId + ) { return } + const tabId = paneKey !== physicalPaneKey ? parsedPaneKey.tabId : reportedTabId if (this.shouldSuppressClosedTabStatus(paneKey)) { return } @@ -1119,7 +1304,8 @@ export class AgentHookServer { // Why: match the listener's HTTP path — `normalizeHookPayload` trims and // length-caps paneKey before caching, so the cache key here must follow // the same rule or remote-vs-local events for the same pane would diverge. - const paneKey = this.resolvePaneKeyAlias(envelope.paneKey.trim()) + const physicalPaneKey = envelope.paneKey.trim() + const paneKey = this.resolvePaneKeyAlias(physicalPaneKey) const parsedPaneKey = parsePaneKey(paneKey) if (paneKey.length === 0) { track('agent_hook_unattributed', { reason: 'empty_pane_key' }) @@ -1140,13 +1326,18 @@ export class AgentHookServer { // Why: mirror the HTTP path's `readStringField` behavior — trim and treat // empty-after-trim as undefined rather than letting a literal "" leak // into the event. - const tabId = + const reportedTabId = envelope.tabId !== undefined && envelope.tabId.trim().length > 0 ? envelope.tabId.trim() : undefined - if (tabId !== undefined && tabId !== parsedPaneKey.tabId) { + if ( + paneKey === physicalPaneKey && + reportedTabId !== undefined && + reportedTabId !== parsedPaneKey.tabId + ) { return } + const tabId = paneKey !== physicalPaneKey ? parsedPaneKey.tabId : reportedTabId if (this.shouldSuppressClosedTabStatus(paneKey)) { return } @@ -1347,6 +1538,7 @@ export class AgentHookServer { this.runtimeObservedStatusPaneKeys.clear() this.promptSentDedupeByPaneKey.clear() this.closedAgentStatusTabIds.clear() + this.closedAgentStatusPaneKeys.clear() this.legacyPaneKeyAliases.clear() clearAllListenerCaches(this.state) this.notifyStatusChangeListeners() @@ -1416,13 +1608,13 @@ export class AgentHookServer { let aliasChanged = false for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) { - if ( - paneCacheKeyMatchesTab(legacyPaneKey, tabId) || - paneCacheKeyMatchesTab(entry.stablePaneKey, tabId) - ) { + const ownerMatches = paneCacheKeyMatchesTab(entry.stablePaneKey, tabId) + if (ownerMatches) { this.legacyPaneKeyAliases.delete(legacyPaneKey) paneKeysToClear.add(legacyPaneKey) paneKeysToClear.add(entry.stablePaneKey) + this.markPaneClosedForAgentStatus(legacyPaneKey) + this.markPaneClosedForAgentStatus(entry.stablePaneKey) aliasChanged = true } } diff --git a/src/main/ai-vault/session-scanner-injected-title.test.ts b/src/main/ai-vault/session-scanner-injected-title.test.ts index a42296f9536..00e084ace17 100644 --- a/src/main/ai-vault/session-scanner-injected-title.test.ts +++ b/src/main/ai-vault/session-scanner-injected-title.test.ts @@ -53,4 +53,42 @@ describe('scanAiVaultSessions harness-injected title seeding', () => { expect(result.sessions).toHaveLength(1) expect(result.sessions[0]?.title).toBe('Fix the sidebar label bug') }) + + it('titles a session from a real first turn that pastes a custom element', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-claude-custom-element-title-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + await mkdir(join(roots.claudeProjectsDir, 'project'), { recursive: true }) + + await writeFile( + join(roots.claudeProjectsDir, 'project', 'custom-element-first.jsonl'), + jsonLines([ + { + type: 'user', + sessionId: 'custom-element-first', + timestamp: '2026-06-11T10:00:00.000Z', + cwd: '/repo/app', + // A real prompt starting with an unknown kebab tag is the user's turn; + // it must win the title over a later prompt, not be demoted as machinery. + message: { role: 'user', content: ' render the profile card' } + }, + { + type: 'user', + sessionId: 'custom-element-first', + timestamp: '2026-06-11T10:00:01.000Z', + cwd: '/repo/app', + message: { role: 'user', content: 'now add a dark variant' } + } + ]) + ) + + const result = await scanAiVaultSessions({ + ...roots, + platform: 'darwin' + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]?.title).toBe(' render the profile card') + }) }) diff --git a/src/main/ai-vault/session-scanner-kimi-index-cache.test.ts b/src/main/ai-vault/session-scanner-kimi-index-cache.test.ts new file mode 100644 index 00000000000..549c1a3c170 --- /dev/null +++ b/src/main/ai-vault/session-scanner-kimi-index-cache.test.ts @@ -0,0 +1,164 @@ +import { mkdtemp, rm, stat, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + KimiSessionIndexCache, + KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS, + KIMI_WORK_DIR_CACHE_TTL_MS, + type KimiSessionIndexIdentity +} from './session-scanner-kimi-index-cache' +import { + clearKimiSessionIndexCache, + hasKimiSessionIndexCacheEntryForTests, + readKimiWorkDirBySessionId +} from './session-scanner-kimi-paths' + +const IDENTITY: KimiSessionIndexIdentity = { + changeTimeMs: 1, + mtimeMs: 1, + sizeBytes: 1 +} +let tempDirs: string[] = [] + +afterEach(async () => { + vi.useRealTimers() + clearKimiSessionIndexCache() + await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))) + tempDirs = [] +}) + +describe('KimiSessionIndexCache', () => { + it('bounds prolonged path churn and retains a reused index', async () => { + const cache = new KimiSessionIndexCache() + + for (let index = 0; index < KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS; index += 1) { + const path = `home-${index}/session_index.jsonl` + await cache.get(path, IDENTITY, cache.beginRead(), async () => new Map([[path, path]])) + } + const reusedPath = 'home-0/session_index.jsonl' + await cache.get(reusedPath, IDENTITY, cache.beginRead(), async () => new Map()) + + const firstOverflowPath = `home-${KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS}/session_index.jsonl` + await cache.get( + firstOverflowPath, + IDENTITY, + cache.beginRead(), + async () => new Map([[firstOverflowPath, firstOverflowPath]]) + ) + expect(cache.has(reusedPath)).toBe(true) + expect(cache.has('home-1/session_index.jsonl')).toBe(false) + + for (let index = KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS + 1; index < 640; index += 1) { + const path = `home-${index}/session_index.jsonl` + await cache.get(path, IDENTITY, cache.beginRead(), async () => new Map([[path, path]])) + } + + expect(cache.has(reusedPath)).toBe(false) + expect(cache.size).toBe(KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS) + expect(cache.has('home-576/session_index.jsonl')).toBe(true) + expect(cache.has('home-575/session_index.jsonl')).toBe(false) + cache.clear() + }) + + it('refreshes active entries and expires them after an idle TTL', async () => { + vi.useFakeTimers() + const cache = new KimiSessionIndexCache() + const path = 'active/session_index.jsonl' + const value = new Map([['session', '/repo']]) + + await cache.get(path, IDENTITY, cache.beginRead(), async () => value) + await vi.advanceTimersByTimeAsync(KIMI_WORK_DIR_CACHE_TTL_MS - 1) + expect(await cache.get(path, IDENTITY, cache.beginRead(), async () => new Map())).toBe(value) + + await vi.advanceTimersByTimeAsync(KIMI_WORK_DIR_CACHE_TTL_MS - 1) + expect(cache.has(path)).toBe(true) + await vi.advanceTimersByTimeAsync(1) + expect(cache.has(path)).toBe(false) + expect(vi.getTimerCount()).toBe(0) + }) + + it('deduplicates concurrent reads of the same file identity', async () => { + const cache = new KimiSessionIndexCache() + const load = vi.fn(async () => new Map([['session', '/repo']])) + const first = cache.get('index', IDENTITY, cache.beginRead(), load) + const second = cache.get('index', IDENTITY, cache.beginRead(), load) + + expect(second).toBe(first) + await expect(second).resolves.toEqual(new Map([['session', '/repo']])) + expect(load).toHaveBeenCalledOnce() + cache.clear() + }) + + it('does not let an older mutation race replace a newer identity', async () => { + const cache = new KimiSessionIndexCache() + const oldGeneration = cache.beginRead() + const newGeneration = cache.beginRead() + const newerIdentity = { ...IDENTITY, changeTimeMs: 2, mtimeMs: 2, sizeBytes: 2 } + const newer = new Map([['session', '/new']]) + + await cache.get('index', newerIdentity, newGeneration, async () => newer) + await cache.get('index', IDENTITY, oldGeneration, async () => new Map([['session', '/old']])) + const reload = vi.fn(async () => new Map()) + + await expect(cache.get('index', newerIdentity, cache.beginRead(), reload)).resolves.toBe(newer) + expect(reload).not.toHaveBeenCalled() + cache.clear() + }) + + it('does not repopulate after an owner clears an in-flight read', async () => { + const cache = new KimiSessionIndexCache() + const staleGeneration = cache.beginRead() + cache.clear() + + await cache.get('index', IDENTITY, staleGeneration, async () => new Map([['session', '/old']])) + + expect(cache.has('index')).toBe(false) + const current = new Map([['session', '/current']]) + await expect( + cache.get('index', IDENTITY, cache.beginRead(), async () => current) + ).resolves.toBe(current) + expect(cache.has('index')).toBe(true) + cache.clear() + }) +}) + +describe('Kimi session index reader cache', () => { + it('releases a retained map when its index file disappears', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-kimi-index-delete-')) + tempDirs.push(root) + const indexPath = join(root, 'session_index.jsonl') + await writeFile(indexPath, `${JSON.stringify({ sessionId: 'session', workDir: '/repo' })}\n`) + + await readKimiWorkDirBySessionId(indexPath) + expect(hasKimiSessionIndexCacheEntryForTests(indexPath)).toBe(true) + await rm(indexPath) + + await expect(readKimiWorkDirBySessionId(indexPath)).resolves.toEqual(new Map()) + expect(hasKimiSessionIndexCacheEntryForTests(indexPath)).toBe(false) + }) + + it('invalidates when size changes even if the mtime is restored', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-kimi-index-mutation-')) + tempDirs.push(root) + const indexPath = join(root, 'session_index.jsonl') + await writeFile(indexPath, `${JSON.stringify({ sessionId: 'session-old', workDir: '/old' })}\n`) + const originalStat = await stat(indexPath) + + await expect(readKimiWorkDirBySessionId(indexPath)).resolves.toEqual( + new Map([['session-old', '/old']]) + ) + await writeFile( + indexPath, + `${JSON.stringify({ sessionId: 'session-old', workDir: '/old' })}\n${JSON.stringify({ sessionId: 'session-new', workDir: '/new' })}\n` + ) + await utimes(indexPath, originalStat.atime, originalStat.mtime) + + await expect(readKimiWorkDirBySessionId(indexPath)).resolves.toEqual( + new Map([ + ['session-old', '/old'], + ['session-new', '/new'] + ]) + ) + }) +}) diff --git a/src/main/ai-vault/session-scanner-kimi-index-cache.ts b/src/main/ai-vault/session-scanner-kimi-index-cache.ts new file mode 100644 index 00000000000..fb5f6926eb3 --- /dev/null +++ b/src/main/ai-vault/session-scanner-kimi-index-cache.ts @@ -0,0 +1,129 @@ +export type KimiSessionIndexIdentity = { + changeTimeMs: number + mtimeMs: number + sizeBytes: number +} + +type KimiSessionIndexCacheEntry = { + expiresAt: number + generation: number + identity: KimiSessionIndexIdentity + timer: NodeJS.Timeout | null + value: Promise> +} + +export const KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS = 64 +// Active Vault scans refresh this window; closing the surface releases parsed +// index maps soon without making a live Kimi session reread on every scan. +export const KIMI_WORK_DIR_CACHE_TTL_MS = 5 * 60_000 + +export class KimiSessionIndexCache { + private readonly entries = new Map() + private minimumCacheGeneration = 0 + private nextGeneration = 0 + + beginRead(): number { + this.nextGeneration += 1 + return this.nextGeneration + } + + clear(): void { + for (const entry of this.entries.values()) { + if (entry.timer) { + clearTimeout(entry.timer) + } + } + this.entries.clear() + // Why: a read already awaiting stat/load when its owner clears the cache + // may finish later, but must not silently recreate the released entry. + this.minimumCacheGeneration = this.nextGeneration + 1 + } + + delete(indexPath: string, generation = Number.POSITIVE_INFINITY): void { + const entry = this.entries.get(indexPath) + if (entry && entry.generation <= generation) { + this.forget(indexPath, entry) + } + } + + get( + indexPath: string, + identity: KimiSessionIndexIdentity, + generation: number, + load: () => Promise> + ): Promise> { + if (generation < this.minimumCacheGeneration) { + return load() + } + const cached = this.entries.get(indexPath) + const now = Date.now() + if (cached && cached.expiresAt > now && identitiesMatch(cached.identity, identity)) { + this.remember(indexPath, cached, now) + return cached.value + } + if (cached && cached.generation > generation) { + // Why: a slower, older stat must not replace a newer file generation + // that another concurrent scan already cached for the same path. + return load() + } + + const entry: KimiSessionIndexCacheEntry = { + expiresAt: now + KIMI_WORK_DIR_CACHE_TTL_MS, + generation, + identity, + timer: null, + value: load() + } + this.remember(indexPath, entry, now) + return entry.value + } + + has(indexPath: string): boolean { + return this.entries.has(indexPath) + } + + get size(): number { + return this.entries.size + } + + private forget(indexPath: string, entry: KimiSessionIndexCacheEntry): void { + if (this.entries.get(indexPath) !== entry) { + return + } + if (entry.timer) { + clearTimeout(entry.timer) + } + this.entries.delete(indexPath) + } + + private remember(indexPath: string, entry: KimiSessionIndexCacheEntry, now: number): void { + const replaced = this.entries.get(indexPath) + if (replaced?.timer && replaced !== entry) { + clearTimeout(replaced.timer) + } + if (entry.timer) { + clearTimeout(entry.timer) + } + entry.expiresAt = now + KIMI_WORK_DIR_CACHE_TTL_MS + entry.timer = setTimeout(() => this.forget(indexPath, entry), KIMI_WORK_DIR_CACHE_TTL_MS) + entry.timer.unref() + this.entries.delete(indexPath) + this.entries.set(indexPath, entry) + + while (this.entries.size > KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS) { + const oldest = this.entries.entries().next().value + if (!oldest) { + return + } + this.forget(oldest[0], oldest[1]) + } + } +} + +function identitiesMatch(left: KimiSessionIndexIdentity, right: KimiSessionIndexIdentity): boolean { + return ( + left.changeTimeMs === right.changeTimeMs && + left.mtimeMs === right.mtimeMs && + left.sizeBytes === right.sizeBytes + ) +} diff --git a/src/main/ai-vault/session-scanner-kimi-parser.test.ts b/src/main/ai-vault/session-scanner-kimi-parser.test.ts index bb268fbdcb1..67748ae727f 100644 --- a/src/main/ai-vault/session-scanner-kimi-parser.test.ts +++ b/src/main/ai-vault/session-scanner-kimi-parser.test.ts @@ -3,7 +3,12 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { parseKimiSessionFile } from './session-scanner-kimi-parser' -import { clearKimiSessionIndexCache } from './session-scanner-kimi-paths' +import { + clearKimiSessionIndexCache, + hasKimiSessionIndexCacheEntryForTests, + KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS, + readKimiWorkDirBySessionId +} from './session-scanner-kimi-paths' import type { FileWithMtime } from './session-scanner-types' let tempDirs: string[] = [] @@ -218,4 +223,34 @@ describe('parseKimiSessionFile', () => { const session = await parseKimiSessionFile(file, 'darwin') expect(session?.title).toBe('do the thing') }) + + it('caps cached session index maps by recent index path', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-kimi-cache-')) + tempDirs.push(root) + const indexPaths: string[] = [] + + for (let index = 0; index <= KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS; index += 1) { + const home = join(root, `home-${index}`) + await mkdir(home, { recursive: true }) + const indexPath = join(home, 'session_index.jsonl') + await writeFile( + indexPath, + `${JSON.stringify({ sessionId: `session_${index}`, workDir: `/tmp/kimi-${index}` })}\n` + ) + indexPaths.push(indexPath) + } + + for (const indexPath of indexPaths.slice(0, KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS)) { + await readKimiWorkDirBySessionId(indexPath) + } + const refreshedFirst = await readKimiWorkDirBySessionId(indexPaths[0]) + expect(refreshedFirst.get('session_0')).toBe('/tmp/kimi-0') + await readKimiWorkDirBySessionId(indexPaths[KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS]) + + expect(hasKimiSessionIndexCacheEntryForTests(indexPaths[0])).toBe(true) + expect(hasKimiSessionIndexCacheEntryForTests(indexPaths[1])).toBe(false) + expect( + hasKimiSessionIndexCacheEntryForTests(indexPaths[KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS]) + ).toBe(true) + }) }) diff --git a/src/main/ai-vault/session-scanner-kimi-paths.ts b/src/main/ai-vault/session-scanner-kimi-paths.ts index db307cb22d0..7ed457f6c07 100644 --- a/src/main/ai-vault/session-scanner-kimi-paths.ts +++ b/src/main/ai-vault/session-scanner-kimi-paths.ts @@ -4,6 +4,13 @@ import { homedir } from 'node:os' import { basename, dirname, join } from 'node:path' import { createInterface } from 'node:readline' import { asRecord, extractString } from './session-scanner-values' +import { + KimiSessionIndexCache, + KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS, + KIMI_WORK_DIR_CACHE_TTL_MS +} from './session-scanner-kimi-index-cache' + +export { KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS, KIMI_WORK_DIR_CACHE_TTL_MS } // Why: Kimi Code stores sessions under /sessions/, mirroring the // CLI's own `KIMI_CODE_HOME ?? ~/.kimi-code` resolution (see kimi-fetcher.ts). @@ -55,38 +62,41 @@ export function kimiPrimaryAgentWirePath( return join(dirname(statePath), 'agents', primaryId, 'wire.jsonl') } -type WorkDirCacheEntry = { - mtimeMs: number - map: Promise> -} - // Why: every session under one Kimi home shares a single session_index.jsonl. -// Re-reading it once per session would be O(n^2); memoize by path + mtime so a -// scan reads the index at most once and the cache self-invalidates when Kimi -// appends a new session (mtime bump). -const workDirCacheByIndexPath = new Map() +// Re-reading it once per session would be O(n^2); memoize by path + file +// identity so a scan reads the index at most once. Bound and expire entries +// because host/WSL/runtime roots can change during one main-process lifetime. +const workDirCacheByIndexPath = new KimiSessionIndexCache() export function clearKimiSessionIndexCache(): void { workDirCacheByIndexPath.clear() } +export function hasKimiSessionIndexCacheEntryForTests(indexPath: string): boolean { + return workDirCacheByIndexPath.has(indexPath) +} + export async function readKimiWorkDirBySessionId(indexPath: string): Promise> { - let mtimeMs: number + const generation = workDirCacheByIndexPath.beginRead() + let identity: Awaited> try { - mtimeMs = (await stat(indexPath)).mtimeMs + identity = await stat(indexPath) } catch { // Missing index (e.g. user deleted it): sessions still list, just without cwd. + workDirCacheByIndexPath.delete(indexPath, generation) return new Map() } - const cached = workDirCacheByIndexPath.get(indexPath) - if (cached && cached.mtimeMs === mtimeMs) { - return cached.map - } - - const map = parseKimiSessionIndex(indexPath) - workDirCacheByIndexPath.set(indexPath, { mtimeMs, map }) - return map + return workDirCacheByIndexPath.get( + indexPath, + { + changeTimeMs: identity.ctimeMs, + mtimeMs: identity.mtimeMs, + sizeBytes: identity.size + }, + generation, + () => parseKimiSessionIndex(indexPath) + ) } async function parseKimiSessionIndex(indexPath: string): Promise> { diff --git a/src/main/ai-vault/session-scanner-primary-parsers.ts b/src/main/ai-vault/session-scanner-primary-parsers.ts index 9e270194712..b5d0c6900f2 100644 --- a/src/main/ai-vault/session-scanner-primary-parsers.ts +++ b/src/main/ai-vault/session-scanner-primary-parsers.ts @@ -2,7 +2,7 @@ import { createReadStream } from 'node:fs' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' -import { isHarnessInjectedUserTurnText } from '../../shared/harness-injected-user-turns' +import { isKnownHarnessInjectedUserTurnText } from '../../shared/harness-injected-user-turns' import type { FileWithMtime, ResumableSessionParseState, @@ -122,8 +122,9 @@ export function consumeClaudeSessionLine(state: ClaudeSessionParseState, line: s if (title) { // Meta prompts (injected context) only seed the last-resort title. Some // injected turns (task notifications) carry no isMeta, so also gate on - // the shared machinery-text classifier. - if (record.isMeta === true || isHarnessInjectedUserTurnText(title)) { + // the known-tag classifier — a real prompt pasting a custom `` + // must seed the primary title, not be demoted as machinery. + if (record.isMeta === true || isKnownHarnessInjectedUserTurnText(title)) { state.metaTitle ??= title } else { state.firstUserTitle ??= title diff --git a/src/main/antigravity/hook-service.test.ts b/src/main/antigravity/hook-service.test.ts index 67750cedffb..fffe15052f9 100644 --- a/src/main/antigravity/hook-service.test.ts +++ b/src/main/antigravity/hook-service.test.ts @@ -16,6 +16,7 @@ vi.mock('os', async () => { }) import { AntigravityHookService } from './hook-service' +import { createManagedCommandMatcher } from '../agent-hooks/installer-utils' const ANTIGRAVITY_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'antigravity-hook.cmd' : 'antigravity-hook.sh' @@ -165,10 +166,9 @@ describe('AntigravityHookService', () => { const definition = config['orca-status'][eventName][0] const command = eventName === 'PostToolUse' ? definition.hooks?.[0]?.command : definition.command - expect(command).toContain(wrapperFileName) + expect(createManagedCommandMatcher(wrapperFileName)(command)).toBe(true) expect(command).not.toContain('cmd /d /s /c') expect(command).not.toContain('ORCA_ANTIGRAVITY_EVENT') - expect(command).not.toContain('"') const wrapper = readFileSync(join(homeDir, '.orca', 'agent-hooks', wrapperFileName), 'utf8') expect(wrapper).toContain(`set "ORCA_ANTIGRAVITY_EVENT=${eventName}"`) diff --git a/src/main/antigravity/hook-service.ts b/src/main/antigravity/hook-service.ts index c8e30632a16..e83160a3549 100644 --- a/src/main/antigravity/hook-service.ts +++ b/src/main/antigravity/hook-service.ts @@ -14,6 +14,7 @@ import { readHooksJson, removeManagedCommands, wrapPosixHookCommand, + wrapWindowsCmdHookCommand, writeHooksJson, writeManagedScript, type HookDefinition, @@ -24,6 +25,12 @@ import { writeHooksJsonRemote, writeManagedScriptRemote } from '../agent-hooks/installer-utils-remote' +import { + buildPosixHookPayloadCapture, + buildWindowsHookEnvironmentGuardLines, + buildWindowsHookStdinDrainEpilogue, + WINDOWS_HOOK_STDIN_DRAIN_COMMAND +} from '../agent-hooks/hook-stdin-contract' const ANTIGRAVITY_HOOK_BUNDLE_NAME = 'orca-status' @@ -76,7 +83,7 @@ function getWindowsWrapperScriptPath(event: AntigravityEvent): string { function getManagedCommand(scriptPath: string, event: AntigravityEvent): string { if (process.platform === 'win32') { - return getWindowsWrapperScriptPath(event) + return wrapWindowsCmdHookCommand(getWindowsWrapperScriptPath(event)) } return wrapPosixHookCommand(scriptPath, { ORCA_ANTIGRAVITY_EVENT: event.eventName }) } @@ -92,11 +99,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' echo {}', ')', 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', - 'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0', - 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0', - 'if "%ORCA_PANE_KEY%"=="" exit /b 0', + ...buildWindowsHookEnvironmentGuardLines(), buildWindowsAntigravityHookPostCommand(), 'exit /b 0', + ...buildWindowsHookStdinDrainEpilogue(), '' ].join('\r\n') } @@ -114,18 +120,15 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' printf "{}\\n"', ' ;;', 'esac', + // Why: some Antigravity events arrive without stdin but still need a + // status post, so the shared capture maps empty input to an object. + ...buildPosixHookPayloadCapture('empty-object'), 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', ' exit 0', 'fi', - 'payload=$(cat)', - 'if [ -z "$payload" ]; then', - // Why: some Antigravity hook events can arrive without stdin. Still post - // the event name so Orca shows a status row instead of silently dropping it. - " payload='{}'", - 'fi', // Timeout caps best-effort hook posts if the local listener stalls. // Why: pipe payload to curl's stdin (`payload@-`) instead of an inline // `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl @@ -162,6 +165,9 @@ function getWindowsWrapperScript(eventName: string): string { ') else (', ' echo {}', ')', + // Why: when the shared core script is missing, this wrapper becomes the + // stdin owner and must finish the agent's payload write before returning. + WINDOWS_HOOK_STDIN_DRAIN_COMMAND, 'exit /b 0', '' ].join('\r\n') diff --git a/src/main/browser/agent-browser-bridge.test.ts b/src/main/browser/agent-browser-bridge.test.ts index 55b650c7377..f55f726f356 100644 --- a/src/main/browser/agent-browser-bridge.test.ts +++ b/src/main/browser/agent-browser-bridge.test.ts @@ -1,14 +1,14 @@ /* eslint-disable max-lines */ import { describe, it, expect, vi, beforeEach } from 'vitest' -const { execFileMock, webContentsFromIdMock, existsSyncMock, readFileSyncMock } = vi.hoisted( - () => ({ +const { execFileMock, webContentsFromIdMock, existsSyncMock, readFileSyncMock, stdinWrites } = + vi.hoisted(() => ({ execFileMock: vi.fn(), webContentsFromIdMock: vi.fn(), existsSyncMock: vi.fn(() => false), - readFileSyncMock: vi.fn(() => Buffer.from('')) - }) -) + readFileSyncMock: vi.fn(() => Buffer.from('')), + stdinWrites: [] as string[] + })) vi.mock('child_process', () => ({ execFile: execFileMock })) vi.mock('fs', () => ({ @@ -108,6 +108,22 @@ function mockWebContents(id: number, url = 'https://example.com', title = 'Examp function succeedWith(data: unknown): void { execFileMock.mockImplementation((_bin: string, _args: string[], _opts: unknown, cb: Function) => { cb(null, JSON.stringify({ success: true, data }), '') + return { + stdin: { on: vi.fn(), end: (text: string) => stdinWrites.push(text) } + } + }) +} + +function succeedForContentEditable(data: unknown = { ok: true }): void { + execFileMock.mockImplementation((_bin: string, args: string[], _opts: unknown, cb: Function) => { + const result = + args.includes('get') && args.includes('attr') && args.includes('contenteditable') + ? { value: 'true' } + : data + cb(null, JSON.stringify({ success: true, data: result }), '') + return { + stdin: { on: vi.fn(), end: (text: string) => stdinWrites.push(text) } + } }) } @@ -207,10 +223,57 @@ function createFillEvalNode(options: { function runFillEvalExpressions( expressions: string[], - document: { activeElement: unknown; getElementById: (id: string) => unknown } + document: { activeElement: unknown; getElementById: (id: string) => unknown }, + windowObject: Record = {} ): void { for (const expression of expressions) { - new Function('document', 'Event', `return (${expression})`)(document, TestEvent) + new Function('document', 'Event', 'window', `return (${expression})`)( + document, + TestEvent, + windowObject + ) + } +} + +function createContentEditableEvalEnvironment(initialText: string) { + const editor = { + tagName: 'DIV', + isContentEditable: true, + textContent: initialText, + matches: vi.fn(() => false), + getAttribute: vi.fn((name: string) => (name === 'contenteditable' ? 'true' : null)), + dispatchEvent: vi.fn() + } + let selected = false + const selection = { + selectAllChildren: vi.fn(() => { + selected = true + }) + } + const execCommand = vi.fn((command: string, _showUi: boolean, value: string) => { + // Chromium treats an empty insertText as a successful no-op; deletion is + // required to clear a selected contenteditable through the input pipeline. + if (command === 'delete') { + if (!selected) { + return false + } + editor.textContent = '' + } else if (value.length > 0) { + editor.textContent = selected ? value : editor.textContent + value + } + selected = false + return true + }) + return { + editor, + execCommand, + document: { + activeElement: editor, + body: {}, + getElementById: () => null, + execCommand + }, + windowObject: { getSelection: () => selection } } } @@ -222,6 +285,7 @@ describe('AgentBrowserBridge', () => { beforeEach(() => { vi.clearAllMocks() + stdinWrites.length = 0 CdpWsProxyMock.instances.length = 0 existsSyncMock.mockReturnValue(false) readFileSyncMock.mockReturnValue(Buffer.from('')) @@ -1529,6 +1593,78 @@ describe('AgentBrowserBridge', () => { expect(() => new Function(expression)).not.toThrow() }) + it('replaces contenteditable text through the browser editing pipeline', async () => { + succeedForContentEditable() + const environment = createContentEditableEvalEnvironment('existing text') + + await bridge.fill('@editor', 'replacement text') + + runFillEvalExpressions(stdinWrites, environment.document, environment.windowObject) + + expect(environment.editor.textContent).toBe('replacement text') + expect(environment.execCommand).toHaveBeenCalledWith('insertText', false, 'replacement text') + expect(environment.editor.dispatchEvent).not.toHaveBeenCalled() + }) + + it('clears selected contenteditable text with a browser delete command', async () => { + succeedForContentEditable() + const environment = createContentEditableEvalEnvironment('existing text') + + const result = await bridge.clear('@editor') + + runFillEvalExpressions(stdinWrites, environment.document, environment.windowObject) + + expect(environment.editor.textContent).toBe('') + expect(environment.execCommand).toHaveBeenCalledWith('delete', false, '') + expect(environment.editor.dispatchEvent).not.toHaveBeenCalled() + expect(result).toEqual({ cleared: '@editor' }) + }) + + it('fails contenteditable fill when the browser editing command is unavailable', async () => { + succeedForContentEditable() + const environment = createContentEditableEvalEnvironment('existing text') + environment.execCommand.mockReturnValue(false) + + await bridge.fill('@editor', 'replacement text') + + expect(() => + runFillEvalExpressions(stdinWrites, environment.document, environment.windowObject) + ).toThrow('Browser rich-text editing command failed') + expect(environment.editor.textContent).toBe('existing text') + expect(environment.editor.dispatchEvent).not.toHaveBeenCalled() + }) + + it('inserts paste-sized contenteditable text as one stdin editing transaction', async () => { + const firstChunk = 'x'.repeat(AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES) + succeedForContentEditable() + const environment = createContentEditableEvalEnvironment('existing text') + + await bridge.fill('@editor', `${firstChunk}tail`) + + const evalCalls = execFileMock.mock.calls.filter((call: unknown[]) => + (call[1] as string[]).includes('eval') + ) + runFillEvalExpressions(stdinWrites, environment.document, environment.windowObject) + + expect(evalCalls).toHaveLength(1) + expect(evalCalls[0][1]).toContain('--stdin') + expect(stdinWrites).toHaveLength(1) + expect(environment.editor.textContent).toBe(`${firstChunk}tail`) + }) + + it('uses target-aware agent-browser fill when clearing a non-rich target', async () => { + succeedWith({ filled: '@disabled' }) + + const result = await bridge.clear('@disabled') + + const commandArgs = execFileMock.mock.calls.map((call: unknown[]) => call[1] as string[]) + expect(commandArgs.some((args) => args.includes('eval'))).toBe(false) + expect(commandArgs.some((args) => args.includes('fill') && args.includes('@disabled'))).toBe( + true + ) + expect(result).toEqual({ cleared: '@disabled' }) + }) + it('routes focused spinbutton wrappers to editable descendants before filling', async () => { succeedWith({ ok: true }) diff --git a/src/main/browser/agent-browser-bridge.ts b/src/main/browser/agent-browser-bridge.ts index 4f460a56aba..80817c81d58 100644 --- a/src/main/browser/agent-browser-bridge.ts +++ b/src/main/browser/agent-browser-bridge.ts @@ -118,10 +118,49 @@ function focusedValueSetExpression( ].join('') } +// Why: rich editors reconcile only browser editing transactions; direct DOM +// fallback can look correct while leaving their model stale. +function focusedRichTextEditExpression( + valueExpression: string, + options?: { selectAll?: boolean } +): string { + const selectAll = options?.selectAll ? 'true' : 'false' + return [ + '(() => {', + ' const target = document.activeElement;', + ' const value = ', + valueExpression, + ';', + ` const selectAll = ${selectAll};`, + " const isEditable = target?.isContentEditable === true || /^(|true|plaintext-only)$/i.test(target?.getAttribute?.('contenteditable') ?? 'false');", + " if (!target || target === document.body || !isEditable) { throw new Error('Focused rich-text target is unavailable'); }", + ' if (selectAll) {', + " if (typeof window.getSelection !== 'function') { throw new Error('Rich-text selection is unavailable'); }", + ' const selection = window.getSelection();', + " if (!selection) { throw new Error('Rich-text selection is unavailable'); }", + ' selection.selectAllChildren(target);', + ' }', + " const editCommand = selectAll && value.length === 0 ? 'delete' : 'insertText';", + ' let edited = false;', + ' try {', + ' edited = document.execCommand(editCommand, false, value) === true;', + ' } catch { edited = false; }', + " if (!edited) { throw new Error('Browser rich-text editing command failed'); }", + ' })()' + ].join('') +} + +function isExplicitContentEditableResult(result: unknown): boolean { + const value = + result && typeof result === 'object' ? (result as { value?: unknown }).value : undefined + return typeof value === 'string' && /^(|true|plaintext-only)$/i.test(value) +} + type AgentBrowserExecOptions = { envOverrides?: NodeJS.ProcessEnv timeoutMs?: number timeoutError?: BrowserError + stdinText?: string } type EnqueueTargetedCommandOptions = { @@ -772,33 +811,35 @@ export class AgentBrowserBridge { browserPageId?: string ): Promise { await assertClipboardTextWriteWithinLimitWithYield(value) - // Why: Input.insertText via Electron's debugger API does not deliver text to - // focused inputs in webviews — this is a fundamental Electron limitation. - // Agent-browser's fill and click also fail for the same reason. - // Workaround: use agent-browser's focus to resolve the ref, then set the value - // directly via chunked JS and dispatch input/change events for React/framework compat. + // Why: agent-browser's CDP text insertion loses focus in Electron guests. + // Resolve the ref first, then edit through the browser's input pipeline. return this.enqueueTargetedCommand( worktreeId, browserPageId, async (sessionName) => { - await this.execAgentBrowser(sessionName, ['focus', element]) - await this.execAgentBrowser(sessionName, [ - 'eval', - focusedValueSetExpression(JSON.stringify('')) - ]) - for (const chunk of iterateBrowserTextInsertionChunks( - value, - AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES - )) { + if (!(await this.isExplicitContentEditableTarget(sessionName, element))) { + await this.execAgentBrowser(sessionName, ['focus', element]) await this.execAgentBrowser(sessionName, [ 'eval', - focusedValueSetExpression(JSON.stringify(chunk), { append: true }) + focusedValueSetExpression(JSON.stringify('')) ]) + for (const chunk of iterateBrowserTextInsertionChunks( + value, + AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES + )) { + await this.execAgentBrowser(sessionName, [ + 'eval', + focusedValueSetExpression(JSON.stringify(chunk), { append: true }) + ]) + } + await this.execAgentBrowser(sessionName, [ + 'eval', + focusedValueSetExpression(JSON.stringify(''), { append: true, dispatchEvents: true }) + ]) + return { filled: element } as BrowserFillResult } - await this.execAgentBrowser(sessionName, [ - 'eval', - focusedValueSetExpression(JSON.stringify(''), { append: true, dispatchEvents: true }) - ]) + + await this.fillExplicitContentEditable(sessionName, element, value) return { filled: element } as BrowserFillResult }, { requireScopedTarget: true } @@ -1537,10 +1578,22 @@ export class AgentBrowserBridge { worktreeId?: string, browserPageId?: string ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - // Why: agent-browser has no clear command — use fill with empty string - return (await this.execAgentBrowser(sessionName, ['fill', element, ''])) as BrowserClearResult - }) + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (sessionName) => { + if (!(await this.isExplicitContentEditableTarget(sessionName, element))) { + // Why: agent-browser resolves this ref directly, preserving iframe, + // shadow-root, and unfocusable-target semantics for ordinary fields. + await this.execAgentBrowser(sessionName, ['fill', element, '']) + return { cleared: element } + } + + await this.fillExplicitContentEditable(sessionName, element, '') + return { cleared: element } + }, + { requireScopedTarget: true } + ) } async selectAll( @@ -2387,6 +2440,32 @@ export class AgentBrowserBridge { return translated.result } + private async isExplicitContentEditableTarget( + sessionName: string, + element: string + ): Promise { + const result = await this.execAgentBrowser(sessionName, [ + 'get', + 'attr', + element, + 'contenteditable' + ]) + return isExplicitContentEditableResult(result) + } + + private async fillExplicitContentEditable( + sessionName: string, + element: string, + value: string + ): Promise { + await this.execAgentBrowser(sessionName, ['focus', element]) + // Why: stdin avoids argv limits while keeping replacement atomic; chunked + // editor transactions can move focus and split one fill across controls. + await this.execAgentBrowser(sessionName, ['eval', '--stdin'], { + stdinText: focusedRichTextEditExpression(JSON.stringify(value), { selectAll: true }) + }) + } + private createPageUnavailableError(sessionName: string): BrowserError { return new BrowserError('browser_tab_not_found', pageUnavailableMessageForSession(sessionName)) } @@ -2536,6 +2615,11 @@ export class AgentBrowserBridge { if (session) { session.activeProcess = child } + if (execOptions?.stdinText !== undefined && child?.stdin) { + // Why: eval --stdin keeps paste-sized scripts out of argv on every platform. + child.stdin.on('error', () => {}) + child.stdin.end(execOptions.stdinText) + } }) } diff --git a/src/main/browser/browser-manager.test.ts b/src/main/browser/browser-manager.test.ts index c854d1f0acb..29d2c8f8edd 100644 --- a/src/main/browser/browser-manager.test.ts +++ b/src/main/browser/browser-manager.test.ts @@ -3,6 +3,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { + openPopupWithOriginBarMock, appGetPathMock, shellOpenExternalMock, browserWindowFromWebContentsMock, @@ -25,7 +26,8 @@ const { guestSetWindowOpenHandlerMock: vi.fn(), guestOpenDevToolsMock: vi.fn(), webContentsFromIdMock: vi.fn(), - screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })) + screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })), + openPopupWithOriginBarMock: vi.fn() })) vi.mock('electron', () => ({ @@ -48,6 +50,10 @@ vi.mock('electron', () => ({ } })) +vi.mock('./popup-origin-bar-window', () => ({ + openPopupWithOriginBar: openPopupWithOriginBarMock +})) + import { browserManager } from './browser-manager' describe('browserManager', () => { @@ -96,6 +102,7 @@ describe('browserManager', () => { guestSetWindowOpenHandlerMock.mockReset() guestOpenDevToolsMock.mockReset() webContentsFromIdMock.mockReset() + openPopupWithOriginBarMock.mockReset() browserManager.unregisterAll() browserManager.setDictationShortcutForwardingPredicate(null) browserManager.setSettingsResolver(() => ({})) @@ -162,37 +169,185 @@ describe('browserManager', () => { const handler = guestSetWindowOpenHandlerMock.mock.calls[0][0] as (details: { url: string features?: string - }) => { action: 'allow' | 'deny' } + }) => { + action: 'allow' | 'deny' + overrideBrowserWindowOptions?: unknown + createWindow?: unknown + } expect(handler({ url: 'about:blank' })).toMatchObject({ action: 'allow' }) - expect( - handler({ - url: 'https://example.com/login', - features: 'alwaysOnTop=yes,frame=no,fullscreen=yes,kiosk=yes,modal=yes,transparent=yes' - }) - ).toEqual({ - action: 'allow', - overrideBrowserWindowOptions: { - alwaysOnTop: false, - closable: true, - focusable: true, - frame: true, - fullscreen: false, - kiosk: false, - modal: false, - movable: true, - opacity: 1, - show: true, - simpleFullscreen: false, - skipTaskbar: false, - titleBarStyle: 'default', - transparent: false + const response = handler({ + url: 'https://example.com/login', + features: 'alwaysOnTop=yes,frame=no,fullscreen=yes,kiosk=yes,modal=yes,transparent=yes' + }) + expect(response.action).toBe('allow') + expect(response.overrideBrowserWindowOptions).toEqual({ + alwaysOnTop: false, + closable: true, + focusable: true, + frame: true, + fullscreen: false, + kiosk: false, + modal: false, + movable: true, + opacity: 1, + show: true, + simpleFullscreen: false, + skipTaskbar: false, + titleBarStyle: 'default', + transparent: false, + webPreferences: { + allowRunningInsecureContent: false, + contextIsolation: true, + nodeIntegration: false, + nodeIntegrationInSubFrames: false, + sandbox: true, + webviewTag: false } }) + // Why: the custom createWindow is what swaps the chrome-less native child + // for Orca's origin-bar window without losing the popup contents. + expect(typeof response.createWindow).toBe('function') expect(shellOpenExternalMock).not.toHaveBeenCalled() expect(rendererSendMock).not.toHaveBeenCalled() }) + it('keeps featureless window.open popups in-app for every disposition', () => { + // Regression guard for the reverted #8332: gating the allow on + // disposition === 'new-window' silently broke featureless window.open() + // OAuth flows (disposition 'foreground-tab'), whose returned handle must + // stay live. Disposition is a UX hint, not a trust signal. + const guest = { + id: 140, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + webContentsFromIdMock.mockReturnValue(guest) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'browser-1', + webContentsId: guest.id, + rendererWebContentsId + }) + + const handler = guestSetWindowOpenHandlerMock.mock.calls[0][0] as (details: { + url: string + frameName: string + features: string + disposition: string + }) => { action: 'allow' | 'deny' } + for (const disposition of ['foreground-tab', 'background-tab', 'new-window']) { + expect( + handler({ url: 'https://sso.example.com/auth', frameName: '', features: '', disposition }) + ).toMatchObject({ action: 'allow' }) + } + expect(shellOpenExternalMock).not.toHaveBeenCalled() + }) + + it('hosts allowed popups in an origin-bar window with inherited guest policies', () => { + const rendererSendMock = vi.fn() + const guestOnceMock = vi.fn() + const guest = { + id: 150, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + once: guestOnceMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + webContentsFromIdMock.mockImplementation((id: number) => { + if (id === guest.id) { + return guest + } + if (id === rendererWebContentsId) { + return { isDestroyed: vi.fn(() => false), send: rendererSendMock } + } + return null + }) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'browser-1', + webContentsId: guest.id, + rendererWebContentsId + }) + + const popupContents = { + id: 151, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'window'), + setBackgroundThrottling: vi.fn(), + setWindowOpenHandler: vi.fn(), + on: vi.fn(), + once: vi.fn(), + off: vi.fn() + } + const popupCloseMock = vi.fn() + const popupOnClosedMock = vi.fn() + openPopupWithOriginBarMock.mockReturnValue({ + contentWebContents: popupContents, + close: popupCloseMock, + onClosed: popupOnClosedMock + }) + + const handler = guestSetWindowOpenHandlerMock.mock.calls[0][0] as (details: { + url: string + }) => { + action: string + createWindow: (options: Record) => unknown + } + const response = handler({ url: 'https://sso.example.com/auth?code=SECRET' }) + const preCreatedContents = { id: 152 } + const options = { webContents: preCreatedContents, width: 500, height: 600 } + const returned = response.createWindow(options) + + expect(openPopupWithOriginBarMock).toHaveBeenCalledWith( + options, + 'https://sso.example.com/auth?code=SECRET' + ) + expect(returned).toBe(popupContents) + // did-create-window does not fire for createWindow-created children, so + // the popup must get guest policies (nav guards, recursive popup handling) + // attached directly here. + expect(popupContents.setWindowOpenHandler).toHaveBeenCalledTimes(1) + expect(popupContents.setBackgroundThrottling).toHaveBeenCalledWith(false) + // The renderer notice carries only the sanitized origin, never the URL. + expect(rendererSendMock).toHaveBeenCalledWith('browser:popup', { + browserPageId: 'browser-1', + origin: 'https://sso.example.com', + action: 'opened-in-orca' + }) + + // Opener-lifecycle parity: destroying the owning guest closes the popup. + const destroyedCall = guestOnceMock.mock.calls.find(([event]) => event === 'destroyed') + expect(destroyedCall).toBeDefined() + ;(destroyedCall as [string, () => void])[1]() + expect(popupCloseMock).toHaveBeenCalledTimes(1) + + // Popup windows opened by the popup itself keep the owner context, so the + // recursive handler still routes to the owning browser tab. + const popupHandler = popupContents.setWindowOpenHandler.mock.calls[0][0] as (details: { + url: string + }) => { action: string } + openPopupWithOriginBarMock.mockReturnValue({ + contentWebContents: { ...popupContents, id: 153 }, + close: vi.fn(), + onClosed: vi.fn() + }) + expect(popupHandler({ url: 'https://sso.example.com/step2' })).toMatchObject({ + action: 'allow' + }) + }) + it('blocks unsafe popup URLs for registered guests', () => { const rendererSendMock = vi.fn() const guest = { diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index 18cddd8c78c..acd404f91ee 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -38,6 +38,7 @@ import { setupGuestShortcutForwarding } from './browser-guest-ui' import { ANTI_DETECTION_SCRIPT } from './anti-detection' +import { openPopupWithOriginBar, type PopupChildWindowOptions } from './popup-origin-bar-window' import { cleanElectronUserAgent } from './browser-session-ua' import type { BrowserViewportOverride } from '../../shared/types' import { @@ -160,7 +161,18 @@ const SAFE_POPUP_WINDOW_OPTIONS = { simpleFullscreen: false, skipTaskbar: false, titleBarStyle: 'default', - transparent: false + transparent: false, + // Why: applied by Electron when it creates the popup's WebContents, before + // createWindow runs. Feature strings and opener inheritance must not be able + // to relax the child's process isolation. + webPreferences: { + allowRunningInsecureContent: false, + contextIsolation: true, + nodeIntegration: false, + nodeIntegrationInSubFrames: false, + sandbox: true, + webviewTag: false + } } satisfies Electron.BrowserWindowConstructorOptions type ActiveDownload = { @@ -637,7 +649,15 @@ export class BrowserManager { if (browserTabId && canOpenAsChild) { // Why: OAuth may request ordinary size/position features, but browser // content must not create deceptive or inescapable native chrome. - return { action: 'allow', overrideBrowserWindowOptions: SAFE_POPUP_WINDOW_OPTIONS } + return { + action: 'allow', + overrideBrowserWindowOptions: SAFE_POPUP_WINDOW_OPTIONS, + // Why: a default child window has no address bar, so users cannot + // verify a popup's destination. Host it in an Orca window with an + // origin bar while keeping the shared session + window.opener. + createWindow: (options: PopupChildWindowOptions) => + this.createPopupChildWindowWithOriginBar(guest, url, options) + } } else if (externalUrl) { // Why: a target=_blank click on a Kagi search result page produces a // popup URL that still contains the bearer token; redact before @@ -730,6 +750,34 @@ export class BrowserManager { }) } + private createPopupChildWindowWithOriginBar( + openerGuest: Electron.WebContents, + targetUrl: string, + options: PopupChildWindowOptions + ): Electron.WebContents { + const popup = openPopupWithOriginBar(options, targetUrl) + // Why: Electron does not emit did-create-window for createWindow-created + // children, so the opener's policies and routing context attach here. + this.attachGuestPolicies( + popup.contentWebContents, + this.resolvePopupOwnerContext(openerGuest.id) + ) + this.forwardOrQueuePopupEvent(openerGuest.id, { + origin: safeOrigin(targetUrl), + action: 'opened-in-orca' + }) + // Why: parity with Electron's default child-window lifecycle — closing the + // owning browser tab must not leave orphaned session-bearing popups. + const closePopupWithOpener = (): void => popup.close() + openerGuest.once('destroyed', closePopupWithOpener) + popup.onClosed(() => { + if (!openerGuest.isDestroyed()) { + openerGuest.off('destroyed', closePopupWithOpener) + } + }) + return popup.contentWebContents + } + private retireStaleGuestWebContents(previousWebContentsId: number): void { // Why: a browser page can re-register with a new guest id after Chromium // swaps renderer processes. Late events from the dead guest must stop diff --git a/src/main/browser/popup-origin-bar-window.test.ts b/src/main/browser/popup-origin-bar-window.test.ts new file mode 100644 index 00000000000..3e816e74b07 --- /dev/null +++ b/src/main/browser/popup-origin-bar-window.test.ts @@ -0,0 +1,336 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type Handler = (...args: unknown[]) => void + +type FakeWebContents = { + on: ReturnType + once: ReturnType + off: ReturnType + executeJavaScript: ReturnType + loadURL: ReturnType + close: ReturnType + isDestroyed: ReturnType + emit: (event: string, ...args: unknown[]) => void +} + +const { fakeElectron } = vi.hoisted(() => { + function createFakeWebContents(): FakeWebContents { + const handlers = new Map() + let destroyed = false + const add = (event: string, handler: Handler): void => { + handlers.set(event, [...(handlers.get(event) ?? []), handler]) + } + return { + on: vi.fn(add), + once: vi.fn(add), + off: vi.fn((event: string, handler: Handler) => { + handlers.set( + event, + (handlers.get(event) ?? []).filter((h) => h !== handler) + ) + }), + executeJavaScript: vi.fn(() => Promise.resolve()), + loadURL: vi.fn(() => Promise.resolve()), + close: vi.fn(), + // Mirrors real Electron: isDestroyed() is already true inside a + // 'destroyed' handler, which is what the double-close guard relies on. + isDestroyed: vi.fn(() => destroyed), + emit: (event: string, ...args: unknown[]) => { + if (event === 'destroyed') { + destroyed = true + } + // off() replaces the stored array, so iterating the fetched one is safe. + for (const handler of handlers.get(event) ?? []) { + handler(...args) + } + } + } + } + + class FakeWebContentsView { + static instances: FakeWebContentsView[] = [] + options: { webContents?: FakeWebContents; webPreferences?: unknown } + webContents: FakeWebContents + setBounds = vi.fn() + constructor(options: { webContents?: FakeWebContents; webPreferences?: unknown }) { + // Why: Electron rejects explicit undefined instead of treating it as + // omitted, which the popup fallback path depends on. + if (Object.hasOwn(options, 'webContents') && options.webContents === undefined) { + throw new TypeError('options.webContents must be a WebContents') + } + this.options = options + this.webContents = options.webContents ?? createFakeWebContents() + FakeWebContentsView.instances.push(this) + } + } + + class FakeBaseWindow { + static instances: FakeBaseWindow[] = [] + options: Record + private handlers = new Map() + contentView = { addChildView: vi.fn() } + setTitle = vi.fn() + isDestroyed = vi.fn(() => false) + close = vi.fn(() => this.emit('closed')) + constructor(options: Record) { + this.options = options + FakeBaseWindow.instances.push(this) + } + on(event: string, handler: Handler): void { + this.handlers.set(event, [...(this.handlers.get(event) ?? []), handler]) + } + once(event: string, handler: Handler): void { + this.on(event, handler) + } + getContentBounds(): { x: number; y: number; width: number; height: number } { + return { + x: 0, + y: 0, + width: this.options.width as number, + height: this.options.height as number + } + } + emit(event: string, ...args: unknown[]): void { + for (const handler of this.handlers.get(event) ?? []) { + handler(...args) + } + } + } + + return { + fakeElectron: { createFakeWebContents, FakeWebContentsView, FakeBaseWindow } + } +}) + +vi.mock('electron', () => ({ + BaseWindow: fakeElectron.FakeBaseWindow, + WebContentsView: fakeElectron.FakeWebContentsView +})) + +import { + describePopupOrigin, + openPopupWithOriginBar, + POPUP_ORIGIN_BAR_HEIGHT +} from './popup-origin-bar-window' + +const { createFakeWebContents, FakeWebContentsView, FakeBaseWindow } = fakeElectron + +function lastWindow(): InstanceType { + const instance = FakeBaseWindow.instances.at(-1) + if (!instance) { + throw new Error('no BaseWindow was constructed') + } + return instance +} + +// View construction order in openPopupWithOriginBar: origin bar first, content second. +function lastViews(): { + bar: InstanceType + content: InstanceType +} { + const bar = FakeWebContentsView.instances.at(-2) + const content = FakeWebContentsView.instances.at(-1) + if (!bar || !content) { + throw new Error('expected an origin bar view and a content view') + } + return { bar, content } +} + +beforeEach(() => { + FakeBaseWindow.instances = [] + FakeWebContentsView.instances = [] +}) + +describe('describePopupOrigin', () => { + it('reduces URLs to origin only', () => { + expect(describePopupOrigin('https://accounts.example.com/oauth?code=SECRET')).toEqual({ + label: 'https://accounts.example.com', + insecure: false + }) + }) + + it('flags plain http to remote hosts as insecure', () => { + expect(describePopupOrigin('http://phish.example.net/login')).toEqual({ + label: 'http://phish.example.net', + insecure: true + }) + }) + + it('treats loopback http as secure', () => { + expect(describePopupOrigin('http://localhost:3000/callback').insecure).toBe(false) + expect(describePopupOrigin('http://127.0.0.1:8080/').insecure).toBe(false) + expect(describePopupOrigin('http://[::1]:8080/').insecure).toBe(false) + expect(describePopupOrigin('http://app.localhost/callback').insecure).toBe(false) + }) + + it('labels about:blank popups', () => { + expect(describePopupOrigin('about:blank')).toEqual({ label: 'about:blank', insecure: false }) + }) + + it('falls back to unknown for unparseable URLs', () => { + expect(describePopupOrigin('not a url')).toEqual({ label: 'unknown', insecure: true }) + }) +}) + +describe('openPopupWithOriginBar', () => { + it('adopts the pre-created popup contents so window.opener and the session survive', () => { + const adopted = createFakeWebContents() + const webPreferences = { partition: 'persist:browser' } + const popup = openPopupWithOriginBar( + { webContents: adopted as never, webPreferences }, + 'https://example.com/login' + ) + + const { content } = lastViews() + expect(content.options.webContents).toBe(adopted) + expect(content.options.webPreferences).toBe(webPreferences) + expect(popup.contentWebContents).toBe(adopted) + // Chromium already drives the adopted contents' navigation. + expect(adopted.loadURL).not.toHaveBeenCalled() + }) + + it('loads the target itself only when no pre-created contents were provided', () => { + const popup = openPopupWithOriginBar({}, 'https://example.com/login') + expect(lastViews().content.options).not.toHaveProperty('webContents') + expect(popup.contentWebContents.loadURL).toHaveBeenCalledWith('https://example.com/login') + }) + + it('reserves an origin-bar strip above the requested content size', () => { + openPopupWithOriginBar( + { webContents: createFakeWebContents() as never, width: 500, height: 400 }, + 'https://example.com/' + ) + + expect(lastWindow().options).toMatchObject({ + width: 500, + height: 400 + POPUP_ORIGIN_BAR_HEIGHT + }) + const { bar, content } = lastViews() + expect(bar.setBounds).toHaveBeenCalledWith({ + x: 0, + y: 0, + width: 500, + height: POPUP_ORIGIN_BAR_HEIGHT + }) + expect(content.setBounds).toHaveBeenCalledWith({ + x: 0, + y: POPUP_ORIGIN_BAR_HEIGHT, + width: 500, + height: 400 + }) + }) + + it('keeps the origin bar isolated with locked-down webPreferences', () => { + openPopupWithOriginBar( + { webContents: createFakeWebContents() as never }, + 'https://example.com/' + ) + const { bar } = lastViews() + expect(bar.options.webPreferences).toEqual({ + contextIsolation: true, + nodeIntegration: false, + sandbox: true + }) + }) + + it('renders only the origin in the bar, never path or query', () => { + openPopupWithOriginBar( + { webContents: createFakeWebContents() as never }, + 'https://accounts.example.com/oauth?code=SECRET' + ) + const { bar } = lastViews() + bar.webContents.emit('did-finish-load') + + expect(bar.webContents.executeJavaScript).toHaveBeenCalledTimes(1) + const script = bar.webContents.executeJavaScript.mock.calls[0][0] as string + expect(script).toContain('"https://accounts.example.com"') + expect(script).not.toContain('SECRET') + expect(script).not.toContain('/oauth') + expect(lastWindow().options.title).toBe('https://accounts.example.com') + }) + + it('updates the origin bar and title when the popup navigates', () => { + const adopted = createFakeWebContents() + openPopupWithOriginBar({ webContents: adopted as never }, 'about:blank') + const { bar } = lastViews() + bar.webContents.emit('did-finish-load') + bar.webContents.executeJavaScript.mockClear() + + adopted.emit('did-navigate', {}, 'http://phish.example.net/login?token=SECRET') + + const script = bar.webContents.executeJavaScript.mock.calls[0][0] as string + expect(script).toContain("classList.toggle('insecure', true)") + expect(script).toContain('"http://phish.example.net"') + expect(script).not.toContain('SECRET') + expect(lastWindow().setTitle).toHaveBeenCalledWith('http://phish.example.net') + }) + + it('shows the page title in the native title bar but resets to origin on navigation', () => { + const adopted = createFakeWebContents() + openPopupWithOriginBar( + { webContents: adopted as never }, + 'https://accounts.example.com/oauth?code=SECRET' + ) + // Until the page supplies a title, the window title is the origin. + expect(lastWindow().options.title).toBe('https://accounts.example.com') + + adopted.emit('page-title-updated', {}, 'Sign in to Example') + expect(lastWindow().setTitle).toHaveBeenLastCalledWith('Sign in to Example') + + // A stale title must not survive a cross-origin navigation. + adopted.emit('did-navigate', {}, 'https://evil.example.net/') + expect(lastWindow().setTitle).toHaveBeenLastCalledWith('https://evil.example.net') + }) + + it('closes the window when the popup content is destroyed, without re-closing the contents', () => { + const adopted = createFakeWebContents() + openPopupWithOriginBar({ webContents: adopted as never }, 'https://example.com/') + + adopted.emit('destroyed') + + expect(lastWindow().close).toHaveBeenCalled() + // The window's closed handler must not call close() on already-destroyed + // contents — that throws in real Electron. + expect(adopted.close).not.toHaveBeenCalled() + }) + + it('re-asserts the origin when the popup finishes loading', () => { + const adopted = createFakeWebContents() + openPopupWithOriginBar({ webContents: adopted as never }, 'https://example.com/login') + const { bar } = lastViews() + bar.webContents.emit('did-finish-load') + bar.webContents.executeJavaScript.mockClear() + + adopted.emit('did-finish-load') + + expect(bar.webContents.executeJavaScript).toHaveBeenCalledTimes(1) + expect(bar.webContents.executeJavaScript.mock.calls[0][0]).toContain('"https://example.com"') + }) + + it('elides the start of long origins so the registrable domain stays visible', () => { + openPopupWithOriginBar( + { webContents: createFakeWebContents() as never }, + 'https://example.com/' + ) + const { bar } = lastViews() + const dataUrl = bar.webContents.loadURL.mock.calls[0][0] as string + const html = decodeURIComponent(dataUrl.replace('data:text/html;charset=utf-8,', '')) + // rtl clip container ellipsizes the left; the isolated ltr bdi keeps the + // origin's own characters (host, port) in normal order. + expect(html).toContain('') + expect(html).toMatch(/#origin-clip\s*{[^}]*direction:\s*rtl/) + expect(html).toMatch(/#origin\s*{[^}]*direction:\s*ltr/) + }) + + it('closes the popup content and notifies listeners when the window closes', () => { + const adopted = createFakeWebContents() + const onClosed = vi.fn() + const popup = openPopupWithOriginBar({ webContents: adopted as never }, 'https://example.com/') + popup.onClosed(onClosed) + + popup.close() + + expect(adopted.close).toHaveBeenCalledTimes(1) + expect(onClosed).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/browser/popup-origin-bar-window.ts b/src/main/browser/popup-origin-bar-window.ts new file mode 100644 index 00000000000..c9ac057c177 --- /dev/null +++ b/src/main/browser/popup-origin-bar-window.ts @@ -0,0 +1,229 @@ +import { BaseWindow, WebContentsView } from 'electron' + +// Why: Electron passes the pre-created popup WebContents through the +// createWindow options (present at runtime, absent from the published d.ts). +// Adopting it — instead of constructing fresh contents — is what preserves +// window.opener and the inherited session that OAuth/SSO popups depend on. +export type PopupChildWindowOptions = Electron.BrowserWindowConstructorOptions & { + webContents?: Electron.WebContents +} + +export type PopupOriginBarWindow = { + contentWebContents: Electron.WebContents + close: () => void + onClosed: (listener: () => void) => void +} + +export const POPUP_ORIGIN_BAR_HEIGHT = 34 + +const DEFAULT_POPUP_CONTENT_WIDTH = 800 +const DEFAULT_POPUP_CONTENT_HEIGHT = 600 +const MIN_POPUP_CONTENT_WIDTH = 360 +const MIN_POPUP_CONTENT_HEIGHT = 200 + +// Why: http on loopback is a secure context (local OAuth callback servers are +// common); only flag plain http to a real remote host. +function isLoopbackHost(hostname: string): boolean { + return ( + hostname === 'localhost' || + hostname.endsWith('.localhost') || + hostname === '127.0.0.1' || + hostname === '[::1]' + ) +} + +// Why: the bar must show only the origin — popup URLs routinely carry OAuth +// codes and one-time tokens in path/query that must never reach UI surfaces. +export function describePopupOrigin(rawUrl: string): { label: string; insecure: boolean } { + try { + const parsed = new URL(rawUrl) + if (parsed.origin !== 'null') { + return { + label: parsed.origin, + insecure: parsed.protocol === 'http:' && !isLoopbackHost(parsed.hostname) + } + } + if (parsed.protocol === 'about:') { + return { label: 'about:blank', insecure: false } + } + return { label: parsed.protocol, insecure: false } + } catch { + return { label: 'unknown', insecure: true } + } +} + +// Colors mirror the canonical tokens in src/renderer/src/assets/main.css +// (--background/--foreground/--border/--destructive); a data: URL page cannot +// import that stylesheet, so the values are inlined per theme here. +const ORIGIN_BAR_HTML = `Not secure` + +function clampPopupContentSize(options: PopupChildWindowOptions): { + width: number + height: number +} { + return { + width: Math.max(MIN_POPUP_CONTENT_WIDTH, options.width ?? DEFAULT_POPUP_CONTENT_WIDTH), + height: Math.max(MIN_POPUP_CONTENT_HEIGHT, options.height ?? DEFAULT_POPUP_CONTENT_HEIGHT) + } +} + +/** + * Hosts a guest-opened popup inside an Orca-built window whose top strip is a + * separate, Orca-controlled WebContentsView showing the popup's current + * origin. A default Electron child window has no address bar, so arbitrary + * web content could open windows whose destination the user cannot verify. + */ +export function openPopupWithOriginBar( + options: PopupChildWindowOptions, + initialUrl: string +): PopupOriginBarWindow { + const { width, height } = clampPopupContentSize(options) + const initialOrigin = describePopupOrigin(initialUrl) + const window = new BaseWindow({ + width, + height: height + POPUP_ORIGIN_BAR_HEIGHT, + // Why: window.open features request a content size; without this the + // native frame eats into the popup's viewport. + useContentSize: true, + ...(typeof options.x === 'number' && typeof options.y === 'number' + ? { x: options.x, y: options.y } + : {}), + minWidth: MIN_POPUP_CONTENT_WIDTH, + minHeight: MIN_POPUP_CONTENT_HEIGHT + POPUP_ORIGIN_BAR_HEIGHT, + title: initialOrigin.label + }) + + // Why: the origin bar renders only Orca's own data: URL and must stay + // isolated from the (arbitrary) popup content below it. + const originBarView = new WebContentsView({ + webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true } + }) + const contentView = new WebContentsView({ + // Why: Electron rejects an explicitly undefined webContents; omitting it + // lets WebContentsView create contents for Cmd/Ctrl-click popups. + ...(options.webContents === undefined ? {} : { webContents: options.webContents }), + webPreferences: options.webPreferences + }) + window.contentView.addChildView(contentView) + window.contentView.addChildView(originBarView) + + const layoutViews = (): void => { + const bounds = window.getContentBounds() + originBarView.setBounds({ x: 0, y: 0, width: bounds.width, height: POPUP_ORIGIN_BAR_HEIGHT }) + contentView.setBounds({ + x: 0, + y: POPUP_ORIGIN_BAR_HEIGHT, + width: bounds.width, + height: Math.max(0, bounds.height - POPUP_ORIGIN_BAR_HEIGHT) + }) + } + window.on('resize', layoutViews) + // Why: HTML5 fullscreen makes the whole window fullscreen. resize covers + // this on macOS, but re-pin on the explicit events too so the origin bar + // provably stays above fullscreen content on every platform. + window.on('enter-full-screen', layoutViews) + window.on('leave-full-screen', layoutViews) + layoutViews() + + const contentWebContents = contentView.webContents + let currentUrl = initialUrl + const renderOrigin = (): void => { + const { label, insecure } = describePopupOrigin(currentUrl) + // Why: origin is the title only until the page supplies one — the bar + // below stays the trust surface, so the native title bar can show the + // page title (Chrome popup behavior) instead of doubling the origin. + // Re-asserting on navigation stops a stale title outliving its origin. + if (!window.isDestroyed()) { + window.setTitle(label) + } + // Why: textContent + JSON encoding — the URL is attacker-controlled and + // must never be interpolated into the bar's markup. + void originBarView.webContents + .executeJavaScript( + `document.body.classList.toggle('insecure', ${insecure ? 'true' : 'false'});` + + `document.getElementById('origin').textContent = ${JSON.stringify(label)};` + ) + .catch(() => {}) + } + originBarView.webContents.once('did-finish-load', renderOrigin) + void originBarView.webContents.loadURL( + `data:text/html;charset=utf-8,${encodeURIComponent(ORIGIN_BAR_HTML)}` + ) + + const handleDidNavigate = (_event: Electron.Event, url: string): void => { + currentUrl = url + renderOrigin() + } + contentWebContents.on('did-navigate', handleDidNavigate) + // Why: origin writes fail silently if the bar is mid-load; re-asserting at + // load completion means a dropped write can never leave a stale origin up + // for the lifetime of the page. + contentWebContents.on('did-finish-load', renderOrigin) + const handlePageTitleUpdated = (_event: Electron.Event, title: string): void => { + if (!window.isDestroyed() && title) { + window.setTitle(title) + } + } + contentWebContents.on('page-title-updated', handlePageTitleUpdated) + + // Why: with no adopted contents there is no Chromium-driven navigation for + // this popup, so load the target ourselves (opener handle is already gone). + if (!options.webContents) { + void contentWebContents.loadURL(initialUrl).catch(() => {}) + } + + const closedListeners: (() => void)[] = [] + const handleContentDestroyed = (): void => { + if (!window.isDestroyed()) { + window.close() + } + } + contentWebContents.once('destroyed', handleContentDestroyed) + window.once('closed', () => { + if (!contentWebContents.isDestroyed()) { + contentWebContents.off('destroyed', handleContentDestroyed) + contentWebContents.off('did-navigate', handleDidNavigate) + contentWebContents.off('did-finish-load', renderOrigin) + contentWebContents.off('page-title-updated', handlePageTitleUpdated) + // Why: close() (not destroy) so the page's unload handlers run — OAuth + // pages often notify the opener from unload. + contentWebContents.close() + } + for (const listener of closedListeners) { + listener() + } + }) + + return { + contentWebContents, + close: (): void => { + if (!window.isDestroyed()) { + window.close() + } + }, + onClosed: (listener: () => void): void => { + closedListeners.push(listener) + } + } +} diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index 6400efe7357..1c07cf38942 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -247,8 +247,8 @@ describe('ClaudeHookService.installRemote', () => { expect(settings).toBeTruthy() const parsed = JSON.parse(settings!) // Why: every load-bearing event must be present and point at the - // remote-shaped script path with the `if [ -x ... ]; then ... fi` - // wrapper applied. Drift in any of these is a real bug — Claude + // remote-shaped script path with the guarded launcher applied. Drift in + // any of these is a real bug — Claude // Code rejects unknown shapes silently and the agent-hooks pipeline // goes dark. for (const event of [ @@ -266,7 +266,7 @@ describe('ClaudeHookService.installRemote', () => { expect(parsed.hooks[event]).toBeTruthy() const cmd = parsed.hooks[event][0].hooks[0].command as string expect(cmd).toContain('/home/dev/.orca/agent-hooks/claude-hook.sh') - expect(cmd).toMatch(/^if \[ -x /) + expect(cmd).toMatch(/^if \[ -f /) } // Managed script body const script = fs.files.get('/home/dev/.orca/agent-hooks/claude-hook.sh') @@ -355,7 +355,7 @@ describe('OpenClaudeHookService-compatible install', () => { const command = parsed.hooks[event][0].hooks[0].command as string expect(isOpenClaudeManagedCommand(command)).toBe(true) if (process.platform !== 'win32') { - expect(command).toMatch(/^if \[ -x /) + expect(command).toMatch(/^if \[ -f /) } } expect( diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index 758daec3890..980c9604bec 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -11,6 +11,12 @@ import { writeHooksJsonRemote, writeManagedScriptRemote } from '../agent-hooks/installer-utils-remote' +import { + buildPosixHookPayloadCapture, + buildWindowsHookEnvironmentGuardLines, + buildWindowsHookStdinDrainEpilogue, + WINDOWS_HOOK_STDIN_DRAIN_LABEL +} from '../agent-hooks/hook-stdin-contract' import { applyManagedHooks, CLAUDE_EVENTS, @@ -50,7 +56,7 @@ function getManagedScript( ? [ // Why: Devin imports .claude hooks by default. Skip Orca's managed // Claude hook there so status posts stay attributed to Devin. - 'if not "%DEVIN_PROJECT_DIR%"=="" exit /b 0' + `if not "%DEVIN_PROJECT_DIR%"=="" goto :${WINDOWS_HOOK_STDIN_DRAIN_LABEL}` ] : []), // Why: the endpoint file holds the *live* port/token for this Orca @@ -60,9 +66,7 @@ function getManagedScript( // reaches the current server. Falls through to PTY env if the file // is missing (first run / pre-endpoint-file / running outside Orca). 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', - 'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0', - 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0', - 'if "%ORCA_PANE_KEY%"=="" exit /b 0', + ...buildWindowsHookEnvironmentGuardLines(), // Why: post via curl.exe, not a second PowerShell. Claude's launcher is // already an encoded PowerShell command (Git Bash needs it to survive // spaces); a PowerShell post on top of that meant two interpreter @@ -70,12 +74,14 @@ function getManagedScript( // curl works the same here as for the POSIX/Codex hooks. buildWindowsAgentHookCurlPostCommand('claude'), 'exit /b 0', + ...buildWindowsHookStdinDrainEpilogue(), '' ].join('\r\n') } return [ '#!/bin/sh', + ...buildPosixHookPayloadCapture(), ...(options.skipWhenDevinImportsClaude ? [ // Why: Devin imports .claude hooks by default. Skip Orca's managed @@ -105,10 +111,6 @@ function getManagedScript( 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', ' exit 0', 'fi', - 'payload=$(cat)', - 'if [ -z "$payload" ]; then', - ' exit 0', - 'fi', // Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX // shell is not safe once a path contains quotes or newlines. Post the raw // hook payload plus metadata as form fields and let the receiver parse it. diff --git a/src/main/cli/cli-installer.test.ts b/src/main/cli/cli-installer.test.ts index 4ce919cef6b..a42805313c0 100644 --- a/src/main/cli/cli-installer.test.ts +++ b/src/main/cli/cli-installer.test.ts @@ -346,6 +346,88 @@ describe('CliInstaller', () => { expect(userPath).not.toContain(join(fixture.root, 'Programs', 'Orca', 'bin')) }) + it.each(['UnauthorizedAccessException', 'SecurityException'])( + 'rejects with a friendly message for Windows PATH denial: %s', + async (permissionMarker) => { + const fixture = await makeFixture() + const installPath = join(fixture.root, 'Programs', 'Orca', 'bin', 'orca.cmd') + const installer = new CliInstaller({ + platform: 'win32', + isPackaged: false, + userDataPath: fixture.userDataPath, + execPath: 'C:\\Users\\me\\AppData\\Local\\Orca\\Orca.exe', + appPath: fixture.appPath, + commandPathOverride: installPath, + userPathReader: async () => 'C:\\Windows\\System32', + userPathWriter: async () => { + // The .NET error id survives localized or mojibake PowerShell output. + const error = new Error( + `Command failed: powershell -NoProfile -Command [Environment]::SetEnvironmentVariable('Path', '...', 'User')\nFullyQualifiedErrorId : ${permissionMarker},Microsoft.PowerShell.Commands` + ) + Object.assign(error, { code: 1 }) + throw error + } + }) + + const result = installer.install() + await expect(result).rejects.toThrow(/access denied|Group Policy|manually/i) + await expect(result).rejects.not.toThrow(/Command failed: powershell/) + await expect(result).rejects.toMatchObject({ + cause: expect.objectContaining({ + message: expect.stringContaining(permissionMarker) + }) + }) + } + ) + + it('skips the Windows PATH write when removing an absent entry', async () => { + const fixture = await makeFixture() + const userPathWriter = vi.fn() + const installer = new CliInstaller({ + platform: 'win32', + isPackaged: false, + userDataPath: fixture.userDataPath, + execPath: 'C:\\Users\\me\\AppData\\Local\\Orca\\Orca.exe', + appPath: fixture.appPath, + commandPathOverride: join(fixture.root, 'Programs', 'Orca', 'bin', 'orca.cmd'), + userPathReader: async () => 'C:\\Windows\\System32', + userPathWriter + }) + + await expect(installer.remove()).resolves.toMatchObject({ state: 'not_installed' }) + expect(userPathWriter).not.toHaveBeenCalled() + }) + + it.each([ + ['PowerShell timeout', 'Windows PATH command timed out after 5000ms.'], + [ + 'generic PowerShell method failure', + "Command failed: powershell -NoProfile -Command [Environment]::SetEnvironmentVariable('Path', '...', 'User')\nCategoryInfo : NotSpecified: (:) [], MethodInvocationException\nFullyQualifiedErrorId : MethodInvocationException" + ] + ])( + 'propagates a non-permission Windows PATH write error unchanged: %s', + async (_name, message) => { + const fixture = await makeFixture() + const installPath = join(fixture.root, 'Programs', 'Orca', 'bin', 'orca.cmd') + const installer = new CliInstaller({ + platform: 'win32', + isPackaged: false, + userDataPath: fixture.userDataPath, + execPath: 'C:\\Users\\me\\AppData\\Local\\Orca\\Orca.exe', + appPath: fixture.appPath, + commandPathOverride: installPath, + userPathReader: async () => 'C:\\Windows\\System32', + userPathWriter: async () => { + throw new Error(message) + } + }) + + const result = installer.install() + await expect(result).rejects.toThrow(message) + await expect(result).rejects.not.toThrow(/Windows blocked updating your user PATH/) + } + ) + it('settles when the Windows PATH query hangs', async () => { vi.useFakeTimers() const fixture = await makeFixture() @@ -1092,12 +1174,12 @@ describe('CliInstaller', () => { } ) - it('resolves packaged Windows command path to resources/bin/orca.cmd', async () => { + it('resolves custom-install packaged Windows command path from resourcesPath', async () => { const fixture = await makeFixture() - const localAppDataPath = fixture.root - const resourcesPath = join(fixture.root, 'resources') + const localAppDataPath = join(fixture.root, 'AppData', 'Local') + const resourcesPath = join(fixture.root, 'D Custom Orca', 'resources') await mkdir(join(resourcesPath, 'bin'), { recursive: true }) - await writeFile(join(resourcesPath, 'bin', 'orca.cmd'), '@echo off\n', 'utf8') + await writeFile(join(resourcesPath, 'bin', 'orca.exe'), 'native launcher', 'utf8') const installer = new CliInstaller({ platform: 'win32', @@ -1105,24 +1187,22 @@ describe('CliInstaller', () => { resourcesPath, localAppDataPath, userDataPath: fixture.userDataPath, - execPath: join(localAppDataPath, 'Programs', 'Orca', 'Orca.exe'), + execPath: join(fixture.root, 'D Custom Orca', 'Orca.exe'), appPath: fixture.appPath, userPathReader: async () => null, userPathWriter: async () => {} }) const status = await installer.getStatus() - expect(status.commandPath).toBe( - join(localAppDataPath, 'Programs', 'Orca', 'resources', 'bin', 'orca.cmd') - ) + expect(status.commandPath).toBe(join(resourcesPath, 'bin', 'orca.exe')) }) it('does not overwrite the packaged Windows launcher while registering PATH', async () => { const fixture = await makeFixture() - const localAppDataPath = fixture.root - const resourcesPath = join(localAppDataPath, 'Programs', 'Orca', 'resources') - const bundledLauncher = join(resourcesPath, 'bin', 'orca.cmd') - const bundledContent = '@echo off\r\necho bundled-orca %*\r\n' + const localAppDataPath = join(fixture.root, 'AppData', 'Local') + const resourcesPath = join(fixture.root, 'D Custom Orca', 'resources') + const bundledLauncher = join(resourcesPath, 'bin', 'orca.exe') + const bundledContent = 'native launcher' await mkdir(dirname(bundledLauncher), { recursive: true }) await writeFile(bundledLauncher, bundledContent, 'utf8') @@ -1133,7 +1213,7 @@ describe('CliInstaller', () => { resourcesPath, localAppDataPath, userDataPath: fixture.userDataPath, - execPath: join(localAppDataPath, 'Programs', 'Orca', 'Orca.exe'), + execPath: join(fixture.root, 'D Custom Orca', 'Orca.exe'), appPath: fixture.appPath, userPathReader: async () => userPath, userPathWriter: async (value) => { diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index 6765d8249b1..c5f1286126b 100644 --- a/src/main/cli/cli-installer.ts +++ b/src/main/cli/cli-installer.ts @@ -185,7 +185,7 @@ export class CliInstaller { await this.installAppImageWrapper(status.commandPath, status.launcherPath) await this.removeLegacyLinuxCommandIfManaged(status.launcherPath) } else if (this.isWindowsPackagedBundledCommand(status.commandPath, status.launcherPath)) { - // Why: packaged Windows already ships resources/bin/orca.cmd. Registration + // Why: packaged Windows already ships resources/bin/orca.exe. Registration // only owns the user PATH entry; rewriting the asset makes it recurse. } else { // Why: mkdir stays here for the Windows wrapper path — the target dir is @@ -356,7 +356,9 @@ export class CliInstaller { } if (this.platform === 'win32') { - return join(this.localAppDataPath, 'Programs', 'Orca', 'resources', 'bin', 'orca.cmd') + // Why: NSIS /D installs can live outside LOCALAPPDATA. The packaged + // resources directory is the authoritative native launcher location. + return getBundledLauncherPath(this.platform, this.resourcesPath) } return null @@ -823,7 +825,7 @@ export class CliInstaller { return } entries.push(pathDirectory) - await this.userPathWriter(entries.join(';')) + await this.writeWindowsUserPathEntry(entries.join(';'), pathDirectory, 'add') } private async removeWindowsPathEntry(pathDirectory: string): Promise { @@ -831,10 +833,36 @@ export class CliInstaller { return } const current = await this.userPathReader() - const nextEntries = splitPathEntries('win32', current).filter( - (entry) => !samePathEntry('win32', entry, pathDirectory) - ) - await this.userPathWriter(nextEntries.join(';')) + const entries = splitPathEntries('win32', current) + const nextEntries = entries.filter((entry) => !samePathEntry('win32', entry, pathDirectory)) + if (nextEntries.length === entries.length) { + return + } + await this.writeWindowsUserPathEntry(nextEntries.join(';'), pathDirectory, 'remove') + } + + // Why: raw PowerShell errors reach the UI, so translate denied PATH writes + // while preserving the original diagnostic as the error cause. + private async writeWindowsUserPathEntry( + value: string, + pathDirectory: string, + action: 'add' | 'remove' + ): Promise { + try { + await this.userPathWriter(value) + } catch (error) { + if (!isWindowsUserPathPermissionError(error)) { + throw error + } + const guidance = + action === 'add' + ? `Add this folder to your PATH manually: ${pathDirectory}. Or run Orca as an administrator and try again.` + : `Remove this folder from your PATH manually: ${pathDirectory}. Or run Orca as an administrator and try again.` + throw new Error( + `Windows blocked updating your user PATH (access denied). This usually means your PATH environment variable is managed by Group Policy or your organization's device management. ${guidance}`, + { cause: error } + ) + } } } @@ -1038,6 +1066,26 @@ function isMissingError(error: unknown): boolean { ) } +// Why: localized permission errors retain these .NET/ACL markers even when +// their human-readable PowerShell text is mojibake. +function isWindowsUserPathPermissionError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + const stderr = + 'stderr' in error && typeof (error as { stderr?: unknown }).stderr === 'string' + ? (error as { stderr: string }).stderr + : '' + const haystack = `${error.message}\n${stderr}` + return ( + haystack.includes('UnauthorizedAccessException') || + haystack.includes('SecurityException') || + haystack.includes('Requested registry access is not allowed') || + haystack.includes('Access is denied') || + haystack.includes('Access to the registry key') + ) +} + function quoteShell(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'` } @@ -1137,7 +1185,7 @@ export function getBundledLauncherPath( return join(resourcesPath, 'bin', LINUX_COMMAND_NAME) } if (platform === 'win32') { - return join(resourcesPath, 'bin', 'orca.cmd') + return join(resourcesPath, 'bin', 'orca.exe') } return null } diff --git a/src/main/cli/keyed-promise-queue.test.ts b/src/main/cli/keyed-promise-queue.test.ts new file mode 100644 index 00000000000..a4a9aab5687 --- /dev/null +++ b/src/main/cli/keyed-promise-queue.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { getKeyedSerializedQueueTail, runKeyedSerializedOperation } from './keyed-promise-queue' + +describe('runKeyedSerializedOperation', () => { + it('propagates rejections to the caller but never through the stored tail', async () => { + const queues = new Map>() + const failing = runKeyedSerializedOperation(queues, 'key', async () => { + throw new Error('write failed') + }) + // Why: awaiting the tail is how reads barrier on writes; an unrelated + // failed write must not abort the reader (startup candidate discovery). + const tail = getKeyedSerializedQueueTail(queues, 'key') + + await expect(failing).rejects.toThrow('write failed') + await expect(tail).resolves.toBeUndefined() + await expect(runKeyedSerializedOperation(queues, 'key', async () => 'recovered')).resolves.toBe( + 'recovered' + ) + }) + + it('serializes operations per key and clears settled queues', async () => { + const queues = new Map>() + const events: string[] = [] + let release!: () => void + const first = runKeyedSerializedOperation(queues, 'a', async () => { + events.push('first-start') + await new Promise((resolve) => { + release = resolve + }) + events.push('first-end') + }) + const second = runKeyedSerializedOperation(queues, 'a', async () => { + events.push('second') + }) + const other = runKeyedSerializedOperation(queues, 'b', async () => { + events.push('other') + }) + + await other + expect(events).toEqual(['first-start', 'other']) + release() + await Promise.all([first, second]) + expect(events).toEqual(['first-start', 'other', 'first-end', 'second']) + await expect(getKeyedSerializedQueueTail(queues, 'a')).resolves.toBeUndefined() + expect(queues.size).toBe(0) + }) +}) diff --git a/src/main/cli/keyed-promise-queue.ts b/src/main/cli/keyed-promise-queue.ts new file mode 100644 index 00000000000..8fb7b8d56aa --- /dev/null +++ b/src/main/cli/keyed-promise-queue.ts @@ -0,0 +1,33 @@ +/** + * Serializes async operations per key. The stored queue tail never rejects, so + * callers may await it (e.g. read-after-write barriers) without inheriting an + * unrelated operation's failure; rejections still propagate to the enqueuer. + */ +export function runKeyedSerializedOperation( + queues: Map>, + key: string, + operation: () => Promise +): Promise { + const previous = queues.get(key) ?? Promise.resolve() + const current = previous.then(operation) + const queued = current.then( + () => undefined, + () => undefined + ) + queues.set(key, queued) + + const clear = (): void => { + if (queues.get(key) === queued) { + queues.delete(key) + } + } + queued.then(clear, clear) + return current +} + +export function getKeyedSerializedQueueTail( + queues: Map>, + key: string +): Promise { + return queues.get(key) ?? Promise.resolve() +} diff --git a/src/main/cli/linux-bare-orca-dispatcher.ts b/src/main/cli/linux-bare-orca-dispatcher.ts index 33c794b8150..3dc8df2906c 100644 --- a/src/main/cli/linux-bare-orca-dispatcher.ts +++ b/src/main/cli/linux-bare-orca-dispatcher.ts @@ -60,30 +60,41 @@ export async function installLinuxBareOrcaDispatcher( return { state: 'installed', dispatcherPath, target: resolved.target } } -function resolveDispatcherScript( +/** Bare-`orca` script that execs the Orca CLI: the stable AppImage when running + * from one, otherwise the bundled `orca-ide` launcher. Shared by the serve + * dispatcher and the managed-terminal PATH shim. */ +export function buildBareOrcaCliScript( resourcesPath: string, appImagePath: string | null ): { script: string; target: string } | null { if (appImagePath) { // Why: an AppImage mounts resources under an ephemeral FUSE path per launch, - // so the dispatcher must exec the stable outer AppImage — reuse the same + // so the script must exec the stable outer AppImage — reuse the same // wrapper CliInstaller installs for the AppImage command. - return { script: withMarker(buildAppImageCliWrapper(appImagePath)), target: appImagePath } + return { script: buildAppImageCliWrapper(appImagePath), target: appImagePath } } const launcher = getBundledLauncherPath('linux', resourcesPath) // Why: getBundledLauncherPath only joins the path; guard existence so we never - // write a dispatcher pointing at a missing launcher (which would fail at exec + // write a script pointing at a missing launcher (which would fail at exec // time with a confusing error instead of the command-not-found we fix). if (!launcher || !existsSync(launcher)) { return null } return { - script: `#!/usr/bin/env bash\n${DISPATCHER_MARKER}\nexec ${quoteShell(launcher)} "$@"\n`, + script: `#!/usr/bin/env bash\nexec ${quoteShell(launcher)} "$@"\n`, target: launcher } } +function resolveDispatcherScript( + resourcesPath: string, + appImagePath: string | null +): { script: string; target: string } | null { + const resolved = buildBareOrcaCliScript(resourcesPath, appImagePath) + return resolved && { script: withMarker(resolved.script), target: resolved.target } +} + function withMarker(script: string): string { const firstNewline = script.indexOf('\n') if (firstNewline === -1) { diff --git a/src/main/cli/linux-terminal-orca-cli-shim.test.ts b/src/main/cli/linux-terminal-orca-cli-shim.test.ts new file mode 100644 index 00000000000..81af17ba779 --- /dev/null +++ b/src/main/cli/linux-terminal-orca-cli-shim.test.ts @@ -0,0 +1,118 @@ +import { chmodSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { isPackaged: true } +})) + +import { ensureLinuxTerminalOrcaCliShimDir } from './linux-terminal-orca-cli-shim' + +const created: string[] = [] + +async function makeFixture(): Promise<{ userDataPath: string; resourcesPath: string }> { + const root = await mkdtemp(join(tmpdir(), 'orca-terminal-cli-shim-')) + created.push(root) + const resourcesPath = join(root, 'resources') + // The bundled orca-ide launcher must exist for the shim to be written. + mkdirSync(join(resourcesPath, 'bin'), { recursive: true }) + writeFileSync(join(resourcesPath, 'bin', 'orca-ide'), '#!/usr/bin/env bash\n', 'utf8') + return { userDataPath: join(root, 'user-data'), resourcesPath } +} + +afterEach(async () => { + await Promise.all(created.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) + +describe('ensureLinuxTerminalOrcaCliShimDir', () => { + it('writes an executable bare-orca shim that execs the bundled orca-ide launcher', async () => { + const { userDataPath, resourcesPath } = await makeFixture() + + const shimDir = ensureLinuxTerminalOrcaCliShimDir({ + userDataPath, + resourcesPath, + appImagePath: null + }) + + expect(shimDir).toBe(join(userDataPath, 'linux-orca-cli-shim')) + const content = readFileSync(join(shimDir!, 'orca'), 'utf8') + // Single-quoted so a resources path with shell metacharacters can't break out. + expect(content).toContain(`exec '${join(resourcesPath, 'bin', 'orca-ide')}' "$@"`) + const mode = statSync(join(shimDir!, 'orca')).mode & 0o777 + expect(mode & 0o111).not.toBe(0) + }) + + it('memoizes per userDataPath and re-asserts the exec bit for a stale shim', async () => { + const { userDataPath, resourcesPath } = await makeFixture() + const options = { userDataPath, resourcesPath, appImagePath: null } + + const first = ensureLinuxTerminalOrcaCliShimDir(options) + expect(first).not.toBeNull() + const shimPath = join(first!, 'orca') + chmodSync(shimPath, 0o644) + + // A distinct userData path is not memoized, so ensure runs again and heals + // the exec bit lost above only when it actually processes that path. + const second = ensureLinuxTerminalOrcaCliShimDir(options) + expect(second).toBe(first) + + const root = await mkdtemp(join(tmpdir(), 'orca-terminal-cli-shim-2-')) + created.push(root) + const otherUserData = join(root, 'user-data') + mkdirSync(join(otherUserData, 'linux-orca-cli-shim'), { recursive: true }) + writeFileSync(join(otherUserData, 'linux-orca-cli-shim', 'orca'), 'stale contents', 'utf8') + chmodSync(join(otherUserData, 'linux-orca-cli-shim', 'orca'), 0o644) + + const healed = ensureLinuxTerminalOrcaCliShimDir({ + userDataPath: otherUserData, + resourcesPath, + appImagePath: null + }) + expect(healed).not.toBeNull() + const healedPath = join(healed!, 'orca') + expect(readFileSync(healedPath, 'utf8')).toContain('orca-ide') + expect(statSync(healedPath).mode & 0o111).not.toBe(0) + }) + + it('execs the stable AppImage (not the ephemeral mount) when running from an AppImage', async () => { + const { userDataPath, resourcesPath } = await makeFixture() + const appImagePath = join(userDataPath, 'Applications', 'Orca.AppImage') + + const shimDir = ensureLinuxTerminalOrcaCliShimDir({ + userDataPath, + resourcesPath, + appImagePath + }) + + const content = readFileSync(join(shimDir!, 'orca'), 'utf8') + expect(content).toContain(appImagePath) + expect(content).not.toContain(resourcesPath) + }) + + it('returns null (and does not memoize) when the bundled launcher is missing', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-terminal-cli-shim-missing-')) + created.push(root) + const userDataPath = join(root, 'user-data') + + const missing = ensureLinuxTerminalOrcaCliShimDir({ + userDataPath, + resourcesPath: join(root, 'resources'), + appImagePath: null + }) + expect(missing).toBeNull() + + // Once the launcher exists (e.g. later probe with real resources), the same + // userData path succeeds — proving failures are not cached. + const resourcesPath = join(root, 'resources') + mkdirSync(join(resourcesPath, 'bin'), { recursive: true }) + writeFileSync(join(resourcesPath, 'bin', 'orca-ide'), '#!/usr/bin/env bash\n', 'utf8') + const recovered = ensureLinuxTerminalOrcaCliShimDir({ + userDataPath, + resourcesPath, + appImagePath: null + }) + expect(recovered).toBe(join(userDataPath, 'linux-orca-cli-shim')) + }) +}) diff --git a/src/main/cli/linux-terminal-orca-cli-shim.ts b/src/main/cli/linux-terminal-orca-cli-shim.ts new file mode 100644 index 00000000000..56f38df15ce --- /dev/null +++ b/src/main/cli/linux-terminal-orca-cli-shim.ts @@ -0,0 +1,70 @@ +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { buildBareOrcaCliScript } from './linux-bare-orca-dispatcher' + +const SHIM_DIR_NAME = 'linux-orca-cli-shim' + +// Why: rewriting the shim on every PTY spawn is wasted fs work; the target only +// changes with the install itself, so one successful write per process is enough. +// Failures are NOT cached so a transient fs error retries on the next spawn. +const ensuredShimDirs = new Map() + +export type LinuxTerminalOrcaCliShimOptions = { + userDataPath: string + /** Test seam — defaults to the packaged resources root. */ + resourcesPath?: string | null + /** Test seam — defaults to $APPIMAGE (set only when running from an AppImage). */ + appImagePath?: string | null +} + +// Why: on Linux the CLI installs as `orca-ide` so it never shadows the GNOME +// Orca screen reader at /usr/bin/orca — but agent-facing surfaces (skills, +// dispatch preambles, CLI hints) all invoke bare `orca`, so on stock Ubuntu an +// agent inside an Orca terminal would launch the screen reader instead +// (stablyai/orca#7904). Prepending this userData-scoped shim dir to managed-PTY +// PATH makes bare `orca` resolve to the Orca CLI inside Orca terminals only, +// leaving the user's own shells (and their screen reader) untouched. +export function ensureLinuxTerminalOrcaCliShimDir( + options: LinuxTerminalOrcaCliShimOptions +): string | null { + const cached = ensuredShimDirs.get(options.userDataPath) + if (cached !== undefined) { + return cached + } + + const resourcesPath = options.resourcesPath ?? process.resourcesPath + if (!resourcesPath) { + return null + } + const resolved = buildBareOrcaCliScript( + resourcesPath, + options.appImagePath ?? process.env.APPIMAGE ?? null + ) + if (!resolved) { + return null + } + + const shimDir = join(options.userDataPath, SHIM_DIR_NAME) + const shimPath = join(shimDir, 'orca') + try { + if (readShim(shimPath) !== resolved.script) { + mkdirSync(shimDir, { recursive: true }) + writeFileSync(shimPath, resolved.script, 'utf8') + } + // Why: always re-assert the exec bit — a shim written by an older run (or + // restored from backup) with mode stripped would fail every agent CLI call. + chmodSync(shimPath, 0o755) + } catch { + return null + } + ensuredShimDirs.set(options.userDataPath, shimDir) + return shimDir +} + +function readShim(shimPath: string): string | null { + try { + return readFileSync(shimPath, 'utf8') + } catch { + return null + } +} diff --git a/src/main/cli/windows-launcher-asset.test.ts b/src/main/cli/windows-launcher-asset.test.ts index cf73b0a3d0b..53c7434d63f 100644 --- a/src/main/cli/windows-launcher-asset.test.ts +++ b/src/main/cli/windows-launcher-asset.test.ts @@ -3,11 +3,12 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' describe('packaged Windows CLI launcher asset', () => { - it('walks from resources/bin back to the app root before locating Orca.exe', () => { + it('keeps the batch compatibility shim behind the newline-safe native launcher', () => { const launcherPath = join(process.cwd(), 'resources', 'win32', 'bin', 'orca.cmd') const launcher = readFileSync(launcherPath, 'utf8') - expect(launcher).toContain('for %%I in ("%RESOURCES_DIR%\\..") do set "APP_DIR=%%~fI"') - expect(launcher).not.toContain('for %%I in ("%RESOURCES_DIR%..") do set "APP_DIR=%%~fI"') + expect(launcher).toContain('set "LAUNCHER=%SCRIPT_DIR%orca.exe"') + expect(launcher).toContain('orca.cmd cannot safely forward orchestration message bodies') + expect(launcher).not.toContain('"%ELECTRON%" "%CLI%" %*') }) }) diff --git a/src/main/cli/wsl-cli-installer.test.ts b/src/main/cli/wsl-cli-installer.test.ts index b9228fcb6a2..547679df46f 100644 --- a/src/main/cli/wsl-cli-installer.test.ts +++ b/src/main/cli/wsl-cli-installer.test.ts @@ -1,22 +1,28 @@ import type { CliInstallStatus } from '../../shared/cli-install-types' +import { execFileSync } from 'node:child_process' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const execFileMock = vi.hoisted(() => vi.fn()) -vi.mock('node:child_process', () => ({ +vi.mock('node:child_process', async (importOriginal) => ({ + ...(await importOriginal>()), execFile: execFileMock })) import { WslCliInstaller, _internals } from './wsl-cli-installer' +import { reconcileManagedWslCliRegistrations } from './wsl-cli-registration-reconciliation' function makeHostStatus( - launcherPath = 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.cmd' + launcherPath = 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.exe' ) { return { platform: 'win32', commandName: 'orca', - commandPath: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\bin\\orca.cmd', - pathDirectory: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\bin', + commandPath: launcherPath, + pathDirectory: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin', pathConfigured: true, launcherPath, installMethod: 'wrapper', @@ -28,13 +34,51 @@ function makeHostStatus( } satisfies CliInstallStatus } -function createWslRunner(initialFile: string | null = null, pathIncludesLocalBin = true) { +// Frozen from v1.4.138-rc.2: the upgrade regression only reproduces when the +// persisted managed script still names the pre-native Windows batch launcher. +const PRE_RC4_MANAGED_WSL_LAUNCHER = `#!/usr/bin/env bash +set -euo pipefail +# Orca managed WSL CLI launcher +# ORCA_WIN_LAUNCHER_B64=QzpcUHJvZ3JhbSBGaWxlc1xPcmNhXHJlc291cmNlc1xiaW5cb3JjYS5jbWQ= +ORCA_WIN_LAUNCHER='C:\\Program Files\\Orca\\resources\\bin\\orca.cmd' +ORCA_BRIDGE_PS1='/home/alice/.local/share/orca/orca-wsl-bridge.ps1' +if command -v powershell.exe >/dev/null 2>&1; then + ORCA_POWERSHELL=powershell.exe +elif [ -x /mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe ]; then + ORCA_POWERSHELL=/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe +else + echo "Orca WSL CLI requires Windows interop and could not find powershell.exe." >&2 + exit 1 +fi +ORCA_BRIDGE_PS1_WIN=$(wslpath -w "$ORCA_BRIDGE_PS1") +exec "$ORCA_POWERSHELL" -NoProfile -ExecutionPolicy Bypass -File "$ORCA_BRIDGE_PS1_WIN" "$ORCA_WIN_LAUNCHER" "$@" +` + +function createWslRunner( + initialFile: string | null = null, + pathIncludesLocalBin = true, + options: { + initialBridge?: string | null + initialLegacyFile?: string | null + failInstall?: boolean + interopReady?: boolean + } = {} +) { const commandPath = '/home/alice/.local/bin/orca-ide' + const legacyCommandPath = '/home/alice/.local/bin/orca' const bridgePath = '/home/alice/.local/share/orca/orca-wsl-bridge.ps1' const files = new Map() if (initialFile !== null) { files.set(commandPath, initialFile) - files.set(bridgePath, _internals.buildWslBridgeScript()) + } + if ( + options.initialBridge !== null && + (initialFile !== null || options.initialBridge !== undefined) + ) { + files.set(bridgePath, options.initialBridge ?? _internals.buildWslBridgeScript()) + } + if (options.initialLegacyFile) { + files.set(legacyCommandPath, options.initialLegacyFile) } const calls: string[] = [] const runner = vi.fn(async (_distro: string, command: string) => { @@ -46,6 +90,15 @@ function createWslRunner(initialFile: string | null = null, pathIncludesLocalBin return pathIncludesLocalBin ? 'yes' : 'no' } if (command.includes('cat > "$command_tmp"')) { + if (options.failInstall) { + throw new Error('simulated replacement failure') + } + if ( + files.has(bridgePath) && + !files.get(bridgePath)?.includes('# Orca managed WSL CLI PowerShell bridge') + ) { + throw new Error('__ORCA_CONFLICT__') + } const launcher = command.match(/cat > "\$command_tmp" <<'ORCA_WSL_CLI'\n([\s\S]*)\nORCA_WSL_CLI/)?.[1] ?? '' const bridge = @@ -54,20 +107,31 @@ function createWslRunner(initialFile: string | null = null, pathIncludesLocalBin )?.[1] ?? '' files.set(commandPath, launcher) files.set(bridgePath, bridge) + if (files.get(legacyCommandPath)?.includes('# Orca managed WSL CLI launcher')) { + files.delete(legacyCommandPath) + } return '' } if (command.includes('command -v powershell.exe')) { - return 'yes' + return options.interopReady === false ? 'no' : 'yes' } if (command.includes('rm -f')) { + if (command.includes(`rm -f '${commandPath}'`)) { + if ( + files.has(bridgePath) && + !files.get(bridgePath)?.includes('# Orca managed WSL CLI PowerShell bridge') + ) { + throw new Error('__ORCA_CONFLICT__') + } + files.delete(commandPath) + files.delete(bridgePath) + } if ( - files.has(bridgePath) && - !files.get(bridgePath)?.includes('# Orca managed WSL CLI PowerShell bridge') + command.includes(legacyCommandPath) && + files.get(legacyCommandPath)?.includes('# Orca managed WSL CLI launcher') ) { - throw new Error('__ORCA_CONFLICT__') + files.delete(legacyCommandPath) } - files.delete(commandPath) - files.delete(bridgePath) return '' } if (command.includes('cat ')) { @@ -77,6 +141,9 @@ function createWslRunner(initialFile: string | null = null, pathIncludesLocalBin if (command.includes(bridgePath)) { return files.get(bridgePath) ?? '__ORCA_MISSING__' } + if (command.includes(legacyCommandPath)) { + return files.get(legacyCommandPath) ?? '__ORCA_MISSING__' + } } throw new Error(`Unexpected WSL command: ${command}`) }) @@ -84,7 +151,8 @@ function createWslRunner(initialFile: string | null = null, pathIncludesLocalBin runner, calls, getBridge: () => files.get(bridgePath) ?? null, - getFile: () => files.get(commandPath) ?? null + getFile: () => files.get(commandPath) ?? null, + getLegacyFile: () => files.get(legacyCommandPath) ?? null } } @@ -116,18 +184,26 @@ describe('WslCliInstaller', () => { expect(installed).toMatchObject({ state: 'installed', pathConfigured: true, - launcherPath: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.cmd' + launcherPath: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.exe' }) expect(wsl.getFile()).toBe( _internals.buildWslLauncher( - 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.cmd', + 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.exe', '/home/alice/.local/share/orca/orca-wsl-bridge.ps1' ) ) expect(wsl.getBridge()).toBe(_internals.buildWslBridgeScript()) const installCommand = wsl.calls.find((command) => command.includes('cat > "$command_tmp"')) + expect(installCommand).toBeDefined() expect(installCommand).toContain("legacy_command_path='/home/alice/.local/bin/orca'") expect(installCommand).toContain('rm -f "$legacy_command_path"') + // Why: the new bridge accepts the old launcher's positional arguments, so + // publishing it first keeps interrupted upgrades usable. + const bridgePublishIndex = installCommand?.indexOf('mv -f "$bridge_tmp"') ?? -1 + const launcherPublishIndex = installCommand?.indexOf('mv -f "$command_tmp"') ?? -1 + expect(bridgePublishIndex).toBeGreaterThan(-1) + expect(bridgePublishIndex).toBeLessThan(launcherPublishIndex) + expect(installCommand).toContain('[ ! -L "$legacy_command_path" ]') }) it('derives the shared WSL bridge path for current and legacy command names', () => { @@ -227,12 +303,34 @@ describe('WslCliInstaller', () => { 'Orca WSL CLI requires Windows interop and could not find powershell.exe.' ) expect(launcher).toContain('"$ORCA_POWERSHELL" -NoProfile -ExecutionPolicy Bypass -File') - expect(launcher).toContain('"$ORCA_WIN_LAUNCHER" "$@"') + expect(launcher).toContain('ORCA_WSL_CWD=$(pwd -P 2>/dev/null) || {') + expect(launcher).toContain('ORCA_WSL_CWD=/') + expect(launcher).toContain('cd /') + expect(launcher).toContain('ORCA_WSL_CWD_WIN=$(wslpath -w "$ORCA_WSL_CWD")') + expect(launcher.indexOf('ORCA_WSL_CWD=$(pwd -P')).toBeLessThan( + launcher.indexOf('ORCA_BRIDGE_PS1_WIN=$(wslpath') + ) + expect(launcher).toContain('"$ORCA_WIN_LAUNCHER" -WslCwd "$ORCA_WSL_CWD_WIN" "$@"') expect(launcher).not.toContain('-Command') + expect(bridge).toContain('[CmdletBinding(PositionalBinding=$false)]') + expect(bridge).toContain('[Parameter(Mandatory=$true, Position=0)]') + expect(bridge).toContain('[string]$WslCwd') expect(bridge).toContain('[Parameter(ValueFromRemainingArguments=$true)]') + expect(bridge).toContain('if ([string]::IsNullOrEmpty($WslCwd))') + expect(bridge).toContain('$env:ORCA_CLI_CWD = $WslCwd') + expect(bridge).toContain('Push-Location -LiteralPath (Split-Path -Parent $OrcaLauncher)') expect(bridge).toContain('& $OrcaLauncher @ForwardArgs') + const nullExitCodeBranch = bridge.indexOf('if ($null -eq $LASTEXITCODE)') + const invocationFailureBranch = bridge.indexOf('if (-not $?)') + expect(nullExitCodeBranch).toBeGreaterThan(-1) + // Why: native launchers can set a non-zero LASTEXITCODE while $? is false; + // checking the native status first preserves that specific exit code. + expect(nullExitCodeBranch).toBeLessThan(invocationFailureBranch) + expect(bridge).toContain('$exitCode = $LASTEXITCODE') + expect(bridge).toContain('Remove-Item Env:ORCA_CLI_CWD -ErrorAction SilentlyContinue') expect(bridge).toContain('catch') - expect(bridge).toContain('exit 1') + expect(bridge).toContain('$exitCode = 1') + expect(bridge).toContain('exit $exitCode') }) it('wraps WSL bash scripts as a single encoded command line', () => { @@ -291,15 +389,387 @@ describe('WslCliInstaller', () => { await expect(installer.getStatus()).resolves.toMatchObject({ state: 'stale', currentTarget: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\bin\\orca.cmd', - launcherPath: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.cmd' + launcherPath: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.exe' }) await expect(installer.install()).resolves.toMatchObject({ state: 'installed', - currentTarget: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.cmd' + currentTarget: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.exe' }) }) + it('repairs the frozen pre-rc4 registration so orchestration send/reply reach native rc4', async () => { + const nativeLauncher = 'C:\\Program Files\\Orca\\resources\\bin\\orca.exe' + const wsl = createWslRunner(PRE_RC4_MANAGED_WSL_LAUNCHER) + const installer = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) }, + wslRunner: wsl.runner + }) + const orchestrationCalls = [ + ['orchestration', 'send', '--type', 'heartbeat'], + ['orchestration', 'send', '--type', 'worker_done'], + ['orchestration', 'reply', '--message', 'line one\nline two'] + ] + const simulateRc4Launch = (args: string[]): number => { + const target = _internals.parseManagedLauncherTarget(wsl.getFile() ?? '') + return target?.toLowerCase().endsWith('orca.cmd') && + args[0] === 'orchestration' && + (args[1] === 'send' || args[1] === 'reply') + ? 2 + : 0 + } + + expect(orchestrationCalls.map(simulateRc4Launch)).toEqual([2, 2, 2]) + + await expect( + reconcileManagedWslCliRegistrations({ + platform: 'win32', + isPackaged: true, + userDataPath: '/user-data', + listDistros: async () => ['Ubuntu'], + registry: { + getCandidates: async () => ['Ubuntu'], + recordObservations: async () => undefined + }, + createInstaller: () => installer + }) + ).resolves.toEqual([ + { distro: 'Ubuntu', outcome: 'repaired', state: 'installed', managed: true } + ]) + await expect(installer.getStatus()).resolves.toMatchObject({ + state: 'installed', + currentTarget: nativeLauncher + }) + expect(orchestrationCalls.map(simulateRc4Launch)).toEqual([0, 0, 0]) + }) + + it('leaves unmanaged WSL commands and conflicting bridges untouched during automatic repair', async () => { + const unmanaged = '#!/usr/bin/env bash\necho user-owned\n' + const wsl = createWslRunner(unmanaged) + const installer = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { getStatus: async () => makeHostStatus() }, + wslRunner: wsl.runner + }) + + await expect(installer.repairManagedRegistration()).resolves.toMatchObject({ + changed: false, + status: { state: 'conflict' } + }) + expect(wsl.getFile()).toBe(unmanaged) + expect(wsl.calls.some((command) => command.includes('cat > "$command_tmp"'))).toBe(false) + }) + + it('repairs a managed launcher whose bridge is missing, but preserves a conflicting bridge', async () => { + const nativeLauncher = 'C:\\Orca\\resources\\bin\\orca.exe' + const missingBridge = createWslRunner(PRE_RC4_MANAGED_WSL_LAUNCHER, true, { + initialBridge: null + }) + const missingBridgeInstaller = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) }, + wslRunner: missingBridge.runner + }) + + await expect(missingBridgeInstaller.repairManagedRegistration()).resolves.toMatchObject({ + changed: true, + status: { state: 'installed' } + }) + expect(missingBridge.getBridge()).toBe(_internals.buildWslBridgeScript()) + + const staleBridge = createWslRunner(PRE_RC4_MANAGED_WSL_LAUNCHER, true, { + initialBridge: '# Orca managed WSL CLI PowerShell bridge\nWrite-Output "stale"\n' + }) + const staleBridgeInstaller = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) }, + wslRunner: staleBridge.runner + }) + await expect(staleBridgeInstaller.repairManagedRegistration()).resolves.toMatchObject({ + changed: true, + status: { state: 'installed' } + }) + expect(staleBridge.getBridge()).toBe(_internals.buildWslBridgeScript()) + + const conflictingBridge = createWslRunner(PRE_RC4_MANAGED_WSL_LAUNCHER, true, { + initialBridge: 'Write-Output "user-owned bridge"\n' + }) + const conflictingBridgeInstaller = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) }, + wslRunner: conflictingBridge.runner + }) + + // Why: a stale launcher with a user-owned bridge must surface as a + // non-throwing conflict, not retry a doomed install on every startup. + await expect(conflictingBridgeInstaller.repairManagedRegistration()).resolves.toMatchObject({ + changed: false, + managed: true, + status: { state: 'conflict' } + }) + expect(conflictingBridge.getBridge()).toBe('Write-Output "user-owned bridge"\n') + expect(conflictingBridge.getFile()).toBe(PRE_RC4_MANAGED_WSL_LAUNCHER) + expect( + conflictingBridge.calls.some((command) => command.includes('cat > "$command_tmp"')) + ).toBe(false) + }) + + it('retains command ownership when only the bridge conflicts', async () => { + const nativeLauncher = 'C:\\Orca\\resources\\bin\\orca.exe' + const currentLauncher = _internals.buildWslLauncher( + nativeLauncher, + '/home/alice/.local/share/orca/orca-wsl-bridge.ps1' + ) + const wsl = createWslRunner(currentLauncher, true, { + initialBridge: 'Write-Output "user-owned bridge"\n' + }) + const installer = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) }, + wslRunner: wsl.runner + }) + + await expect(installer.repairManagedRegistration()).resolves.toMatchObject({ + changed: false, + managed: true, + status: { state: 'conflict' } + }) + expect(wsl.getBridge()).toBe('Write-Output "user-owned bridge"\n') + expect(wsl.getFile()).toBe(currentLauncher) + }) + + it('moves a legacy-only managed registration to orca-ide without touching unmanaged names', async () => { + const nativeLauncher = 'C:\\Orca\\resources\\bin\\orca.exe' + const managedLegacy = createWslRunner(null, true, { + initialBridge: _internals.buildWslBridgeScript(), + initialLegacyFile: PRE_RC4_MANAGED_WSL_LAUNCHER + }) + const installer = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) }, + wslRunner: managedLegacy.runner + }) + + await expect(installer.repairManagedRegistration()).resolves.toMatchObject({ + changed: true, + status: { state: 'installed', currentTarget: nativeLauncher } + }) + expect(managedLegacy.getLegacyFile()).toBeNull() + + const unmanagedLegacy = createWslRunner(null, true, { + initialLegacyFile: '#!/bin/sh\necho user-owned\n' + }) + const unmanagedInstaller = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) }, + wslRunner: unmanagedLegacy.runner + }) + await expect(unmanagedInstaller.repairManagedRegistration()).resolves.toMatchObject({ + changed: false, + status: { state: 'not_installed' } + }) + expect(unmanagedLegacy.getLegacyFile()).toBe('#!/bin/sh\necho user-owned\n') + }) + + it('does not adopt a legacy-managed registration when the bridge is user-owned', async () => { + const wsl = createWslRunner(null, true, { + initialBridge: 'Write-Output "user-owned bridge"\n', + initialLegacyFile: PRE_RC4_MANAGED_WSL_LAUNCHER + }) + const installer = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { getStatus: async () => makeHostStatus() }, + wslRunner: wsl.runner + }) + + // Why: adoption would fail install()'s bridge guard on every startup; + // repair must report blocked-but-managed instead of a doomed install. + await expect(installer.repairManagedRegistration()).resolves.toMatchObject({ + changed: false, + managed: true, + status: { state: 'not_installed' } + }) + expect(wsl.getLegacyFile()).toBe(PRE_RC4_MANAGED_WSL_LAUNCHER) + expect(wsl.calls.some((command) => command.includes('cat > "$command_tmp"'))).toBe(false) + }) + + it('removes the managed legacy launcher on removal so reconciliation cannot re-adopt it', async () => { + const nativeLauncher = 'C:\\Orca\\resources\\bin\\orca.exe' + const managedLegacy = createWslRunner(null, true, { + initialBridge: _internals.buildWslBridgeScript(), + initialLegacyFile: PRE_RC4_MANAGED_WSL_LAUNCHER + }) + const managedLegacyInstaller = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) }, + wslRunner: managedLegacy.runner + }) + await expect(managedLegacyInstaller.remove()).resolves.toMatchObject({ + state: 'not_installed' + }) + expect(managedLegacy.getLegacyFile()).toBeNull() + + const unmanagedLegacy = createWslRunner(null, true, { + initialLegacyFile: '#!/bin/sh\necho user-owned\n' + }) + const unmanagedInstaller2 = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) }, + wslRunner: unmanagedLegacy.runner + }) + await expect(unmanagedInstaller2.remove()).resolves.toMatchObject({ + state: 'not_installed' + }) + expect(unmanagedLegacy.getLegacyFile()).toBe('#!/bin/sh\necho user-owned\n') + + const installedWithLegacy = createWslRunner( + _internals.buildWslLauncher( + nativeLauncher, + '/home/alice/.local/share/orca/orca-wsl-bridge.ps1' + ), + true, + { initialLegacyFile: PRE_RC4_MANAGED_WSL_LAUNCHER } + ) + const installedInstaller = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) }, + wslRunner: installedWithLegacy.runner + }) + await expect(installedInstaller.remove()).resolves.toMatchObject({ state: 'not_installed' }) + expect(installedWithLegacy.getFile()).toBeNull() + expect(installedWithLegacy.getLegacyFile()).toBeNull() + }) + + it('keeps the pre-rc4 files on a transactional replacement failure', async () => { + const bridge = _internals.buildWslBridgeScript() + const wsl = createWslRunner(PRE_RC4_MANAGED_WSL_LAUNCHER, true, { + initialBridge: bridge, + failInstall: true + }) + const installer = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { + getStatus: async () => makeHostStatus('C:\\Program Files\\Orca\\resources\\bin\\orca.exe') + }, + wslRunner: wsl.runner + }) + + await expect(installer.repairManagedRegistration()).rejects.toThrow( + 'simulated replacement failure' + ) + expect(wsl.getFile()).toBe(PRE_RC4_MANAGED_WSL_LAUNCHER) + expect(wsl.getBridge()).toBe(bridge) + const installCommand = wsl.calls.find((command) => command.includes('cat > "$command_tmp"')) + expect(installCommand).toContain('rollback() {') + expect(installCommand).toContain('set +e') + expect(installCommand).toContain('bridge_backup="${bridge_tmp}.backup"') + expect(installCommand).toContain('cp -p') + expect(installCommand).toContain('elif [ "$bridge_touched" -eq 1 ]') + expect(installCommand).toContain('committed=1') + expect(installCommand).toContain('flock -x -w 30 9') + // Why: the command replace must stay one atomic rename; a mv-based backup + // would leave a window where a concurrent shell finds no orca-ide at all. + expect(installCommand).not.toContain('command_backup') + expect(installCommand).not.toContain(`mv -f '/home/alice/.local/bin/orca-ide'`) + }) + + it.skipIf(process.platform === 'win32')( + 'rolls both files back when the command replacement fails after the bridge move', + async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-wsl-cli-rollback-')) + const home = join(root, 'home with spaces') + const commandPath = join(home, '.local', 'bin', 'orca-ide') + const bridgePath = join(home, '.local', 'share', 'orca', 'orca-wsl-bridge.ps1') + const bridge = _internals.buildWslBridgeScript() + await mkdir(join(home, '.local', 'bin'), { recursive: true }) + await mkdir(join(home, '.local', 'share', 'orca'), { recursive: true }) + await writeFile(commandPath, PRE_RC4_MANAGED_WSL_LAUNCHER, 'utf8') + await writeFile(bridgePath, bridge, 'utf8') + + const runner = async (_distro: string, command: string): Promise => { + if (command.includes('printf %s "$HOME"')) { + return home + } + if (command.includes('cat > "$command_tmp"')) { + const executableCommand = command + .split('\n') + .map((line) => (line.startsWith('mv -f "$command_tmp" ') ? 'exit 71' : line)) + .join('\n') + return execFileSync('bash', ['-c', executableCommand], { encoding: 'utf8' }) + } + if (command.includes('command -v powershell.exe')) { + return 'yes' + } + if (command.includes('case ":$PATH:"')) { + return 'yes' + } + return execFileSync('bash', ['-c', command], { encoding: 'utf8' }) + } + const installer = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { + getStatus: async () => makeHostStatus('C:\\Program Files\\Orca\\resources\\bin\\orca.exe') + }, + wslRunner: runner + }) + + try { + await expect(installer.repairManagedRegistration()).rejects.toThrow() + await expect(readFile(commandPath, 'utf8')).resolves.toBe(PRE_RC4_MANAGED_WSL_LAUNCHER) + await expect(readFile(bridgePath, 'utf8')).resolves.toBe(bridge) + } finally { + await rm(root, { recursive: true, force: true }) + } + } + ) + + it('skips automatic repair when WSL interop is unavailable', async () => { + const wsl = createWslRunner(PRE_RC4_MANAGED_WSL_LAUNCHER, true, { interopReady: false }) + const installer = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { getStatus: async () => makeHostStatus() }, + wslRunner: wsl.runner + }) + + await expect(installer.repairManagedRegistration()).resolves.toMatchObject({ + changed: false, + status: { state: 'unsupported' } + }) + expect(wsl.getFile()).toBe(PRE_RC4_MANAGED_WSL_LAUNCHER) + }) + + it('is idempotent after repairing an old managed registration', async () => { + const nativeLauncher = 'D:\\Custom Orca\\resources\\bin\\orca.exe' + const wsl = createWslRunner(PRE_RC4_MANAGED_WSL_LAUNCHER) + const installer = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) }, + wslRunner: wsl.runner + }) + + await expect(installer.repairManagedRegistration()).resolves.toMatchObject({ changed: true }) + await expect(installer.repairManagedRegistration()).resolves.toMatchObject({ changed: false }) + expect(wsl.calls.filter((command) => command.includes('cat > "$command_tmp"'))).toHaveLength(1) + expect(wsl.getFile()).toContain("ORCA_WIN_LAUNCHER='D:\\Custom Orca\\resources\\bin\\orca.exe'") + }) + it('settles when wsl.exe never reports completion', async () => { vi.useFakeTimers() const killMock = vi.fn() diff --git a/src/main/cli/wsl-cli-installer.ts b/src/main/cli/wsl-cli-installer.ts index 5d473ad5bfd..f6a1684e19c 100644 --- a/src/main/cli/wsl-cli-installer.ts +++ b/src/main/cli/wsl-cli-installer.ts @@ -5,6 +5,8 @@ import type { CliInstallStatus } from '../../shared/cli-install-types' import { getDefaultWslDistro } from '../wsl' import { CliInstaller } from './cli-installer' import { + buildManagedLegacyRemoveCommand, + buildRegistrationLockPrelude, buildSafeRemoveCommand, buildSafeReplaceGuard, buildWslBridgeScript, @@ -38,6 +40,12 @@ type WslCliInstallerOptions = { wslRunner?: (distro: string, command: string) => Promise } +export type ManagedWslCliRepairResult = { + changed: boolean + managed: boolean + status: CliInstallStatus +} + export class WslCliInstaller { private readonly platform: NodeJS.Platform private readonly distro: string | null @@ -119,21 +127,80 @@ export class WslCliInstaller { }) } + // Why: a stale managed launcher is only repairable when its bridge is + // ours too; reporting conflict here keeps repair from a doomed install + // whose bridge guard would fail on every startup. + const bridgeConflict = managed && (await this.isBridgeConflict(ready.distro, ready.bridgePath)) return this.buildStatus({ distro: ready.distro, commandPath: ready.commandPath, launcherPath: ready.launcherPath, - state: managed ? 'stale' : 'conflict', + state: managed && !bridgeConflict ? 'stale' : 'conflict', currentTarget, pathConfigured: ready.pathConfigured, - detail: managed - ? `${ready.commandPath} points to a different Orca launcher.` - : `${ready.commandPath} exists but is not managed by Orca.` + detail: !managed + ? `${ready.commandPath} exists but is not managed by Orca.` + : bridgeConflict + ? `${ready.bridgePath} exists but is not managed by Orca.` + : `${ready.commandPath} points to a different Orca launcher.` }) } - async install(): Promise { + private async isBridgeConflict(distro: string, bridgePath: string): Promise { + const bridgeContent = await this.readCommandFile(distro, bridgePath) + if (bridgeContent === null) { + return false + } + return bridgeContent === 'not_file' || !bridgeContent.includes(BRIDGE_MANAGED_MARKER) + } + + async repairManagedRegistration(): Promise { const status = await this.getStatus() + if (!status.supported) { + return { changed: false, managed: false, status } + } + if (status.state === 'conflict') { + // Why: a user-owned bridge conflicts with repair, but the launcher is + // still Orca-managed and must remain registered for future reconciliation. + return { changed: false, managed: status.currentTarget !== null, status } + } + + if (status.state === 'stale') { + return { changed: true, managed: true, status: await this.install(status) } + } + + const legacyCommandPath = status.commandPath + ? `${getPosixDirname(status.commandPath)}/${LEGACY_WSL_COMMAND_NAME}` + : null + if (!legacyCommandPath || !this.distro) { + return { changed: false, managed: status.state === 'installed', status } + } + + const legacyContent = await this.readCommandFile(this.distro, legacyCommandPath) + const legacyManaged = + typeof legacyContent === 'string' && legacyContent.includes(MANAGED_MARKER) + if (!legacyManaged) { + return { changed: false, managed: status.state === 'installed', status } + } + + if ( + status.commandPath && + (await this.isBridgeConflict(this.distro, getBridgePathFromCommandPath(status.commandPath))) + ) { + // Why: adopting the legacy command would fail install()'s bridge guard + // forever; stay registered so reconciliation retries after an update. + return { changed: false, managed: true, status } + } + + // Why: a legacy-only managed command proves the user opted into WSL CLI + // registration; install the current name before removing that owned script. + return { changed: true, managed: true, status: await this.install(status) } + } + + async install(precomputedStatus?: CliInstallStatus): Promise { + // Why: repair passes its fresh probe; re-probing here would double every + // WSL round trip on the startup reconciliation path. + const status = precomputedStatus ?? (await this.getStatus()) if (!status.supported || !status.commandPath || !status.launcherPath) { throw new Error(status.detail ?? 'WSL CLI registration is unavailable.') } @@ -141,20 +208,36 @@ export class WslCliInstaller { throw new Error(`Refusing to replace non-Orca command at ${status.commandPath}.`) } + // Why: the launcher and PowerShell bridge are one registration; the + // command replacement stays a single atomic rename (never missing for a + // concurrent shell) while a bridge copy enables rollback of the pair. await this.run( this.distro as string, [ 'set -euo pipefail', `mkdir -p ${quoteShell(status.pathDirectory as string)}`, `mkdir -p ${quoteShell(getPosixDirname(getBridgePathFromCommandPath(status.commandPath)))}`, + buildRegistrationLockPrelude(status.commandPath), `command_tmp=${quoteShell(`${status.commandPath}.tmp`)}.$$`, `bridge_path=${quoteShell(getBridgePathFromCommandPath(status.commandPath))}`, `legacy_command_path=${quoteShell( `${getPosixDirname(status.commandPath)}/${LEGACY_WSL_COMMAND_NAME}` )}`, 'bridge_tmp="${bridge_path}.tmp.$$"', - 'cleanup() { rm -f "$command_tmp" "$bridge_tmp"; }', - 'trap cleanup EXIT', + 'bridge_backup="${bridge_tmp}.backup"', + 'bridge_had_original=0', + 'bridge_touched=0', + 'committed=0', + 'rollback() {', + ' result=$?', + ' set +e', + ' if [ "$committed" -ne 1 ]; then', + ` if [ "$bridge_had_original" -eq 1 ]; then mv -f "$bridge_backup" ${quoteShell(getBridgePathFromCommandPath(status.commandPath))}; elif [ "$bridge_touched" -eq 1 ]; then rm -f ${quoteShell(getBridgePathFromCommandPath(status.commandPath))}; fi`, + ' fi', + ' rm -f "$command_tmp" "$bridge_tmp" "$bridge_backup"', + ' exit "$result"', + '}', + 'trap rollback EXIT', buildSafeReplaceGuard(status.commandPath, MANAGED_MARKER), buildSafeReplaceGuard( getBridgePathFromCommandPath(status.commandPath), @@ -173,11 +256,15 @@ export class WslCliInstaller { getBridgePathFromCommandPath(status.commandPath), BRIDGE_MANAGED_MARKER ), - // Why: the command was renamed to avoid GNOME Orca; remove only the - // old Orca-managed WSL wrapper so unmanaged `orca` commands survive. - `if [ -f "$legacy_command_path" ] && grep -Fq ${quoteShell(MANAGED_MARKER)} "$legacy_command_path"; then rm -f "$legacy_command_path"; fi`, + `if [ -f ${quoteShell(getBridgePathFromCommandPath(status.commandPath))} ]; then cp -p ${quoteShell(getBridgePathFromCommandPath(status.commandPath))} "$bridge_backup"; bridge_had_original=1; fi`, `mv -f "$bridge_tmp" ${quoteShell(getBridgePathFromCommandPath(status.commandPath))}`, + 'bridge_touched=1', `mv -f "$command_tmp" ${quoteShell(status.commandPath)}`, + 'committed=1', + 'rm -f "$bridge_backup"', + // Why: the command was renamed to avoid GNOME Orca; remove only the + // old Orca-managed WSL wrapper after the replacement has committed. + buildManagedLegacyRemoveCommand('"$legacy_command_path"'), 'trap - EXIT' ].join('\n') ) @@ -189,14 +276,26 @@ export class WslCliInstaller { if (!status.supported || !status.commandPath) { return status } + const legacyCommandPath = `${getPosixDirname(status.commandPath)}/${LEGACY_WSL_COMMAND_NAME}` if (status.state === 'not_installed') { + // Why: a managed legacy `orca` left behind would later be re-adopted by + // startup reconciliation as opt-in proof, silently undoing this removal. + await this.run( + this.distro as string, + ['set -euo pipefail', buildManagedLegacyRemoveCommand(quoteShell(legacyCommandPath))].join( + '\n' + ) + ) return status } if (status.state === 'conflict') { throw new Error(`Refusing to remove non-Orca command at ${status.commandPath}.`) } - await this.run(this.distro as string, buildSafeRemoveCommand(status.commandPath)) + await this.run( + this.distro as string, + buildSafeRemoveCommand(status.commandPath, legacyCommandPath) + ) return this.getStatus() } @@ -411,5 +510,6 @@ export const _internals = { buildEncodedWslBashCommand, buildWslBridgeScript, buildWslLauncher, - getBridgePathFromCommandPath + getBridgePathFromCommandPath, + parseManagedLauncherTarget } diff --git a/src/main/cli/wsl-cli-registration-operation.test.ts b/src/main/cli/wsl-cli-registration-operation.test.ts new file mode 100644 index 00000000000..0240690ffb8 --- /dev/null +++ b/src/main/cli/wsl-cli-registration-operation.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest' +import { runSerializedWslCliRegistrationOperation } from './wsl-cli-registration-operation' + +describe('runSerializedWslCliRegistrationOperation', () => { + it('serializes operations for distro names with different casing', async () => { + let releaseFirst!: () => void + const events: string[] = [] + const first = runSerializedWslCliRegistrationOperation('Ubuntu', async () => { + events.push('first-start') + await new Promise((resolve) => { + releaseFirst = resolve + }) + events.push('first-end') + }) + + await vi.waitFor(() => expect(events).toEqual(['first-start'])) + const second = runSerializedWslCliRegistrationOperation(' ubuntu ', async () => { + events.push('second') + }) + await Promise.resolve() + expect(events).toEqual(['first-start']) + + releaseFirst() + await Promise.all([first, second]) + expect(events).toEqual(['first-start', 'first-end', 'second']) + }) + + it('allows different distros to progress independently', async () => { + let releaseUbuntu!: () => void + const events: string[] = [] + const ubuntu = runSerializedWslCliRegistrationOperation('Ubuntu', async () => { + events.push('ubuntu-start') + await new Promise((resolve) => { + releaseUbuntu = resolve + }) + }) + const debian = runSerializedWslCliRegistrationOperation('Debian', async () => { + events.push('debian') + }) + + await debian + expect(events).toEqual(['ubuntu-start', 'debian']) + releaseUbuntu() + await ubuntu + }) + + it('releases the queue after an operation fails', async () => { + await expect( + runSerializedWslCliRegistrationOperation('Ubuntu', async () => { + throw new Error('interop failed') + }) + ).rejects.toThrow('interop failed') + + await expect( + runSerializedWslCliRegistrationOperation('ubuntu', async () => 'recovered') + ).resolves.toBe('recovered') + }) +}) diff --git a/src/main/cli/wsl-cli-registration-operation.ts b/src/main/cli/wsl-cli-registration-operation.ts new file mode 100644 index 00000000000..2ed8404774c --- /dev/null +++ b/src/main/cli/wsl-cli-registration-operation.ts @@ -0,0 +1,23 @@ +import { runKeyedSerializedOperation } from './keyed-promise-queue' + +const operationQueues = new Map>() + +/** + * Canonical key for a WSL distro name. The operation queue and the + * registration registry must agree on this to serialize against each other. + */ +export function normalizeWslDistroKey(distro: string): string { + return distro.trim().toLowerCase() +} + +/** + * Serializes registration reads and mutations for one WSL distro. + */ +export function runSerializedWslCliRegistrationOperation( + distro: string, + operation: () => Promise +): Promise { + // Why: startup repair continues in the background and can otherwise undo a + // concurrent Settings install/remove or overwrite its ownership metadata. + return runKeyedSerializedOperation(operationQueues, normalizeWslDistroKey(distro), operation) +} diff --git a/src/main/cli/wsl-cli-registration-reconciliation.test.ts b/src/main/cli/wsl-cli-registration-reconciliation.test.ts new file mode 100644 index 00000000000..48b75dd207d --- /dev/null +++ b/src/main/cli/wsl-cli-registration-reconciliation.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it, vi } from 'vitest' +import { reconcileManagedWslCliRegistrations } from './wsl-cli-registration-reconciliation' +import { runSerializedWslCliRegistrationOperation } from './wsl-cli-registration-operation' + +describe('reconcileManagedWslCliRegistrations', () => { + it('repairs known and newly discovered registrations, then records ownership', async () => { + const registry = { + getCandidates: vi.fn(async () => ['Ubuntu', 'Debian']), + recordObservations: vi.fn(async () => undefined) + } + const repairUbuntu = vi.fn(async () => ({ + changed: true, + managed: true, + status: { state: 'installed' as const } + })) + const repairDebian = vi.fn(async () => ({ + changed: false, + managed: false, + status: { state: 'not_installed' as const } + })) + + const results = await reconcileManagedWslCliRegistrations({ + platform: 'win32', + isPackaged: true, + userDataPath: '/user-data', + listDistros: async () => ['Ubuntu', 'Debian', 'Fedora'], + registry, + createInstaller: (distro) => { + if (distro === 'Ubuntu') { + return { repairManagedRegistration: repairUbuntu } + } + return { repairManagedRegistration: repairDebian } + } + }) + + expect(registry.getCandidates).toHaveBeenCalledWith(['Ubuntu', 'Debian', 'Fedora'], { + currentTarget: null, + appVersion: '' + }) + expect(registry.recordObservations).toHaveBeenCalledTimes(2) + expect(registry.recordObservations).toHaveBeenCalledWith([ + { distro: 'Ubuntu', inspected: true, managed: true } + ]) + expect(registry.recordObservations).toHaveBeenCalledWith([ + { distro: 'Debian', inspected: true, managed: false, reconciled: null } + ]) + expect(results).toEqual([ + { distro: 'Ubuntu', outcome: 'repaired', state: 'installed', managed: true }, + { distro: 'Debian', outcome: 'unchanged', state: 'not_installed', managed: false } + ]) + }) + + it('passes the host launcher target through and records reconciliations against it', async () => { + const registry = { + getCandidates: vi.fn(async () => ['Ubuntu']), + recordObservations: vi.fn(async () => undefined) + } + + await reconcileManagedWslCliRegistrations({ + platform: 'win32', + isPackaged: true, + userDataPath: '/user-data', + appVersion: '1.4.138', + listDistros: async () => ['Ubuntu'], + getHostLauncherTarget: async () => 'C:\\Orca\\resources\\bin\\orca.exe', + registry, + createInstaller: () => ({ + repairManagedRegistration: async () => ({ + changed: true, + managed: true, + status: { state: 'installed' as const } + }) + }) + }) + + expect(registry.getCandidates).toHaveBeenCalledWith(['Ubuntu'], { + currentTarget: 'C:\\Orca\\resources\\bin\\orca.exe', + appVersion: '1.4.138' + }) + expect(registry.recordObservations).toHaveBeenCalledWith([ + { + distro: 'Ubuntu', + inspected: true, + managed: true, + reconciled: { target: 'C:\\Orca\\resources\\bin\\orca.exe', appVersion: '1.4.138' } + } + ]) + }) + + it('records unsupported distros without changing ownership and skips failed ones', async () => { + const registry = { + getCandidates: vi.fn(async () => ['Broken Distro', 'No Interop']), + recordObservations: vi.fn(async () => undefined) + } + + const results = await reconcileManagedWslCliRegistrations({ + platform: 'win32', + isPackaged: true, + userDataPath: '/user-data', + listDistros: async () => ['Broken Distro', 'No Interop'], + registry, + createInstaller: (distro) => ({ + repairManagedRegistration: async () => { + if (distro === 'Broken Distro') { + throw new Error('WSL interop failed') + } + return { + changed: false, + managed: false, + status: { state: 'unsupported' as const } + } + } + }) + }) + + expect(results).toEqual([ + { distro: 'Broken Distro', outcome: 'failed', error: 'WSL interop failed' }, + { distro: 'No Interop', outcome: 'unchanged', state: 'unsupported', managed: false } + ]) + expect(registry.recordObservations).toHaveBeenCalledTimes(1) + // Why: unsupported gets a TTL-stamped inspection (managed: null) so an + // interop-off distro is not re-probed and VM-booted on every startup. + expect(registry.recordObservations).toHaveBeenCalledWith([ + { distro: 'No Interop', inspected: true, managed: null, reconciled: null } + ]) + }) + + it('keeps a successful repair result when observation recording fails', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + try { + const results = await reconcileManagedWslCliRegistrations({ + platform: 'win32', + isPackaged: true, + userDataPath: '/user-data', + listDistros: async () => ['Ubuntu'], + registry: { + getCandidates: async () => ['Ubuntu'], + recordObservations: async () => { + throw new Error('ENOSPC') + } + }, + createInstaller: () => ({ + repairManagedRegistration: async () => ({ + changed: true, + managed: true, + status: { state: 'installed' as const } + }) + }) + }) + + expect(results).toEqual([ + { distro: 'Ubuntu', outcome: 'repaired', state: 'installed', managed: true } + ]) + expect(warn).toHaveBeenCalledOnce() + } finally { + warn.mockRestore() + } + }) + + it.each([ + { platform: 'darwin' as const, isPackaged: true }, + { platform: 'linux' as const, isPackaged: true }, + { platform: 'win32' as const, isPackaged: false } + ])('does not inspect local, SSH, or development hosts for $platform', async (host) => { + const listDistros = vi.fn(async () => ['Ubuntu']) + + await expect( + reconcileManagedWslCliRegistrations({ + ...host, + userDataPath: '/user-data', + listDistros + }) + ).resolves.toEqual([]) + expect(listDistros).not.toHaveBeenCalled() + }) + + it('lets a Settings removal win over a late startup repair for the same distro', async () => { + const events: string[] = [] + let repairStarted!: () => void + let finishRepair!: () => void + const started = new Promise((resolve) => { + repairStarted = resolve + }) + const registry = { + getCandidates: vi.fn(async () => ['Ubuntu']), + recordObservations: vi.fn(async () => { + events.push('repair-observed') + }) + } + const reconciliation = reconcileManagedWslCliRegistrations({ + platform: 'win32', + isPackaged: true, + userDataPath: '/user-data', + listDistros: async () => ['Ubuntu'], + registry, + createInstaller: () => ({ + repairManagedRegistration: async () => { + events.push('repair-started') + repairStarted() + await new Promise((resolve) => { + finishRepair = resolve + }) + events.push('repair-finished') + return { + changed: true, + managed: true, + status: { state: 'installed' as const } + } + } + }) + }) + await started + + const removal = runSerializedWslCliRegistrationOperation('ubuntu', async () => { + events.push('settings-remove') + }) + await Promise.resolve() + expect(events).toEqual(['repair-started']) + + finishRepair() + await Promise.all([reconciliation, removal]) + expect(events).toEqual([ + 'repair-started', + 'repair-finished', + 'repair-observed', + 'settings-remove' + ]) + }) +}) diff --git a/src/main/cli/wsl-cli-registration-reconciliation.ts b/src/main/cli/wsl-cli-registration-reconciliation.ts new file mode 100644 index 00000000000..b4c4933bcd1 --- /dev/null +++ b/src/main/cli/wsl-cli-registration-reconciliation.ts @@ -0,0 +1,175 @@ +import type { CliInstallState, CliInstallStatus } from '../../shared/cli-install-types' +import { listWslDistrosAsync } from '../wsl' +import { CliInstaller } from './cli-installer' +import { + getWslCliRegistrationCandidates, + recordWslCliRegistrationObservations, + type WslCliRegistrationObservation +} from './wsl-cli-registration-registry' +import { WslCliInstaller } from './wsl-cli-installer' +import { runSerializedWslCliRegistrationOperation } from './wsl-cli-registration-operation' + +// Why: candidate distros can each boot a stopped WSL VM; a small cap staggers +// those boots instead of spiking RAM/CPU for every distro at once at startup. +const MAX_CONCURRENT_DISTRO_REPAIRS = 2 + +type ManagedWslCliInstaller = { + repairManagedRegistration: () => Promise<{ + changed: boolean + managed: boolean + status: { state: CliInstallState } + }> +} + +type WslCliRegistrationCandidateContext = { + currentTarget?: string | null + appVersion?: string | null +} + +type WslCliRegistrationRegistry = { + getCandidates: ( + availableDistros: string[], + context?: WslCliRegistrationCandidateContext + ) => Promise + recordObservations: (observations: WslCliRegistrationObservation[]) => Promise +} + +type WslCliRegistrationReconciliationOptions = { + platform?: NodeJS.Platform + isPackaged: boolean + userDataPath: string + appVersion?: string + listDistros?: () => Promise + createInstaller?: (distro: string) => ManagedWslCliInstaller + getHostLauncherTarget?: () => Promise + registry?: WslCliRegistrationRegistry +} + +export type WslCliRegistrationReconciliationResult = + | { + distro: string + outcome: 'repaired' | 'unchanged' + state: CliInstallState + managed: boolean + } + | { + distro: string + outcome: 'failed' + error: string + } + +export async function reconcileManagedWslCliRegistrations( + options: WslCliRegistrationReconciliationOptions +): Promise { + const platform = options.platform ?? process.platform + if (platform !== 'win32' || !options.isPackaged) { + return [] + } + + const registry = + options.registry ?? + ({ + getCandidates: (availableDistros, context) => + getWslCliRegistrationCandidates(options.userDataPath, availableDistros, context ?? {}), + recordObservations: (observations) => + recordWslCliRegistrationObservations(options.userDataPath, observations) + } satisfies WslCliRegistrationRegistry) + + let createInstaller = options.createInstaller + let getHostLauncherTarget = options.getHostLauncherTarget + if (!createInstaller) { + const hostInstaller = new CliInstaller() + let hostStatus: Promise | null = null + // Why: every distro must target this app install; share one Windows PATH / + // launcher probe instead of spawning a PowerShell probe per distro. A + // rejected probe is evicted so one transient failure cannot poison the run. + const getHostStatus = (): Promise => + (hostStatus ??= hostInstaller.getStatus().catch((error) => { + hostStatus = null + throw error + })) + createInstaller = (distro: string) => + new WslCliInstaller({ + distro, + hostInstaller: { getStatus: getHostStatus } + }) + getHostLauncherTarget ??= () => getHostStatus().then((status) => status.launcherPath) + } + + const availableDistros = await (options.listDistros ?? listWslDistrosAsync)() + const currentTarget = getHostLauncherTarget + ? await getHostLauncherTarget().catch(() => null) + : null + const appVersion = options.appVersion ?? '' + const distros = await registry.getCandidates(availableDistros, { currentTarget, appVersion }) + if (distros.length === 0) { + return [] + } + + const reconcileDistro = async ( + distro: string + ): Promise => { + let repair: Awaited> + try { + repair = await createInstaller(distro).repairManagedRegistration() + } catch (error) { + return { + distro, + outcome: 'failed', + error: error instanceof Error ? error.message : String(error) + } + } + const result: WslCliRegistrationReconciliationResult = { + distro, + outcome: repair.changed ? 'repaired' : 'unchanged', + state: repair.status.state, + managed: repair.managed + } + const observation: WslCliRegistrationObservation = + repair.status.state === 'unsupported' + ? // Why: managed-ness is unknowable without interop; stamp the + // inspection (negative TTL) without changing registration ownership. + { distro, inspected: true, managed: null, reconciled: null } + : repair.managed + ? { + distro, + inspected: true, + managed: true, + ...(currentTarget ? { reconciled: { target: currentTarget, appVersion } } : {}) + } + : { distro, inspected: true, managed: false, reconciled: null } + try { + // Why: ownership metadata must commit before a concurrent Settings + // operation can mutate this distro, or stale startup state can win. + await registry.recordObservations([observation]) + } catch (error) { + // Why: the repair already succeeded on disk; an advisory bookkeeping + // failure must not reclassify it, mirroring the Settings IPC contract. + console.warn( + `[wsl-cli] Failed to record ${distro} registration observation:`, + error instanceof Error ? error.message : String(error) + ) + } + return result + } + + const results: WslCliRegistrationReconciliationResult[] = Array.from({ + length: distros.length + }) + let nextIndex = 0 + await Promise.all( + Array.from({ length: Math.min(MAX_CONCURRENT_DISTRO_REPAIRS, distros.length) }, async () => { + for (;;) { + const index = nextIndex++ + if (index >= distros.length) { + return + } + const distro = distros[index] + results[index] = await runSerializedWslCliRegistrationOperation(distro, () => + reconcileDistro(distro) + ) + } + }) + ) + return results +} diff --git a/src/main/cli/wsl-cli-registration-registry.test.ts b/src/main/cli/wsl-cli-registration-registry.test.ts new file mode 100644 index 00000000000..95bf554a104 --- /dev/null +++ b/src/main/cli/wsl-cli-registration-registry.test.ts @@ -0,0 +1,233 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + getWslCliRegistrationCandidates, + recordWslCliRegistrationObservations, + recordWslCliRegistrationRemoved +} from './wsl-cli-registration-registry' + +describe('WSL CLI registration registry', () => { + let userDataPath: string + + beforeEach(async () => { + userDataPath = await mkdtemp(join(tmpdir(), 'orca-wsl-cli-registry-')) + }) + + afterEach(async () => { + await rm(userDataPath, { recursive: true, force: true }) + }) + + it('discovers each distro once while continuing to reconcile managed registrations', async () => { + await expect( + getWslCliRegistrationCandidates(userDataPath, ['Ubuntu', 'Debian']) + ).resolves.toEqual(['Ubuntu', 'Debian']) + + await recordWslCliRegistrationObservations(userDataPath, [ + { distro: 'Ubuntu', inspected: true, managed: false }, + { distro: 'Debian', inspected: true, managed: true } + ]) + + await expect( + getWslCliRegistrationCandidates(userDataPath, ['ubuntu', 'Debian', 'Fedora']) + ).resolves.toEqual(['Debian', 'Fedora']) + const state = JSON.parse( + await readFile(join(userDataPath, 'wsl-cli-registrations.json'), 'utf8') + ) as Record + expect(state).toMatchObject({ + schemaVersion: 2, + registeredDistros: ['Debian'], + inspectionTimes: { + ubuntu: expect.any(Number), + debian: expect.any(Number) + } + }) + }) + + it('skips a registered distro already reconciled by this build against this launcher', async () => { + const reconciled = { target: 'C:\\Orca\\resources\\bin\\orca.exe', appVersion: '1.4.138' } + await recordWslCliRegistrationObservations(userDataPath, [ + { distro: 'Ubuntu', inspected: true, managed: true, reconciled } + ]) + + await expect( + getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'], { + currentTarget: reconciled.target, + appVersion: reconciled.appVersion + }) + ).resolves.toEqual([]) + // A launcher move or app update re-probes the registered distro. + await expect( + getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'], { + currentTarget: 'D:\\Elsewhere\\orca.exe', + appVersion: reconciled.appVersion + }) + ).resolves.toEqual(['Ubuntu']) + await expect( + getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'], { + currentTarget: reconciled.target, + appVersion: '1.4.139' + }) + ).resolves.toEqual(['Ubuntu']) + await expect( + getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'], { + currentTarget: reconciled.target, + appVersion: reconciled.appVersion, + now: Date.now() + 365 * 24 * 60 * 60 * 1_000 + }) + ).resolves.toEqual([]) + }) + + it('records unsupported inspections without changing registration ownership', async () => { + await recordWslCliRegistrationObservations(userDataPath, [ + { distro: 'Ubuntu', inspected: true, managed: true } + ]) + + await recordWslCliRegistrationObservations( + userDataPath, + [{ distro: 'Ubuntu', inspected: true, managed: null, reconciled: null }], + { now: 1_000 } + ) + const state = JSON.parse( + await readFile(join(userDataPath, 'wsl-cli-registrations.json'), 'utf8') + ) as { registeredDistros: string[] } + expect(state.registeredDistros).toEqual(['Ubuntu']) + + await recordWslCliRegistrationObservations( + userDataPath, + [{ distro: 'Fedora', inspected: true, managed: null }], + { now: 1_000 } + ) + // Unregistered unsupported distros gain the negative-inspection TTL. + await expect( + getWslCliRegistrationCandidates(userDataPath, ['Fedora'], { + now: 2_000, + negativeInspectionTtlMs: 10_000 + }) + ).resolves.toEqual([]) + await expect( + getWslCliRegistrationCandidates(userDataPath, ['Fedora'], { + now: 12_000, + negativeInspectionTtlMs: 10_000 + }) + ).resolves.toEqual(['Fedora']) + }) + + it('serializes concurrent registry updates without losing a distro', async () => { + const updates = Promise.all([ + recordWslCliRegistrationObservations(userDataPath, [ + { distro: 'Ubuntu', inspected: true, managed: true } + ]), + recordWslCliRegistrationObservations(userDataPath, [ + { distro: 'Debian', inspected: true, managed: true } + ]) + ]) + + // Reads join the write queue, so startup cannot observe a half-updated registry. + await expect( + getWslCliRegistrationCandidates(userDataPath, ['Ubuntu', 'Debian']) + ).resolves.toEqual(['Ubuntu', 'Debian']) + await updates + const state = JSON.parse( + await readFile(join(userDataPath, 'wsl-cli-registrations.json'), 'utf8') + ) as { registeredDistros: string[] } + expect(state.registeredDistros).toEqual(['Ubuntu', 'Debian']) + }) + + it('rediscovers available distros when the registry is corrupt', async () => { + await mkdir(userDataPath, { recursive: true }) + await writeFile(join(userDataPath, 'wsl-cli-registrations.json'), '{broken', 'utf8') + + await expect( + getWslCliRegistrationCandidates(userDataPath, ['Ubuntu', 'Debian']) + ).resolves.toEqual(['Ubuntu', 'Debian']) + }) + + it('stops reconciling a registration removed through Settings', async () => { + await recordWslCliRegistrationObservations(userDataPath, [ + { distro: 'Ubuntu', inspected: true, managed: true } + ]) + await recordWslCliRegistrationRemoved(userDataPath, 'ubuntu') + + await expect(getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'])).resolves.toEqual([]) + }) + + it('periodically re-inspects a negative entry so restored distros are discovered', async () => { + await recordWslCliRegistrationObservations( + userDataPath, + [{ distro: 'Ubuntu', inspected: true, managed: false }], + { now: 1_000 } + ) + + await expect( + getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'], { + now: 1_001, + negativeInspectionTtlMs: 10_000 + }) + ).resolves.toEqual([]) + await expect( + getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'], { + now: 11_001, + negativeInspectionTtlMs: 10_000 + }) + ).resolves.toEqual(['Ubuntu']) + }) + + it('rediscovers negative entries after the system clock moves backward', async () => { + await recordWslCliRegistrationObservations( + userDataPath, + [{ distro: 'Ubuntu', inspected: true, managed: false }], + { now: 100_000 } + ) + + await expect( + getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'], { + now: 1_000, + negativeInspectionTtlMs: 10_000 + }) + ).resolves.toEqual(['Ubuntu']) + }) + + it('safely rediscovers schema-v1 negative entries with no inspection time', async () => { + await writeFile( + join(userDataPath, 'wsl-cli-registrations.json'), + JSON.stringify({ + schemaVersion: 1, + registeredDistros: ['Debian'], + inspectedDistros: ['Ubuntu', 'Debian'] + }), + 'utf8' + ) + + await expect( + getWslCliRegistrationCandidates(userDataPath, ['Ubuntu', 'Debian'], { + now: 1, + negativeInspectionTtlMs: 1_000_000 + }) + ).resolves.toEqual(['Ubuntu', 'Debian']) + }) + + it('caps inspection bookkeeping while always keeping registered distros', async () => { + const observations = Array.from({ length: 70 }, (_, index) => ({ + distro: `Distro${index}`, + inspected: true, + managed: false as const + })) + for (const [index, observation] of observations.entries()) { + await recordWslCliRegistrationObservations(userDataPath, [observation], { now: index }) + } + await recordWslCliRegistrationObservations( + userDataPath, + [{ distro: 'Managed Oldest', inspected: true, managed: true }], + { now: 0 } + ) + + const state = JSON.parse( + await readFile(join(userDataPath, 'wsl-cli-registrations.json'), 'utf8') + ) as { registeredDistros: string[]; inspectionTimes: Record } + expect(state.registeredDistros).toEqual(['Managed Oldest']) + expect(Object.keys(state.inspectionTimes).length).toBeLessThanOrEqual(65) + expect(state.inspectionTimes['managed oldest']).toBe(0) + }) +}) diff --git a/src/main/cli/wsl-cli-registration-registry.ts b/src/main/cli/wsl-cli-registration-registry.ts new file mode 100644 index 00000000000..d1a84a42f52 --- /dev/null +++ b/src/main/cli/wsl-cli-registration-registry.ts @@ -0,0 +1,279 @@ +import { mkdir, readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { getKeyedSerializedQueueTail, runKeyedSerializedOperation } from './keyed-promise-queue' +import { normalizeWslDistroKey } from './wsl-cli-registration-operation' + +const REGISTRY_FILE_NAME = 'wsl-cli-registrations.json' +const REGISTRY_SCHEMA_VERSION = 2 +const DEFAULT_NEGATIVE_INSPECTION_TTL_MS = 7 * 24 * 60 * 60 * 1_000 +// Why: the registry is advisory; cap per-distro bookkeeping so hosts that +// cycle many uniquely named distros cannot grow the file without bound. +const MAX_INSPECTION_ENTRIES = 64 + +type WslCliRegistrationReconciliation = { + target: string + appVersion: string +} + +type WslCliRegistrationRegistryState = { + schemaVersion: 2 + registeredDistros: string[] + inspectionTimes: Record + reconciliations: Record +} + +type WslCliRegistrationRegistryTiming = { + now?: number + negativeInspectionTtlMs?: number + // Why: a registered distro already reconciled against this exact launcher + // by this exact app build has nothing to repair; skipping it avoids booting + // its VM on every startup while still re-probing after each app update. + currentTarget?: string | null + appVersion?: string | null +} + +export type WslCliRegistrationObservation = { + distro: string + inspected: boolean + // Why: null records an inspection without changing registration ownership — + // used for 'unsupported' probes where managed-ness could not be determined. + managed: boolean | null + reconciled?: WslCliRegistrationReconciliation | null +} + +const writeQueues = new Map>() + +function emptyState(): WslCliRegistrationRegistryState { + return { + schemaVersion: REGISTRY_SCHEMA_VERSION, + registeredDistros: [], + inspectionTimes: {}, + reconciliations: {} + } +} + +function uniqueDistros(value: unknown): string[] { + if (!Array.isArray(value)) { + return [] + } + const seen = new Set() + const distros: string[] = [] + for (const entry of value) { + if (typeof entry !== 'string' || !entry.trim()) { + continue + } + const distro = entry.trim() + const key = normalizeWslDistroKey(distro) + if (!seen.has(key)) { + seen.add(key) + distros.push(distro) + } + } + return distros +} + +function parseReconciliations(value: unknown): Record { + if (!value || typeof value !== 'object') { + return {} + } + return Object.fromEntries( + Object.entries(value).filter( + (entry): entry is [string, WslCliRegistrationReconciliation] => + !!entry[1] && + typeof entry[1] === 'object' && + typeof (entry[1] as WslCliRegistrationReconciliation).target === 'string' && + typeof (entry[1] as WslCliRegistrationReconciliation).appVersion === 'string' + ) + ) +} + +function parseState(content: string): WslCliRegistrationRegistryState { + try { + const parsed = JSON.parse(content) as Record + if (parsed.schemaVersion !== 1 && parsed.schemaVersion !== REGISTRY_SCHEMA_VERSION) { + return emptyState() + } + const inspectionTimes = + parsed.inspectionTimes && typeof parsed.inspectionTimes === 'object' + ? Object.fromEntries( + Object.entries(parsed.inspectionTimes).filter( + (entry): entry is [string, number] => + typeof entry[1] === 'number' && Number.isFinite(entry[1]) && entry[1] >= 0 + ) + ) + : {} + return { + schemaVersion: REGISTRY_SCHEMA_VERSION, + registeredDistros: uniqueDistros(parsed.registeredDistros), + inspectionTimes, + reconciliations: parseReconciliations(parsed.reconciliations) + } + } catch { + // Why: a corrupt advisory registry must trigger safe rediscovery rather + // than preventing managed registrations from receiving future updates. + return emptyState() + } +} + +function isMissingError(error: unknown): boolean { + return (error as NodeJS.ErrnoException)?.code === 'ENOENT' +} + +function getRegistryPath(userDataPath: string): string { + return join(userDataPath, REGISTRY_FILE_NAME) +} + +async function readState(userDataPath: string): Promise { + try { + return parseState(await readFile(getRegistryPath(userDataPath), 'utf8')) + } catch (error) { + if (isMissingError(error)) { + return emptyState() + } + throw error + } +} + +function upsertDistro(distros: string[], distro: string): string[] { + const key = normalizeWslDistroKey(distro) + const existingIndex = distros.findIndex((entry) => normalizeWslDistroKey(entry) === key) + if (existingIndex < 0) { + return [...distros, distro.trim()] + } + return distros.map((entry, index) => (index === existingIndex ? distro.trim() : entry)) +} + +function removeDistro(distros: string[], distro: string): string[] { + const key = normalizeWslDistroKey(distro) + return distros.filter((entry) => normalizeWslDistroKey(entry) !== key) +} + +async function writeState( + userDataPath: string, + state: WslCliRegistrationRegistryState +): Promise { + await mkdir(userDataPath, { recursive: true }) + // Why: userData writes on Windows can hit Chromium's Protected-DACL EPERM; + // writeFileAtomically carries the ACL-repair retry a plain rename lacks. + writeFileAtomically(getRegistryPath(userDataPath), `${JSON.stringify(state, null, 2)}\n`) +} + +function capInspectionEntries( + state: WslCliRegistrationRegistryState +): WslCliRegistrationRegistryState { + const registered = new Set(state.registeredDistros.map(normalizeWslDistroKey)) + const entries = Object.entries(state.inspectionTimes) + const inspectionTimes = + entries.length <= MAX_INSPECTION_ENTRIES + ? state.inspectionTimes + : Object.fromEntries( + entries + .sort((a, b) => b[1] - a[1]) + .filter((entry, index) => index < MAX_INSPECTION_ENTRIES || registered.has(entry[0])) + ) + const reconciliations = Object.fromEntries( + Object.entries(state.reconciliations).filter(([key]) => registered.has(key)) + ) + return { ...state, inspectionTimes, reconciliations } +} + +function updateState( + userDataPath: string, + update: (state: WslCliRegistrationRegistryState) => WslCliRegistrationRegistryState +): Promise { + return runKeyedSerializedOperation(writeQueues, getRegistryPath(userDataPath), async () => { + await writeState(userDataPath, capInspectionEntries(update(await readState(userDataPath)))) + }) +} + +export async function getWslCliRegistrationCandidates( + userDataPath: string, + availableDistros: string[], + timing: WslCliRegistrationRegistryTiming = {} +): Promise { + // Why: the stored queue tail never rejects, so a failed concurrent write + // cannot abort candidate discovery; reads still see fully applied updates. + await getKeyedSerializedQueueTail(writeQueues, getRegistryPath(userDataPath)) + const state = await readState(userDataPath) + const registered = new Set(state.registeredDistros.map(normalizeWslDistroKey)) + const now = timing.now ?? Date.now() + const negativeInspectionTtlMs = + timing.negativeInspectionTtlMs ?? DEFAULT_NEGATIVE_INSPECTION_TTL_MS + return uniqueDistros(availableDistros).filter((distro) => { + const key = normalizeWslDistroKey(distro) + if (registered.has(key)) { + const reconciliation = state.reconciliations[key] + return !( + reconciliation && + timing.currentTarget && + reconciliation.target === timing.currentTarget && + reconciliation.appVersion === (timing.appVersion ?? '') + ) + } + const inspectedAt = state.inspectionTimes[key] + return ( + inspectedAt === undefined || inspectedAt > now || now - inspectedAt >= negativeInspectionTtlMs + ) + }) +} + +export function recordWslCliRegistrationObservations( + userDataPath: string, + observations: WslCliRegistrationObservation[], + timing: Pick = {} +): Promise { + const effective = observations.filter( + (observation) => observation.inspected && observation.distro.trim() + ) + if (effective.length === 0) { + return Promise.resolve() + } + return updateState(userDataPath, (state) => { + let registeredDistros = state.registeredDistros + let inspectionTimes = state.inspectionTimes + let reconciliations = state.reconciliations + const now = timing.now ?? Date.now() + for (const observation of effective) { + const key = normalizeWslDistroKey(observation.distro) + inspectionTimes = { ...inspectionTimes, [key]: now } + if (observation.managed === true) { + registeredDistros = upsertDistro(registeredDistros, observation.distro) + } else if (observation.managed === false) { + registeredDistros = removeDistro(registeredDistros, observation.distro) + } + if (observation.reconciled !== undefined) { + if (observation.reconciled === null) { + const { [key]: _removed, ...rest } = reconciliations + reconciliations = rest + } else { + reconciliations = { ...reconciliations, [key]: observation.reconciled } + } + } + } + return { + schemaVersion: REGISTRY_SCHEMA_VERSION, + registeredDistros, + inspectionTimes, + reconciliations + } + }) +} + +export function recordWslCliRegistrationInstalled( + userDataPath: string, + distro: string +): Promise { + return recordWslCliRegistrationObservations(userDataPath, [ + { distro, inspected: true, managed: true } + ]) +} + +export function recordWslCliRegistrationRemoved( + userDataPath: string, + distro: string +): Promise { + return recordWslCliRegistrationObservations(userDataPath, [ + { distro, inspected: true, managed: false, reconciled: null } + ]) +} diff --git a/src/main/cli/wsl-cli-scripts.ts b/src/main/cli/wsl-cli-scripts.ts index 70e1b16fb25..3297b9be9c1 100644 --- a/src/main/cli/wsl-cli-scripts.ts +++ b/src/main/cli/wsl-cli-scripts.ts @@ -20,34 +20,54 @@ else echo "Orca WSL CLI requires Windows interop and could not find powershell.exe." >&2 exit 1 fi +# Why: a shell can outlive a deleted worktree; keep explicit CLI selectors and +# help usable, and repair cwd before any WSL interop tool tries to resolve it. +ORCA_WSL_CWD=$(pwd -P 2>/dev/null) || { + ORCA_WSL_CWD=/ + cd / +} ORCA_BRIDGE_PS1_WIN=$(wslpath -w "$ORCA_BRIDGE_PS1") -exec "$ORCA_POWERSHELL" -NoProfile -ExecutionPolicy Bypass -File "$ORCA_BRIDGE_PS1_WIN" "$ORCA_WIN_LAUNCHER" "$@" +ORCA_WSL_CWD_WIN=$(wslpath -w "$ORCA_WSL_CWD") +exec "$ORCA_POWERSHELL" -NoProfile -ExecutionPolicy Bypass -File "$ORCA_BRIDGE_PS1_WIN" "$ORCA_WIN_LAUNCHER" -WslCwd "$ORCA_WSL_CWD_WIN" "$@" ` } export function buildWslBridgeScript(): string { return `${BRIDGE_MANAGED_MARKER} +[CmdletBinding(PositionalBinding=$false)] param( - [Parameter(Mandatory=$true)] + [Parameter(Mandatory=$true, Position=0)] [string]$OrcaLauncher, + [string]$WslCwd, + [Parameter(ValueFromRemainingArguments=$true)] [string[]]$ForwardArgs ) +$exitCode = 0 try { + if ([string]::IsNullOrEmpty($WslCwd)) { + Remove-Item Env:ORCA_CLI_CWD -ErrorAction SilentlyContinue + } else { + $env:ORCA_CLI_CWD = $WslCwd + } + Push-Location -LiteralPath (Split-Path -Parent $OrcaLauncher) & $OrcaLauncher @ForwardArgs - if (-not $?) { - exit 1 - } if ($null -eq $LASTEXITCODE) { - exit 0 + if (-not $?) { + $exitCode = 1 + } else { + $exitCode = 0 + } + } else { + $exitCode = $LASTEXITCODE } - exit $LASTEXITCODE } catch { Write-Error $_ - exit 1 + $exitCode = 1 } +exit $exitCode ` } @@ -71,13 +91,35 @@ export function buildSafeReplaceGuard(path: string, managedMarker: string): stri ].join('\n') } -export function buildSafeRemoveCommand(commandPath: string): string { +export function buildRegistrationLockPrelude(commandPath: string): string { + const lockDir = getPosixDirname(getBridgePathFromCommandPath(commandPath)) + // Why: the per-distro queue only serializes one Orca process; flock covers + // a second install (e.g. stable + nightly) mutating the same distro files. + return [ + `if command -v flock >/dev/null 2>&1 && mkdir -p ${quoteShell(lockDir)} 2>/dev/null; then`, + ` exec 9>${quoteShell(`${lockDir}/.orca-wsl-cli.lock`)}`, + ' flock -x -w 30 9', + 'fi' + ].join('\n') +} + +export function buildManagedLegacyRemoveCommand(quotedLegacyCommandPath: string): string { + // Why: remove only the Orca-managed pre-rename wrapper; user-owned `orca` + // commands and symlinks must survive. + return `if [ ! -L ${quotedLegacyCommandPath} ] && [ -f ${quotedLegacyCommandPath} ] && grep -Fq ${quoteShell(MANAGED_MARKER)} ${quotedLegacyCommandPath}; then rm -f ${quotedLegacyCommandPath}; fi` +} + +export function buildSafeRemoveCommand(commandPath: string, legacyCommandPath?: string): string { const bridgePath = getBridgePathFromCommandPath(commandPath) return [ 'set -euo pipefail', + buildRegistrationLockPrelude(commandPath), buildSafeReplaceGuard(commandPath, MANAGED_MARKER), buildSafeReplaceGuard(bridgePath, BRIDGE_MANAGED_MARKER), - `rm -f ${quoteShell(commandPath)} ${quoteShell(bridgePath)}` + `rm -f ${quoteShell(commandPath)} ${quoteShell(bridgePath)}`, + // Why: leaving a managed legacy `orca` behind lets startup reconciliation + // re-adopt it as opt-in proof and silently undo this removal. + ...(legacyCommandPath ? [buildManagedLegacyRemoveCommand(quoteShell(legacyCommandPath))] : []) ].join('\n') } diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 9958e0fe867..501229444dc 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -97,6 +97,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings floatingTerminalTriggerLocation: 'floating-button', diffDefaultView: 'inline', combinedDiffFileTreeVisibleByDefault: false, + prBotAuthorOverrides: [], notifications: { enabled: true, agentTaskComplete: true, @@ -142,7 +143,6 @@ function createSettings(overrides: Partial = {}): GlobalSettings experimentalActivity: true, experimentalTerminalAttention: false, compactWorktreeCards: false, - experimentalWorktreeSymlinks: false, terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'powershell.exe', enableGitHubAttribution: true, @@ -496,6 +496,9 @@ describe('CodexRuntimeHomeService', () => { getDefaultWslDistro: () => 'Ubuntu', getWslHome: () => wslHome })) + const wslSystemHomePath = join(wslHome, '.codex') + mkdirSync(wslSystemHomePath, { recursive: true }) + writeFileSync(join(wslSystemHomePath, 'AGENTS.md'), '# WSL instructions\n', 'utf-8') const store = createStore( createSettings({ activeCodexManagedAccountId: null, @@ -521,9 +524,12 @@ describe('CodexRuntimeHomeService', () => { expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledTimes(1) expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({ distro: 'Ubuntu', - systemCodexHomePath: join(wslHome, '.codex'), + systemCodexHomePath: wslSystemHomePath, managedCodexHomePath: wslRuntimeHomePath }) + const runtimeAgentsPath = join(wslRuntimeHomePath, 'AGENTS.md') + expect(readFileSync(runtimeAgentsPath, 'utf-8')).toBe('# WSL instructions\n') + expect(lstatSync(runtimeAgentsPath).isSymbolicLink()).toBe(false) } finally { vi.doUnmock('../codex/wsl-codex-session-bridge') vi.doUnmock('../wsl') diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 2c6259dd98e..29e181bd775 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -38,6 +38,7 @@ import { writeFileAtomically } from './fs-utils' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath, + syncCodexGlobalInstructionsIntoManagedHome, syncSystemCodexResourcesIntoManagedHome } from '../codex/codex-home-paths' import { startSystemCodexSessionBridgeInBackground } from '../codex/codex-session-bridge' @@ -141,7 +142,7 @@ export class CodexRuntimeHomeService { if (target?.runtime === 'wsl') { const wslTarget = this.resolveWslDefaultTarget(target) const syncedRuntimeHomePath = this.syncWslRuntimeForCurrentSelection(wslTarget) - this.syncWslConfigSettingsForLaunch(wslTarget, syncedRuntimeHomePath) + this.syncWslConfigAndGlobalInstructionsForLaunch(wslTarget, syncedRuntimeHomePath) const runtimeHomePath = syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) this.startWslSessionBridgeForLaunch(wslTarget, runtimeHomePath) return runtimeHomePath @@ -223,10 +224,7 @@ export class CodexRuntimeHomeService { return home ? this.joinWslPath(home, '.codex') : null } - // Why: WSL needs the same promote-then-mirror transaction as host. It keeps - // in-Orca changes while reconciling a newer external ~/.codex edit before - // advancing the per-distro baseline. Only runs on a materialized runtime. - private syncWslConfigSettingsForLaunch( + private syncWslConfigAndGlobalInstructionsForLaunch( target: CodexAccountSelectionTarget, runtimeHomePath: string | null ): void { @@ -242,6 +240,12 @@ export class CodexRuntimeHomeService { if (!systemHomePath || systemHomePath === runtimeHomePath) { return } + // Why: WSL uses a distro-local CODEX_HOME, so host resource mirroring + // cannot provide the distro user's global instructions. + syncCodexGlobalInstructionsIntoManagedHome({ + systemHomePath, + managedHomePath: runtimeHomePath + }) syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath }) } diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index a7cf0d033e2..9b185e4c357 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -102,6 +102,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings floatingTerminalTriggerLocation: 'floating-button', diffDefaultView: 'inline', combinedDiffFileTreeVisibleByDefault: false, + prBotAuthorOverrides: [], notifications: { enabled: true, agentTaskComplete: true, @@ -147,7 +148,6 @@ function createSettings(overrides: Partial = {}): GlobalSettings experimentalActivity: true, experimentalTerminalAttention: false, compactWorktreeCards: false, - experimentalWorktreeSymlinks: false, terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'powershell.exe', enableGitHubAttribution: true, diff --git a/src/main/codex/codex-config-mirror.test.ts b/src/main/codex/codex-config-mirror.test.ts index 1ef1f61dd0b..5022fff5cc0 100644 --- a/src/main/codex/codex-config-mirror.test.ts +++ b/src/main/codex/codex-config-mirror.test.ts @@ -422,6 +422,80 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { expect(runtimeConfig).not.toContain('trust_level = "trusted"') }) + it('applies a case-drifted WSL system revocation to the runtime trusted block', () => { + // Why: configs written before WSL tails compared case-sensitively can hold + // the revocation under drifted casing; err toward revoked, not trusted. + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + ["[projects.'\\\\wsl$\\Ubuntu\\home\\u\\Repo']", 'trust_level = "trusted"', ''].join('\n'), + 'utf-8' + ) + writeFileSync( + getSystemConfigPath(), + ["[projects.'\\\\wsl$\\Ubuntu\\home\\u\\repo']", 'trust_level = "untrusted"', ''].join('\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig).toContain('trust_level = "untrusted"') + expect(runtimeConfig).not.toContain('trust_level = "trusted"') + }) + + it('keeps runtime trust when the system config re-trusts the exact-cased WSL project', () => { + // Why: after a user re-grants trust, markCodexProjectTrusted appends an + // exact-cased trusted block beside a legacy drifted-case revocation; the + // loose revocation match must not revert that grant on every mirror pass. + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + ["[projects.'\\\\wsl$\\Ubuntu\\home\\u\\Repo']", 'trust_level = "trusted"', ''].join('\n'), + 'utf-8' + ) + writeFileSync( + getSystemConfigPath(), + [ + "[projects.'\\\\wsl$\\Ubuntu\\home\\u\\repo']", + 'trust_level = "untrusted"', + '', + "[projects.'\\\\wsl$\\Ubuntu\\home\\u\\Repo']", + 'trust_level = "trusted"', + '' + ].join('\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig).toContain("[projects.'\\\\wsl$\\Ubuntu\\home\\u\\Repo']") + expect(runtimeConfig).toContain('trust_level = "trusted"') + expect(runtimeConfig).toContain('trust_level = "untrusted"') + }) + + it('does not let a case-distinct POSIX system revocation clobber runtime trust', () => { + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + ['[projects."/home/u/Repo"]', 'trust_level = "trusted"', ''].join('\n'), + 'utf-8' + ) + writeFileSync( + getSystemConfigPath(), + ['[projects."/home/u/repo"]', 'trust_level = "untrusted"', ''].join('\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig.match(/\[projects\./g)).toHaveLength(2) + expect(runtimeConfig).toContain('trust_level = "untrusted"') + expect(runtimeConfig).toContain('trust_level = "trusted"') + }) + it.each([ { name: 'drive-letter casing and separators', diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index 1949a703d41..e6ca4c0b834 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -17,6 +17,7 @@ import { } from './config-toml-line-scan' import { normalizeCodexProjectPathForLookup, + normalizeCodexProjectPathForRevocationLookup, parseCodexProjectHeaderPath } from './config-toml-trust' @@ -170,10 +171,20 @@ function mergeSystemCodexConfigIntoRuntime(runtimeConfig: string, systemConfig: .filter((section) => isRuntimeProjectTomlSection(section.header)) .map((section) => getTomlSectionHeaderKey(section.header)) ) + const systemProjectSections = deduplicateProjectTomlSections( + getTomlSections(systemConfig) + ).filter((section) => isRuntimeProjectTomlSection(section.header)) const systemUntrustedProjectHeaders = new Set( - deduplicateProjectTomlSections(getTomlSections(systemConfig)) - .filter((section) => isRuntimeProjectTomlSection(section.header)) + systemProjectSections .filter((section) => getProjectTrustLevel(section.block) === 'untrusted') + .map((section) => getRevocationTomlSectionHeaderKey(section.header)) + ) + // Why: an exact-cased trusted entry in ~/.codex is the user's latest explicit + // decision for that exact project; a loosely-matched (case-drifted) revocation + // must not override it, or re-granting trust would be reverted every mirror. + const systemTrustedProjectHeaders = new Set( + systemProjectSections + .filter((section) => getProjectTrustLevel(section.block) === 'trusted') .map((section) => getTomlSectionHeaderKey(section.header)) ) // Why: ordinary Codex settings should mirror ~/.codex exactly; runtime hook @@ -187,7 +198,8 @@ function mergeSystemCodexConfigIntoRuntime(runtimeConfig: string, systemConfig: .filter( (section) => !isRuntimeProjectTomlSection(section.header) || - !systemUntrustedProjectHeaders.has(getTomlSectionHeaderKey(section.header)) + !systemUntrustedProjectHeaders.has(getRevocationTomlSectionHeaderKey(section.header)) || + systemTrustedProjectHeaders.has(getTomlSectionHeaderKey(section.header)) ) .map((section) => section.block) ]) @@ -277,6 +289,15 @@ function getTomlSectionHeaderKey(header: string): string { : `project:${normalizeCodexProjectPathForLookup(projectPath)}` } +// Why: configs written before WSL tails compared case-sensitively can hold a +// revocation under drifted casing; match it loosely so trust is not resurrected. +function getRevocationTomlSectionHeaderKey(header: string): string { + const projectPath = parseCodexProjectHeaderPath(header) + return projectPath === null + ? header.trim() + : `project:${normalizeCodexProjectPathForRevocationLookup(projectPath)}` +} + // Why: hook upsert already removes both quote representations, while its paired // Windows slash variants are required for Codex 0.140 and must remain distinct. function deduplicateProjectTomlSections(sections: TomlSection[]): TomlSection[] { diff --git a/src/main/codex/codex-home-paths.test.ts b/src/main/codex/codex-home-paths.test.ts index bd12ae95c79..54860946c22 100644 --- a/src/main/codex/codex-home-paths.test.ts +++ b/src/main/codex/codex-home-paths.test.ts @@ -21,13 +21,28 @@ const { getPathMock, homedirMock } = vi.hoisted(() => ({ })) const { fsMockState } = vi.hoisted(() => ({ - fsMockState: { failSymlink: false } + fsMockState: { + copyCount: 0, + failSymlink: false, + trackedReadCount: 0, + trackedReadPath: null as string | null + } })) vi.mock('node:fs', async () => { const actual = await vi.importActual('node:fs') return { ...actual, + cpSync: (...args: Parameters) => { + fsMockState.copyCount += 1 + return actual.cpSync(...args) + }, + readFileSync: (...args: Parameters) => { + if (args[0] === fsMockState.trackedReadPath) { + fsMockState.trackedReadCount += 1 + } + return actual.readFileSync(...args) + }, symlinkSync: (...args: Parameters) => { if (fsMockState.failSymlink) { throw new Error('symlink disabled for test') @@ -51,7 +66,10 @@ vi.mock('node:os', async () => { } }) -import { syncSystemCodexResourcesIntoManagedHome } from './codex-home-paths' +import { + syncCodexGlobalInstructionsIntoManagedHome, + syncSystemCodexResourcesIntoManagedHome +} from './codex-home-paths' let fakeHomeDir: string let userDataDir: string @@ -88,7 +106,10 @@ function mockElectronAppPaths(): void { beforeEach(() => { mockElectronAppPaths() + fsMockState.copyCount = 0 fsMockState.failSymlink = false + fsMockState.trackedReadCount = 0 + fsMockState.trackedReadPath = null fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-resource-home-')) userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-resource-user-data-')) previousUserDataPath = process.env.ORCA_USER_DATA_PATH @@ -229,4 +250,190 @@ describe('syncSystemCodexResourcesIntoManagedHome', () => { expect(lstatSync(runtimeHooksPath).isSymbolicLink()).toBe(true) expectSymbolicLinkTargetIfLinked(runtimeHooksPath, systemHooksPath) }) + + it('mirrors the global AGENTS.md into the managed runtime home so user instructions survive', () => { + const systemAgentsPath = join(getSystemCodexHomePath(), 'AGENTS.md') + const runtimeAgentsPath = join(getRuntimeCodexHomePath(), 'AGENTS.md') + writeFileSync(systemAgentsPath, '# Global instructions\n') + + syncSystemCodexResourcesIntoManagedHome() + + expect(readFileSync(runtimeAgentsPath, 'utf-8')).toBe('# Global instructions\n') + expectSymbolicLinkTargetIfLinked(runtimeAgentsPath, systemAgentsPath) + }) + + it('skips unchanged global-instruction fallback copies when symlinks fail', () => { + fsMockState.failSymlink = true + const systemAgentsPath = join(getSystemCodexHomePath(), 'AGENTS.md') + writeFileSync(systemAgentsPath, 'first\n') + + syncSystemCodexResourcesIntoManagedHome() + expect(fsMockState.copyCount).toBe(1) + syncSystemCodexResourcesIntoManagedHome() + expect(fsMockState.copyCount).toBe(1) + writeFileSync(systemAgentsPath, 'second\n') + syncSystemCodexResourcesIntoManagedHome() + + expect(fsMockState.copyCount).toBe(2) + expect(readFileSync(join(getRuntimeCodexHomePath(), 'AGENTS.md'), 'utf-8')).toBe('second\n') + }) + + it('mirrors only global instructions when explicit Codex homes are provided', () => { + const systemHomePath = getSystemCodexHomePath() + const managedHomePath = join(userDataDir, 'wsl-runtime-home') + mkdirSync(join(systemHomePath, 'skills'), { recursive: true }) + writeFileSync(join(systemHomePath, 'skills', 'system.md'), 'skill\n') + writeFileSync(join(systemHomePath, 'AGENTS.md'), '# WSL instructions\n') + + syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath }) + + const runtimeAgentsPath = join(managedHomePath, 'AGENTS.md') + expect(readFileSync(runtimeAgentsPath, 'utf-8')).toBe('# WSL instructions\n') + // Why: WSL homes are \\wsl.localhost UNC paths, so a host-side symlink would + // store a target the distro cannot resolve; global instructions must be a + // real copy even when symlinks are available. + expect(lstatSync(runtimeAgentsPath).isSymbolicLink()).toBe(false) + expect(existsSync(join(managedHomePath, 'skills'))).toBe(false) + }) + + // Why: creating file symlinks on Windows requires developer mode; the + // runtime-home integration test still enforces real-copy behavior there. + it.skipIf(process.platform === 'win32')( + 'materializes symlinked global instructions as a real file for WSL', + () => { + const systemHomePath = getSystemCodexHomePath() + const managedHomePath = join(userDataDir, 'wsl-runtime-home') + const instructionSourcePath = join(userDataDir, 'global-instructions.md') + writeFileSync(instructionSourcePath, 'linked instructions\n') + symlinkSync(instructionSourcePath, join(systemHomePath, 'AGENTS.md')) + + syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath }) + + const runtimeAgentsPath = join(managedHomePath, 'AGENTS.md') + expect(lstatSync(runtimeAgentsPath).isSymbolicLink()).toBe(false) + expect(readFileSync(runtimeAgentsPath, 'utf-8')).toBe('linked instructions\n') + } + ) + + it.skipIf(process.platform === 'win32')( + 'replaces an existing system-instruction link despite a malformed marker directory', + () => { + const systemHomePath = getSystemCodexHomePath() + const managedHomePath = join(userDataDir, 'wsl-runtime-home') + const systemAgentsPath = join(systemHomePath, 'AGENTS.md') + const runtimeAgentsPath = join(managedHomePath, 'AGENTS.md') + mkdirSync(managedHomePath, { recursive: true }) + writeFileSync(systemAgentsPath, 'system\n') + symlinkSync(systemAgentsPath, runtimeAgentsPath) + mkdirSync(join(managedHomePath, '.orca-resource-copies', 'AGENTS.md.json'), { + recursive: true + }) + + syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath }) + + expect(lstatSync(runtimeAgentsPath).isSymbolicLink()).toBe(false) + expect(readFileSync(runtimeAgentsPath, 'utf-8')).toBe('system\n') + } + ) + + it('removes an unowned copy when recording copy ownership fails', () => { + const systemHomePath = getSystemCodexHomePath() + const managedHomePath = join(userDataDir, 'wsl-runtime-home') + writeFileSync(join(systemHomePath, 'AGENTS.md'), 'system\n') + mkdirSync(managedHomePath, { recursive: true }) + writeFileSync(join(managedHomePath, '.orca-resource-copies'), 'blocks marker directory\n') + + syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath }) + + expect(existsSync(join(managedHomePath, 'AGENTS.md'))).toBe(false) + }) + + it('skips unchanged copies, then refreshes and removes owned global instructions', () => { + const systemHomePath = getSystemCodexHomePath() + const managedHomePath = join(userDataDir, 'wsl-runtime-home') + const systemAgentsPath = join(systemHomePath, 'AGENTS.md') + const runtimeAgentsPath = join(managedHomePath, 'AGENTS.md') + writeFileSync(systemAgentsPath, 'first\n') + + syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath }) + expect(fsMockState.copyCount).toBe(1) + syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath }) + expect(fsMockState.copyCount).toBe(1) + writeFileSync(systemAgentsPath, 'second\n') + syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath }) + + expect(fsMockState.copyCount).toBe(2) + expect(lstatSync(runtimeAgentsPath).isSymbolicLink()).toBe(false) + expect(readFileSync(runtimeAgentsPath, 'utf-8')).toBe('second\n') + rmSync(systemAgentsPath) + syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath }) + expect(existsSync(runtimeAgentsPath)).toBe(false) + }) + + it('replaces an owned non-file instruction entry without reading it', () => { + const systemHomePath = getSystemCodexHomePath() + const managedHomePath = join(userDataDir, 'wsl-runtime-home') + const runtimeAgentsPath = join(managedHomePath, 'AGENTS.md') + writeFileSync(join(systemHomePath, 'AGENTS.md'), 'system\n') + syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath }) + rmSync(runtimeAgentsPath) + mkdirSync(runtimeAgentsPath) + fsMockState.trackedReadPath = runtimeAgentsPath + + syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath }) + + expect(fsMockState.trackedReadCount).toBe(0) + expect(lstatSync(runtimeAgentsPath).isFile()).toBe(true) + expect(readFileSync(runtimeAgentsPath, 'utf-8')).toBe('system\n') + }) + + it('removes owned instructions instead of mirroring a non-file source', () => { + const systemHomePath = getSystemCodexHomePath() + const managedHomePath = join(userDataDir, 'wsl-runtime-home') + const systemAgentsPath = join(systemHomePath, 'AGENTS.md') + const runtimeAgentsPath = join(managedHomePath, 'AGENTS.md') + writeFileSync(systemAgentsPath, 'system\n') + syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath }) + rmSync(systemAgentsPath) + mkdirSync(systemAgentsPath) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + try { + syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath }) + } finally { + warn.mockRestore() + } + + expect(existsSync(runtimeAgentsPath)).toBe(false) + }) + + it('preserves runtime-owned global instructions in an explicit managed home', () => { + const systemHomePath = getSystemCodexHomePath() + const managedHomePath = join(userDataDir, 'wsl-runtime-home') + mkdirSync(managedHomePath, { recursive: true }) + writeFileSync(join(systemHomePath, 'AGENTS.md'), 'system\n') + writeFileSync(join(managedHomePath, 'AGENTS.md'), 'runtime\n') + + syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath }) + + expect(readFileSync(join(managedHomePath, 'AGENTS.md'), 'utf-8')).toBe('runtime\n') + }) + + it.skipIf(process.platform === 'win32')( + 'preserves an unowned dangling runtime instruction symlink', + () => { + const systemHomePath = getSystemCodexHomePath() + const managedHomePath = join(userDataDir, 'wsl-runtime-home') + const runtimeAgentsPath = join(managedHomePath, 'AGENTS.md') + const missingTargetPath = join(userDataDir, 'missing-runtime-instructions.md') + mkdirSync(managedHomePath, { recursive: true }) + writeFileSync(join(systemHomePath, 'AGENTS.md'), 'system\n') + symlinkSync(missingTargetPath, runtimeAgentsPath) + + syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath }) + + expect(lstatSync(runtimeAgentsPath).isSymbolicLink()).toBe(true) + expect(readlinkSync(runtimeAgentsPath)).toBe(missingTargetPath) + } + ) }) diff --git a/src/main/codex/codex-home-paths.ts b/src/main/codex/codex-home-paths.ts index 1f2cf9b294c..a2c397dd132 100644 --- a/src/main/codex/codex-home-paths.ts +++ b/src/main/codex/codex-home-paths.ts @@ -7,6 +7,7 @@ import { readlinkSync, rmdirSync, rmSync, + statSync, symlinkSync, unlinkSync, writeFileSync @@ -14,6 +15,8 @@ import { import { homedir } from 'node:os' import { dirname, join } from 'node:path' +const CODEX_GLOBAL_INSTRUCTIONS_ENTRY = 'AGENTS.md' + const CODEX_SYSTEM_RESOURCE_ENTRIES = [ 'skills', 'hooks', @@ -21,7 +24,8 @@ const CODEX_SYSTEM_RESOURCE_ENTRIES = [ 'plugin-state', 'profile-v2', 'themes', - 'prompts' + 'prompts', + CODEX_GLOBAL_INSTRUCTIONS_ENTRY ] as const export function getSystemCodexHomePath(): string { @@ -57,10 +61,28 @@ export function syncSystemCodexResourcesIntoManagedHome(): void { } } +export function syncCodexGlobalInstructionsIntoManagedHome({ + systemHomePath, + managedHomePath +}: { + systemHomePath: string + managedHomePath: string +}): void { + mkdirSync(managedHomePath, { recursive: true }) + // Why: this only runs for WSL runtime homes, whose system + managed homes are + // both \\wsl.localhost UNC paths. A host-side symlink there stores a Windows + // UNC target the distro cannot resolve, so copy the file like the config + // mirror does across the same boundary. + linkSystemCodexResource(systemHomePath, managedHomePath, CODEX_GLOBAL_INSTRUCTIONS_ENTRY, { + preferCopy: true + }) +} + function linkSystemCodexResource( systemHomePath: string, managedHomePath: string, - entryName: string + entryName: string, + { preferCopy = false }: { preferCopy?: boolean } = {} ): void { const sourcePath = join(systemHomePath, entryName) const targetPath = join(managedHomePath, entryName) @@ -68,10 +90,17 @@ function linkSystemCodexResource( removeCopiedResourceIfOwned(targetPath, managedHomePath, entryName, sourcePath) return } + if (entryName === CODEX_GLOBAL_INSTRUCTIONS_ENTRY && !systemResourceIsRegularFile(sourcePath)) { + removeCopiedResourceIfOwned(targetPath, managedHomePath, entryName, sourcePath) + console.warn('[codex-home] Ignoring non-file system Codex resource:', entryName) + return + } if (targetAlreadyPointsToSource(targetPath, sourcePath)) { clearCopiedResourceMarker(managedHomePath, entryName) - return + if (!preferCopy || !removeSymlinkEntry(targetPath)) { + return + } } const shouldRefreshFallbackCopy = targetIsOwnedFallbackCopy( targetPath, @@ -79,13 +108,26 @@ function linkSystemCodexResource( entryName, sourcePath ) - if (existsSync(targetPath) && !shouldRefreshFallbackCopy) { + if (pathEntryExists(targetPath) && !shouldRefreshFallbackCopy) { return } if (shouldRefreshFallbackCopy) { + // Why: WSL launch preparation runs before every Codex start. Avoid + // rewriting an unchanged file across the UNC boundary on every launch. + if ( + entryName === CODEX_GLOBAL_INSTRUCTIONS_ENTRY && + copiedFileContentsMatch(sourcePath, targetPath) + ) { + return + } rmSync(targetPath, { recursive: true, force: true }) } + if (preferCopy) { + copySystemCodexResourceAsOwnedFallback(sourcePath, targetPath, managedHomePath, entryName) + return + } + try { const sourceStat = lstatSync(sourcePath) symlinkSync( @@ -95,16 +137,84 @@ function linkSystemCodexResource( ) clearCopiedResourceMarker(managedHomePath, entryName) } catch (error) { + // Why: Windows can reject file symlinks outside developer mode. Copy is + // a fallback for launch-time resources; mark ownership so later syncs can + // refresh the copy without touching user-created runtime resources. + copySystemCodexResourceAsOwnedFallback( + sourcePath, + targetPath, + managedHomePath, + entryName, + error + ) + } +} + +function copySystemCodexResourceAsOwnedFallback( + sourcePath: string, + targetPath: string, + managedHomePath: string, + entryName: string, + symlinkError?: unknown +): void { + try { + rmSync(targetPath, { recursive: true, force: true }) + cpSync(sourcePath, targetPath, { + recursive: true, + force: false, + errorOnExist: true, + // Why: dotfile managers commonly symlink AGENTS.md. WSL needs the file + // contents because a copied host-side link is not usable in the distro. + dereference: entryName === CODEX_GLOBAL_INSTRUCTIONS_ENTRY + }) + markCopiedResource(managedHomePath, entryName, sourcePath) + } catch (copyError) { + // Why: an unmarked copy cannot be refreshed or safely removed later. + // Roll it back instead of stranding stale instructions in the runtime home. try { rmSync(targetPath, { recursive: true, force: true }) - // Why: Windows can reject file symlinks outside developer mode. Copy is - // a fallback for launch-time resources; mark ownership so later syncs can - // refresh the copy without touching user-created runtime resources. - cpSync(sourcePath, targetPath, { recursive: true, force: false, errorOnExist: true }) - markCopiedResource(managedHomePath, entryName, sourcePath) - } catch { - console.warn('[codex-home] Failed to link system Codex resource:', entryName, error) + } catch (cleanupError) { + console.warn( + '[codex-home] Failed to remove incomplete resource copy:', + entryName, + cleanupError + ) } + console.warn( + '[codex-home] Failed to mirror system Codex resource:', + entryName, + symlinkError ?? copyError + ) + } +} + +function systemResourceIsRegularFile(sourcePath: string): boolean { + try { + return statSync(sourcePath).isFile() + } catch { + return false + } +} + +function pathEntryExists(entryPath: string): boolean { + try { + lstatSync(entryPath) + return true + } catch { + return false + } +} + +function copiedFileContentsMatch(sourcePath: string, targetPath: string): boolean { + try { + // Why: reading a FIFO or device synchronously can block Codex launch. + // Follow source symlinks, but only compare two regular files. + if (!statSync(sourcePath).isFile() || !lstatSync(targetPath).isFile()) { + return false + } + return readFileSync(sourcePath).equals(readFileSync(targetPath)) + } catch { + return false } } @@ -159,7 +269,12 @@ function readCopiedResourceSourcePath(managedHomePath: string, entryName: string } function clearCopiedResourceMarker(managedHomePath: string, entryName: string): void { - rmSync(getResourceCopyMarkerPath(managedHomePath, entryName), { force: true }) + // Why: a malformed marker directory must not block Codex launch or prevent + // an owned resource from being repaired. + rmSync(getResourceCopyMarkerPath(managedHomePath, entryName), { + recursive: true, + force: true + }) } function targetIsOwnedFallbackCopy( diff --git a/src/main/codex/config-toml-trust.test.ts b/src/main/codex/config-toml-trust.test.ts index 4f4dcf4861f..9f8efbea403 100644 --- a/src/main/codex/config-toml-trust.test.ts +++ b/src/main/codex/config-toml-trust.test.ts @@ -17,6 +17,8 @@ import { computeTrustedHash, escapeTomlString, getCodexCanonicalTrustPath, + normalizeCodexProjectPathForLookup, + normalizeCodexProjectPathForRevocationLookup, parseTrustKey, readHookTrustEntries, removeHookTrustEntries, @@ -961,39 +963,36 @@ describe('upsertHookTrustEntries', () => { expect(written).not.toContain(`[hooks.state.'C:\\Users\\O'Connor`) }) - it.skipIf(process.platform !== 'win32')( - 'finds a Codex-written block with lowercased username when Orca key has mixed-case username', - () => { - // Why: realpathSync.native casing can differ between what Codex wrote - // (C:\Users\rod\...) and what Orca resolves (C:\Users\Rod\...). - // normalizeHookTrustKeyForLookup case-folds on Windows so the existing block is - // replaced rather than a duplicate appended. - const lowercasePath = 'C:\\Users\\rod\\AppData\\Roaming\\orca\\hooks.json' - const mixedCasePath = 'C:\\Users\\Rod\\AppData\\Roaming\\orca\\hooks.json' - const literalKey = `${lowercasePath}:session_start:0:0` - const original = [ - `[hooks.state.'${literalKey}']`, - 'enabled = true', - 'trusted_hash = "sha256:LOWERCASE"', - '' - ].join('\n') - writeFileSync(configPath, original, 'utf-8') + it('finds a Codex-written block with lowercased username when Orca key has mixed-case username', () => { + // Why: realpathSync.native casing can differ between what Codex wrote + // (C:\Users\rod\...) and what Orca resolves (C:\Users\Rod\...). + // normalizeHookTrustKeyForLookup case-folds Windows-shaped paths so the + // existing block is replaced rather than a duplicate appended. + const lowercasePath = 'C:\\Users\\rod\\AppData\\Roaming\\orca\\hooks.json' + const mixedCasePath = 'C:\\Users\\Rod\\AppData\\Roaming\\orca\\hooks.json' + const literalKey = `${lowercasePath}:session_start:0:0` + const original = [ + `[hooks.state.'${literalKey}']`, + 'enabled = true', + 'trusted_hash = "sha256:LOWERCASE"', + '' + ].join('\n') + writeFileSync(configPath, original, 'utf-8') - const entry: CodexTrustEntry = { - sourcePath: mixedCasePath, - eventLabel: 'session_start', - groupIndex: 0, - handlerIndex: 0, - command: 'echo session' - } - upsertHookTrustEntries(configPath, [entry]) - - const written = readFileSync(configPath, 'utf-8') - expect((written.match(/\[hooks\.state\./g) ?? []).length).toBe(2) - expect(written).not.toContain('sha256:LOWERCASE') - expect(written).toContain(`trusted_hash = "${computeTrustedHash(entry)}"`) + const entry: CodexTrustEntry = { + sourcePath: mixedCasePath, + eventLabel: 'session_start', + groupIndex: 0, + handlerIndex: 0, + command: 'echo session' } - ) + upsertHookTrustEntries(configPath, [entry]) + + const written = readFileSync(configPath, 'utf-8') + expect((written.match(/\[hooks\.state\./g) ?? []).length).toBe(2) + expect(written).not.toContain('sha256:LOWERCASE') + expect(written).toContain(`trusted_hash = "${computeTrustedHash(entry)}"`) + }) }) describe('upsertProjectTrustLevel', () => { @@ -1149,6 +1148,48 @@ describe('upsertProjectTrustLevel', () => { expect(updated).toContain('trust_level = "trusted"') }) + it('keeps case-distinct WSL Linux project paths as separate trust blocks', () => { + // Why: the \\wsl$\ share is case-insensitive on Windows, but the + // Linux path underneath is not — .../Repo and .../repo are two projects. + const existingPath = '\\\\wsl$\\Ubuntu\\home\\u\\Repo' + const incomingPath = '\\\\wsl$\\Ubuntu\\home\\u\\repo' + const original = [`[projects.'${existingPath}']`, 'trust_level = "untrusted"', ''].join('\n') + + const updated = upsertProjectTrustLevelInContent(original, incomingPath, 'trusted', { + alreadyCanonical: true + }) + + expect(updated.match(/\[projects\./g)).toHaveLength(2) + expect(updated).toContain(`[projects.'${existingPath}']`) + // Why: serializer writes basic-string headers via escapeTomlString; assert + // that form so the fixture can't drift from real header matching. + expect(updated).toContain(`[projects."${escapeTomlString(incomingPath)}"]`) + expect(updated).toContain('trust_level = "untrusted"') + expect(updated).toContain('trust_level = "trusted"') + }) + + it('updates the same WSL project block across wsl$ and wsl.localhost spellings', () => { + // Why: the two share spellings alias the same distro filesystem, so a + // revoked project must not be re-trusted under the other spelling. + const original = [ + "[projects.'\\\\wsl$\\Ubuntu\\home\\u\\proj']", + 'trust_level = "untrusted"', + '' + ].join('\n') + + const updated = upsertProjectTrustLevelInContent( + original, + '\\\\wsl.localhost\\Ubuntu\\home\\u\\proj', + 'trusted', + { alreadyCanonical: true } + ) + + expect(updated.match(/\[projects\./g)).toHaveLength(1) + expect(updated).toContain("[projects.'\\\\wsl$\\Ubuntu\\home\\u\\proj']") + expect(updated).toContain('trust_level = "trusted"') + expect(updated).not.toContain('trust_level = "untrusted"') + }) + it('matches a literal-string POSIX project path containing a quote and backslash', () => { const projectPath = '/tmp/with"quote\\and-backslash' const original = [`[projects.'${projectPath}']`, 'trust_level = "untrusted"', ''].join('\n') @@ -1186,6 +1227,78 @@ describe('upsertProjectTrustLevel', () => { }) }) +describe('normalizeCodexProjectPathForLookup', () => { + it('dedupes drive-letter casing and separators for true Windows paths', () => { + expect(normalizeCodexProjectPathForLookup('C:\\repo')).toBe( + normalizeCodexProjectPathForLookup('c:/repo') + ) + }) + + it('keeps case-distinct WSL Linux paths distinct', () => { + expect(normalizeCodexProjectPathForLookup('\\\\wsl$\\Ubuntu\\home\\u\\Repo')).not.toBe( + normalizeCodexProjectPathForLookup('\\\\wsl$\\Ubuntu\\home\\u\\repo') + ) + }) + + it('merges separator and distro-casing variants of the same WSL path', () => { + // Why: separators and the case-insensitive \\wsl$\ share may drift, + // but the same Linux path must still resolve to one trust key. + expect(normalizeCodexProjectPathForLookup('\\\\wsl$\\Ubuntu\\home\\u\\proj')).toBe( + normalizeCodexProjectPathForLookup('//WSL$/ubuntu/home/u/proj') + ) + }) + + it('treats wsl.localhost like the wsl$ share for the case-sensitive tail', () => { + expect(normalizeCodexProjectPathForLookup('\\\\wsl.localhost\\Ubuntu\\home\\u\\Repo')).not.toBe( + normalizeCodexProjectPathForLookup('\\\\wsl.localhost\\Ubuntu\\home\\u\\repo') + ) + expect(normalizeCodexProjectPathForLookup('\\\\WSL.LOCALHOST\\Ubuntu\\home\\u\\proj')).toBe( + normalizeCodexProjectPathForLookup('//wsl.localhost/ubuntu/home/u/proj') + ) + }) + + it('folds the wsl$ and wsl.localhost spellings of the same path to one key', () => { + expect(normalizeCodexProjectPathForLookup('\\\\wsl$\\Ubuntu\\home\\u\\Proj')).toBe( + normalizeCodexProjectPathForLookup('\\\\wsl.localhost\\Ubuntu\\home\\u\\Proj') + ) + }) + + it('folds drvfs automount tails case-insensitively like the native drive path', () => { + // Why: /mnt/ is NTFS through drvfs, case-insensitive like C:\ itself. + expect(normalizeCodexProjectPathForLookup('\\\\wsl$\\Ubuntu\\mnt\\c\\Users\\Bob\\Repo')).toBe( + normalizeCodexProjectPathForLookup('//wsl.localhost/ubuntu/mnt/c/users/bob/repo') + ) + // /mnt/wsl is tmpfs, not a drvfs drive mount — its tail stays case-sensitive. + expect(normalizeCodexProjectPathForLookup('\\\\wsl$\\Ubuntu\\mnt\\wsl\\Repo')).not.toBe( + normalizeCodexProjectPathForLookup('\\\\wsl$\\Ubuntu\\mnt\\wsl\\repo') + ) + }) + + it('still case-folds normal UNC shares', () => { + expect(normalizeCodexProjectPathForLookup('\\\\server\\share\\Proj')).toBe( + normalizeCodexProjectPathForLookup('//SERVER/share/proj') + ) + }) + + it('leaves POSIX paths untouched', () => { + expect(normalizeCodexProjectPathForLookup('/home/u/Repo')).toBe('/home/u/Repo') + }) +}) + +describe('normalizeCodexProjectPathForRevocationLookup', () => { + it('folds WSL tails fully so drifted-case legacy revocations still match', () => { + expect(normalizeCodexProjectPathForRevocationLookup('\\\\wsl$\\Ubuntu\\home\\u\\Repo')).toBe( + normalizeCodexProjectPathForRevocationLookup('//wsl.localhost/ubuntu/home/u/repo') + ) + }) + + it('keeps POSIX paths case-sensitive', () => { + expect(normalizeCodexProjectPathForRevocationLookup('/home/u/Repo')).not.toBe( + normalizeCodexProjectPathForRevocationLookup('/home/u/repo') + ) + }) +}) + describe('removeHookTrustEntries', () => { it('is a no-op (creates no file) when the config does not exist', () => { removeHookTrustEntries(configPath, ['/x/hooks.json:pre_tool_use:0:0']) @@ -1494,54 +1607,78 @@ describe('readHookTrustEntries', () => { }) }) - it.skipIf(process.platform !== 'win32')( - 'supports case-insensitive lookups for Windows hook trust keys read from config', - () => { - // Why: Codex and realpathSync.native can disagree on user-path casing; - // status checks still need Map.get(computeTrustKey(...)) to find the row. - const rawKey = 'C:\\Users\\rod\\AppData\\Roaming\\orca\\hooks.json:session_start:0:0' - const lookupKey = 'C:/Users/Rod/AppData/Roaming/orca/hooks.json:session_start:0:0' - const original = [ - `[hooks.state.'${rawKey}']`, + it('supports case-insensitive lookups for Windows hook trust keys read from config', () => { + // Why: Codex and realpathSync.native can disagree on user-path casing; + // status checks still need Map.get(computeTrustKey(...)) to find the row. + const rawKey = 'C:\\Users\\rod\\AppData\\Roaming\\orca\\hooks.json:session_start:0:0' + const lookupKey = 'C:/Users/Rod/AppData/Roaming/orca/hooks.json:session_start:0:0' + const original = [ + `[hooks.state.'${rawKey}']`, + 'enabled = true', + 'trusted_hash = "sha256:CASE"', + '' + ].join('\n') + writeFileSync(configPath, original, 'utf-8') + + const result = readHookTrustEntries(configPath) + + expect(result.get(lookupKey)).toEqual({ trustedHash: 'sha256:CASE', enabled: true }) + }) + + it('keeps POSIX-shaped hook trust paths case-sensitive', () => { + const upperKey = '/windows/d/Repo/hooks.json:session_start:0:0' + const lowerKey = '/windows/d/repo/hooks.json:session_start:0:0' + writeFileSync( + configPath, + [ + `[hooks.state."${upperKey}"]`, 'enabled = true', - 'trusted_hash = "sha256:CASE"', + 'trusted_hash = "sha256:UPPER"', + '', + `[hooks.state."${lowerKey}"]`, + 'enabled = true', + 'trusted_hash = "sha256:LOWER"', '' - ].join('\n') - writeFileSync(configPath, original, 'utf-8') + ].join('\n'), + 'utf-8' + ) - const result = readHookTrustEntries(configPath) + const result = readHookTrustEntries(configPath) - expect(result.get(lookupKey)).toEqual({ trustedHash: 'sha256:CASE', enabled: true }) - } - ) + expect(result.get(upperKey)?.trustedHash).toBe('sha256:UPPER') + expect(result.get(lowerKey)?.trustedHash).toBe('sha256:LOWER') + expect(result.size).toBe(2) + }) - it.skipIf(process.platform !== 'win32')( - 'keeps WSL hook trust paths case-sensitive on a Windows host', - () => { - const upperKey = '/windows/d/Repo/hooks.json:session_start:0:0' - const lowerKey = '/windows/d/repo/hooks.json:session_start:0:0' - writeFileSync( - configPath, - [ - `[hooks.state."${upperKey}"]`, - 'enabled = true', - 'trusted_hash = "sha256:UPPER"', - '', - `[hooks.state."${lowerKey}"]`, - 'enabled = true', - 'trusted_hash = "sha256:LOWER"', - '' - ].join('\n'), - 'utf-8' - ) + it('keeps case-distinct WSL UNC hook paths distinct', () => { + // Why: the \\wsl$\ share is case-insensitive, but the Linux tail + // is not — folding the whole path would merge two distinct hook sources. + const upperKey = '\\\\wsl$\\Ubuntu\\home\\u\\Repo\\hooks.json:session_start:0:0' + const lowerKey = '\\\\wsl$\\Ubuntu\\home\\u\\repo\\hooks.json:session_start:0:0' + writeFileSync( + configPath, + [ + `[hooks.state.'${upperKey}']`, + 'enabled = true', + 'trusted_hash = "sha256:UPPER"', + '', + `[hooks.state.'${lowerKey}']`, + 'enabled = true', + 'trusted_hash = "sha256:LOWER"', + '' + ].join('\n'), + 'utf-8' + ) - const result = readHookTrustEntries(configPath) + const result = readHookTrustEntries(configPath) - expect(result.get(upperKey)?.trustedHash).toBe('sha256:UPPER') - expect(result.get(lowerKey)?.trustedHash).toBe('sha256:LOWER') - expect(result.size).toBe(2) - } - ) + // Same share, different-cased distro/separators still fold to one key. + expect(result.get('//WSL$/ubuntu/home/u/Repo/hooks.json:session_start:0:0')?.trustedHash).toBe( + 'sha256:UPPER' + ) + expect(result.get(lowerKey)?.trustedHash).toBe('sha256:LOWER') + expect(result.size).toBe(2) + }) it('reads entries from a CRLF-terminated config', () => { const key = '/x/hooks.json:pre_tool_use:0:0' diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts index 02cd0372837..eede0efea32 100644 --- a/src/main/codex/config-toml-trust.ts +++ b/src/main/codex/config-toml-trust.ts @@ -10,6 +10,7 @@ import { import { dirname, join } from 'node:path' import { createHash, randomUUID } from 'node:crypto' import { copyFileWithWindowsRetry, renameFileWithWindowsRetry } from '../codex-accounts/fs-utils' +import { foldWslUncPathCaseInsensitiveParts } from '../../shared/wsl-paths' import { createTomlLineScanState, isTomlStructuralLine, @@ -211,9 +212,20 @@ function usesWindowsPathSeparators(sourcePath: string): boolean { // Why: Codex and Orca can disagree on quote style, separators, and casing for // the same Windows project, including when the caller targets a remote host. export function normalizeCodexProjectPathForLookup(projectPath: string): string { - return usesWindowsPathSeparators(projectPath) - ? normalizeWindowsPathSeparators(projectPath).toLowerCase() - : projectPath + if (!usesWindowsPathSeparators(projectPath)) { + return projectPath + } + // Why: the Linux path under a WSL share is case-sensitive, so folding it would + // conflate distinct dirs (e.g. .../Repo vs .../repo) onto one trust key. + const slashedPath = normalizeWindowsPathSeparators(projectPath) + return foldWslUncPathCaseInsensitiveParts(slashedPath) ?? slashedPath.toLowerCase() +} + +// Why: trust revocations recorded before WSL tails compared case-sensitively +// can carry drifted casing; fold fully so matching errs toward revoked. +export function normalizeCodexProjectPathForRevocationLookup(projectPath: string): string { + const normalized = normalizeCodexProjectPathForLookup(projectPath) + return usesWindowsPathSeparators(projectPath) ? normalized.toLowerCase() : normalized } export function parseTrustKey(key: string): { @@ -526,18 +538,17 @@ type TrustBlockRange = { // casing) must not prevent findTrustBlockRanges from matching an existing block. export function normalizeHookTrustKeyForLookup(key: string): string { const parsed = parseTrustKey(key) - const separated = parsed - ? `${normalizeWindowsPathSeparators(parsed.sourcePath)}:${parsed.eventLabel}:${parsed.groupIndex}:${parsed.handlerIndex}` - : normalizeWindowsPathSeparators(key) - // Why: Windows-native paths are case-insensitive, but WSL and SSH trust - // sources remain case-sensitive even when Orca's host process is Windows. - const caseInsensitiveWindowsPath = - process.platform === 'win32' && usesWindowsPathSeparators(parsed?.sourcePath ?? key) - return caseInsensitiveWindowsPath ? separated.toLowerCase() : separated + // Why: fold by path shape, not host platform — hook sources on WSL and SSH + // Windows remotes need the same folding when Orca runs on macOS or Linux. + const foldedPath = normalizeCodexProjectPathForLookup(parsed ? parsed.sourcePath : key) + return parsed + ? `${foldedPath}:${parsed.eventLabel}:${parsed.groupIndex}:${parsed.handlerIndex}` + : foldedPath } function findTrustBlockRanges(content: string, key: string): TrustBlockRange[] { const ranges: TrustBlockRange[] = [] + const normalizedKey = normalizeHookTrustKeyForLookup(key) let cursor = 0 let scanState = createTomlLineScanState() while (cursor < content.length) { @@ -547,10 +558,7 @@ function findTrustBlockRanges(content: string, key: string): TrustBlockRange[] { const line = rawLine.replace(/\r$/, '') const nextCursor = newlineIdx === -1 ? content.length : newlineIdx + 1 const headerKey = isTomlStructuralLine(scanState) ? parseHookStateHeaderKey(line) : null - if ( - headerKey !== null && - normalizeHookTrustKeyForLookup(headerKey) === normalizeHookTrustKeyForLookup(key) - ) { + if (headerKey !== null && normalizeHookTrustKeyForLookup(headerKey) === normalizedKey) { const headerLineEnd = rawLine.endsWith('\r') ? lineEnd - 1 : lineEnd const after = content.slice(headerLineEnd) const nextHeaderRel = findNextTableHeader(after) diff --git a/src/main/codex/hook-service-wsl-runtime.test.ts b/src/main/codex/hook-service-wsl-runtime.test.ts index 232725e5f66..d6b06f85189 100644 --- a/src/main/codex/hook-service-wsl-runtime.test.ts +++ b/src/main/codex/hook-service-wsl-runtime.test.ts @@ -66,6 +66,10 @@ function getManagedTrustEntry( } } +function expectedManagedCommand(scriptPath: string): string { + return `if [ -f '${scriptPath}' ] && [ -r '${scriptPath}' ]; then /bin/sh '${scriptPath}'; else cat >/dev/null 2>&1 || :; fi` +} + describe('Codex WSL runtime hook install', () => { it('plans WSL hook files with Linux command and trust paths', () => { const runtimeHome = @@ -133,7 +137,7 @@ describe('Codex WSL runtime hook install', () => { trustConfigPath: '/old/home/hooks.json' } expect(_internals.installManagedHooksIntoWslRuntime(oldPlan).state).toBe('installed') - const oldCommand = `if [ -r '${oldPlan.commandScriptPath}' ]; then /bin/sh '${oldPlan.commandScriptPath}'; fi` + const oldCommand = expectedManagedCommand(oldPlan.commandScriptPath) const oldKey = computeTrustKey(getManagedTrustEntry(oldPlan, oldCommand)) const newPlan = { @@ -142,7 +146,7 @@ describe('Codex WSL runtime hook install', () => { trustConfigPath: '/new/home/hooks.json' } expect(_internals.installManagedHooksIntoWslRuntime(newPlan).state).toBe('installed') - const newCommand = `if [ -r '${newPlan.commandScriptPath}' ]; then /bin/sh '${newPlan.commandScriptPath}'; fi` + const newCommand = expectedManagedCommand(newPlan.commandScriptPath) const newKey = computeTrustKey(getManagedTrustEntry(newPlan, newCommand)) const trustEntries = readHookTrustEntries(plan.tomlPath) @@ -150,6 +154,30 @@ describe('Codex WSL runtime hook install', () => { expect(trustEntries.has(newKey)).toBe(true) }) + it.skipIf(process.platform === 'win32')( + 'drains stdin when the WSL runtime script is missing', + () => { + const basePlan = createTestPlan() + const plan = { + ...basePlan, + commandScriptPath: join(dirname(basePlan.configPath), 'missing-codex-hook.sh') + } + writeFileSync(plan.configPath, '{"hooks":{}}\n', 'utf-8') + writeFileSync(plan.tomlPath, '', 'utf-8') + + expect(_internals.installManagedHooksIntoWslRuntime(plan).state).toBe('installed') + const installed = JSON.parse(readFileSync(plan.configPath, 'utf-8')) as HooksConfig + const command = installed.hooks.UserPromptSubmit[0]?.hooks?.[0]?.command + expect(command).toBe(expectedManagedCommand(plan.commandScriptPath)) + + const result = spawnSync('/bin/sh', ['-c', command!], { + input: Buffer.alloc(1_000_000, 'x') + }) + expect(result.error).toBeUndefined() + expect(result.status).toBe(0) + } + ) + it('sweeps all managed WSL trust for disable or confirmed absence', () => { // Why: disable and confirmed absence intentionally pass []. Transient // unavailability must NOT use this path — last known-good trust remains. @@ -351,9 +379,7 @@ describe('Codex WSL runtime hook install', () => { const installed = JSON.parse(readFileSync(plan.configPath, 'utf-8')) as HooksConfig expect(Object.keys(installed.hooks).sort()).toEqual([...managedEvents].sort()) const managedCommand = installed.hooks.UserPromptSubmit[0]?.hooks?.[0]?.command - expect(managedCommand).toBe( - `if [ -r '${plan.commandScriptPath}' ]; then /bin/sh '${plan.commandScriptPath}'; fi` - ) + expect(managedCommand).toBe(expectedManagedCommand(plan.commandScriptPath)) expect(installed.hooks.UserPromptSubmit[1]?.hooks?.[0]?.command).toBe(userCommand) expect(readFileSync(plan.scriptPath, 'utf-8')).toContain('command -v curl.exe') diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 97142a4a242..b09a94f5ede 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -25,11 +25,18 @@ import { writeManagedScriptRemote, writeTextFileRemoteAtomic } from '../agent-hooks/installer-utils-remote' +import { + buildPosixHookPayloadCapture, + buildWindowsHookEnvironmentGuardLines, + buildWindowsHookStdinDrainEpilogue, + POSIX_HOOK_STDIN_DRAIN_COMMAND +} from '../agent-hooks/hook-stdin-contract' import { computeTrustKey, computeTrustedHash, escapeTomlString, getCodexCanonicalTrustPath, + normalizeCodexProjectPathForLookup, normalizeHookTrustKeyForLookup, parseTrustKey, readHookTrustEntries, @@ -135,8 +142,8 @@ export type { CodexWslRuntimeHookInstallPlan } function wrapReadablePosixHookCommand(scriptPath: string): string { const quoted = `'${scriptPath.replaceAll("'", "'\\''")}'` // Why: WSL runtime hooks are written from Windows through UNC, where the - // executable bit is not reliable. /bin/sh only needs the script to be readable. - return `if [ -r ${quoted} ]; then /bin/sh ${quoted}; fi` + // executable bit is not reliable; a missing script must still own stdin. + return `if [ -f ${quoted} ] && [ -r ${quoted} ]; then /bin/sh ${quoted}; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi` } function getSystemConfigPath(): string { @@ -780,17 +787,17 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { // surviving PTY reach the current server even though its env points at // the prior Orca's coordinates. 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', - 'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0', - 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0', - 'if "%ORCA_PANE_KEY%"=="" exit /b 0', + ...buildWindowsHookEnvironmentGuardLines(), buildWindowsAgentHookCurlPostCommand('codex'), 'exit /b 0', + ...buildWindowsHookStdinDrainEpilogue(), '' ].join('\r\n') } return [ '#!/bin/sh', + ...buildPosixHookPayloadCapture(), // Why: see claude/hook-service.ts for rationale. Sourcing refreshes // PORT/TOKEN/ENV/VERSION from the current Orca so a surviving PTY keeps // reporting after a restart. @@ -822,10 +829,6 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', ' exit 0', 'fi', - 'payload=$(cat)', - 'if [ -z "$payload" ]; then', - ' exit 0', - 'fi', 'post_codex_hook() {', ' curl_bin="$1"', ' connect_timeout="${2:-0.5}"', @@ -1010,6 +1013,12 @@ function getWslHookReconciliationAction(args: { return 'reinstall' } +// Why: fold only the Windows-case-insensitive portion — a full lowercase would +// let case-distinct WSL runtime homes share one reconciliation generation slot. +function getWslReconciliationKey(runtimeHomePath: string): string { + return normalizeCodexProjectPathForLookup(runtimeHomePath) +} + export class CodexHookService { private readonly wslReconciliationGeneration = new Map() @@ -1017,7 +1026,7 @@ export class CodexHookService { if (!runtimeHomePath) { return 0 } - const key = process.platform === 'win32' ? runtimeHomePath.toLowerCase() : runtimeHomePath + const key = getWslReconciliationKey(runtimeHomePath) const generation = (this.wslReconciliationGeneration.get(key) ?? 0) + 1 this.wslReconciliationGeneration.set(key, generation) return generation @@ -1033,7 +1042,7 @@ export class CodexHookService { if (!runtimeHomePath) { return } - const key = process.platform === 'win32' ? runtimeHomePath.toLowerCase() : runtimeHomePath + const key = getWslReconciliationKey(runtimeHomePath) const resolvedPlan = settlement.status === 'resolved' ? createCodexWslRuntimeHookInstallPlan( diff --git a/src/main/command-code/command-code-managed-script.ts b/src/main/command-code/command-code-managed-script.ts index cc625c6054e..ca45f2fa579 100644 --- a/src/main/command-code/command-code-managed-script.ts +++ b/src/main/command-code/command-code-managed-script.ts @@ -1,4 +1,9 @@ import { buildWindowsAgentHookPostCommand } from '../agent-hooks/installer-utils' +import { + buildPosixHookPayloadCapture, + buildWindowsHookEnvironmentGuardLines, + buildWindowsHookStdinDrainEpilogue +} from '../agent-hooks/hook-stdin-contract' export type CommandCodeManagedScriptTarget = 'local' | 'posix' @@ -11,9 +16,7 @@ export function buildCommandCodeManagedScript( 'setlocal', 'if "%ORCA_AGENT_HOOK_PORT%"=="" if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" if not "%ORCA_AGENT_HOOK_PORT%"=="" call :sourceEndpointByPort', - 'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0', - 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0', - 'if "%ORCA_PANE_KEY%"=="" exit /b 0', + ...buildWindowsHookEnvironmentGuardLines(), buildWindowsAgentHookPostCommand('command-code'), 'exit /b 0', ':sourceEndpointByPort', @@ -25,12 +28,14 @@ export function buildCommandCodeManagedScript( 'if not "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0', 'for /f "tokens=2 delims==" %%P in (\'findstr /b /c:"set ORCA_AGENT_HOOK_PORT=" "%~1" 2^>nul\') do if "%%P"=="%ORCA_AGENT_HOOK_PORT%" call "%~1" 2>nul', 'exit /b 0', + ...buildWindowsHookStdinDrainEpilogue(), '' ].join('\r\n') } return [ '#!/bin/sh', + ...buildPosixHookPayloadCapture(), '__orca_read_ancestor_var() {', ' __orca_name="$1"', ' __orca_pid="${PPID:-}"', @@ -116,10 +121,6 @@ export function buildCommandCodeManagedScript( 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', ' exit 0', 'fi', - 'payload=$(cat)', - 'if [ -z "$payload" ]; then', - ' exit 0', - 'fi', // Timeout caps best-effort hook posts if the local listener stalls. // Why: pipe payload to curl's stdin (`payload@-`) instead of an inline // `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl diff --git a/src/main/command-code/hook-service.test.ts b/src/main/command-code/hook-service.test.ts index 4b8b4bfce3c..6e294516324 100644 --- a/src/main/command-code/hook-service.test.ts +++ b/src/main/command-code/hook-service.test.ts @@ -58,7 +58,7 @@ describe('CommandCodeHookService', () => { expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca')) } if (process.platform !== 'win32') { - expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch(/^if \[ -x /) + expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch(/^if \[ -f /) } }) diff --git a/src/main/copilot/hook-service.test.ts b/src/main/copilot/hook-service.test.ts index 68ae69629c4..a9260460deb 100644 --- a/src/main/copilot/hook-service.test.ts +++ b/src/main/copilot/hook-service.test.ts @@ -88,12 +88,16 @@ describe('CopilotHookService', () => { expect(firstPromptHook.type).toBe('command') expect(firstPromptHook.timeoutSec).toBe(5) if (process.platform === 'win32') { - expect(firstPromptHook.powershell).toContain('agent-hooks') - expect(firstPromptHook.powershell).toContain('copilot-hook.ps1') - expect(firstPromptHook.powershell).toContain('ORCA_COPILOT_HOOK_EVENT') - expect(firstPromptHook.powershell).toContain('UserPromptSubmit') + const powershell = firstPromptHook.powershell as string + expect(powershell).toContain('-EncodedCommand') + const encoded = powershell.match(/ -EncodedCommand (\S+)$/)?.[1] + const decoded = Buffer.from(encoded!, 'base64').toString('utf16le') + expect(decoded).toContain('agent-hooks') + expect(decoded).toContain('copilot-hook.ps1') + expect(decoded).toContain("$env:ORCA_COPILOT_HOOK_EVENT = 'UserPromptSubmit'") } else { - expect(firstPromptHook.bash).toContain('if [ -x ') + expect(firstPromptHook.bash).toContain('if [ -f ') + expect(firstPromptHook.bash).toContain('] && [ -x ') expect(firstPromptHook.bash).toContain('.orca/agent-hooks/copilot-hook.sh') expect(firstPromptHook.bash).toContain("ORCA_COPILOT_HOOK_EVENT='UserPromptSubmit'") } diff --git a/src/main/copilot/hook-service.ts b/src/main/copilot/hook-service.ts index a61588ebff5..6a8206d0b59 100644 --- a/src/main/copilot/hook-service.ts +++ b/src/main/copilot/hook-service.ts @@ -12,6 +12,7 @@ import { readHooksJson, removeManagedCommands, wrapPosixHookCommand, + wrapWindowsHookCommand, writeHooksJson, writeManagedScript, type HookDefinition @@ -21,6 +22,7 @@ import { writeHooksJsonRemote, writeManagedScriptRemote } from '../agent-hooks/installer-utils-remote' +import { buildPosixHookPayloadCapture } from '../agent-hooks/hook-stdin-contract' // Why: Copilot's user-level hook files can use VS Code-compatible PascalCase // names, which match the event vocabulary already normalized by Orca's hook @@ -61,14 +63,11 @@ function getManagedScriptPath(): string { return getSharedManagedScriptPath(getManagedScriptFileName()) } -function quotePowerShellPath(path: string): string { - return `'${path.replaceAll("'", "''")}'` -} - function getManagedCommand(scriptPath: string, eventName: string): string { - return process.platform === 'win32' - ? `$env:ORCA_COPILOT_HOOK_EVENT = '${eventName}'; powershell.exe -NoProfile -ExecutionPolicy Bypass -File ${quotePowerShellPath(scriptPath)}` - : wrapPosixHookCommand(scriptPath, { ORCA_COPILOT_HOOK_EVENT: eventName }) + if (process.platform !== 'win32') { + return wrapPosixHookCommand(scriptPath, { ORCA_COPILOT_HOOK_EVENT: eventName }) + } + return wrapWindowsHookCommand(scriptPath, { ORCA_COPILOT_HOOK_EVENT: eventName }) } function getManagedHookDefinition(command: string): HookDefinition { @@ -118,6 +117,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { if (target === 'local' && process.platform === 'win32') { return [ "Write-Output '{}'", + '$inputData = [Console]::In.ReadToEnd()', // Why: endpoint.cmd is cmd syntax, not PowerShell. Parse its `set KEY=...` // lines so surviving PTYs can refresh to the current Orca server. 'if ($env:ORCA_AGENT_HOOK_ENDPOINT -and (Test-Path -LiteralPath $env:ORCA_AGENT_HOOK_ENDPOINT)) {', @@ -130,7 +130,6 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' } catch {}', '}', 'if (-not $env:ORCA_AGENT_HOOK_PORT -or -not $env:ORCA_AGENT_HOOK_TOKEN -or -not $env:ORCA_PANE_KEY) { exit 0 }', - '$inputData = [Console]::In.ReadToEnd()', 'if ([string]::IsNullOrWhiteSpace($inputData)) { exit 0 }', 'try {', ' $payload = $inputData | ConvertFrom-Json', @@ -154,6 +153,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '#!/bin/sh', "printf '{}\\n'", + ...buildPosixHookPayloadCapture(), // Why: Copilot consumes stdout for some hooks, so stdout is emitted before // endpoint refresh, stdin parsing, or the network POST can fail. 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', @@ -162,10 +162,6 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', ' exit 0', 'fi', - 'payload=$(cat)', - 'if [ -z "$payload" ]; then', - ' exit 0', - 'fi', // Why: pipe payload to curl's stdin (`payload@-`) instead of an inline // `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl // command line (EDR command-line false positives). Wire body is identical. diff --git a/src/main/cursor/hook-service.ts b/src/main/cursor/hook-service.ts index 9c5d19e8fa4..b1ccdbf126d 100644 --- a/src/main/cursor/hook-service.ts +++ b/src/main/cursor/hook-service.ts @@ -20,6 +20,11 @@ import { writeHooksJsonRemote, writeManagedScriptRemote } from '../agent-hooks/installer-utils-remote' +import { + buildPosixHookPayloadCapture, + buildWindowsHookEnvironmentGuardLines, + buildWindowsHookStdinDrainEpilogue +} from '../agent-hooks/hook-stdin-contract' // Why: cursor-agent exposes a declarative hooks.json surface at // ~/.cursor/hooks.json (https://cursor.com/docs/hooks) with camelCase event @@ -76,17 +81,17 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { // surviving PTY reach the current server even though its env points at // the prior Orca's coordinates. 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', - 'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0', - 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0', - 'if "%ORCA_PANE_KEY%"=="" exit /b 0', + ...buildWindowsHookEnvironmentGuardLines(), buildWindowsAgentHookPostCommand('cursor'), 'exit /b 0', + ...buildWindowsHookStdinDrainEpilogue(), '' ].join('\r\n') } return [ '#!/bin/sh', + ...buildPosixHookPayloadCapture(), // Why: see claude/hook-service.ts for rationale. Sourcing refreshes // PORT/TOKEN/ENV/VERSION from the current Orca so a surviving PTY keeps // reporting after a restart. @@ -96,10 +101,6 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', ' exit 0', 'fi', - 'payload=$(cat)', - 'if [ -z "$payload" ]; then', - ' exit 0', - 'fi', // Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX // shell is not safe once a path contains quotes or newlines. Post the raw // hook payload plus metadata as form fields and let the receiver parse it. diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index 34c04043950..e29b4d3a615 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -126,6 +126,7 @@ async function main(): Promise { socketPath, tokenPath, log: daemonLog, + requestProcessExit: (exitCode) => process.exit(exitCode), spawnSubprocess: (opts) => createPtySubprocess(opts) }) diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts index 4fbb3047d6a..ce83d70a572 100644 --- a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts +++ b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts @@ -3,7 +3,7 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types' describe('foreground-confirmation daemon protocol', () => { it('rejects daemons from before the fresh-confirmation RPC', () => { - expect(PROTOCOL_VERSION).toBeGreaterThan(19) - expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19) + expect(PROTOCOL_VERSION).toBeGreaterThan(21) + expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(21) }) }) diff --git a/src/main/daemon/daemon-main.test.ts b/src/main/daemon/daemon-main.test.ts index 3fa0bb1f394..e1ae09231ab 100644 --- a/src/main/daemon/daemon-main.test.ts +++ b/src/main/daemon/daemon-main.test.ts @@ -98,7 +98,7 @@ function createMockSubprocess() { write: vi.fn(), resize: vi.fn(), kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), - forceKill: vi.fn(), + forceKill: vi.fn(() => setTimeout(() => onExitCb?.(-1), 5)), signal: vi.fn(), onData(_cb: (data: string) => void) {}, onExit(cb: (code: number) => void) { diff --git a/src/main/daemon/daemon-main.ts b/src/main/daemon/daemon-main.ts index 82f4dad7b7e..b85ee9d6b03 100644 --- a/src/main/daemon/daemon-main.ts +++ b/src/main/daemon/daemon-main.ts @@ -6,6 +6,7 @@ export type DaemonStartOptions = { tokenPath: string spawnSubprocess: DaemonServerOptions['spawnSubprocess'] log?: DaemonFileLog + requestProcessExit?: DaemonServerOptions['requestProcessExit'] } export type DaemonHandle = { @@ -17,7 +18,8 @@ export async function startDaemon(opts: DaemonStartOptions): Promise void>> resume: ReturnType void>> _simulateData: (data: string) => void @@ -49,7 +53,7 @@ function createMockSubprocess(dataOnSubscribe?: string): SubprocessHandle & { pause: vi.fn<() => void>(), resume: vi.fn<() => void>(), kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), - forceKill: vi.fn(), + forceKill: vi.fn(() => setTimeout(() => onExitCb?.(-1), 5)), signal: vi.fn(), onData(cb) { onDataCb = cb @@ -59,6 +63,9 @@ function createMockSubprocess(dataOnSubscribe?: string): SubprocessHandle & { }, onExit(cb) { onExitCb = cb + if (exitOnSubscribe !== undefined) { + cb(exitOnSubscribe) + } }, dispose: vi.fn(), _simulateData(data: string) { @@ -96,9 +103,13 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { command?: string } | null let subprocessDataOnSubscribe: string | undefined + let subprocessExitOnSubscribe: number | undefined + let subprocessSpawnError: Error | undefined beforeEach(async () => { subprocessDataOnSubscribe = undefined + subprocessExitOnSubscribe = undefined + subprocessSpawnError = undefined dir = createTestDir() socketPath = getDaemonSocketPath(dir) tokenPath = join(dir, 'test.token') @@ -107,8 +118,11 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { socketPath, tokenPath, spawnSubprocess: (opts) => { + if (subprocessSpawnError) { + throw subprocessSpawnError + } lastSpawnOpts = opts - lastSubprocess = createMockSubprocess(subprocessDataOnSubscribe) + lastSubprocess = createMockSubprocess(subprocessDataOnSubscribe, subprocessExitOnSubscribe) return lastSubprocess } }) @@ -133,6 +147,150 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(typeof result.id).toBe('string') }) + it('rejects a session that exits synchronously before its create reply publishes', async () => { + subprocessExitOnSubscribe = 0 + + await expect( + adapter.spawn({ sessionId: 'synchronous-admission-exit', cols: 80, rows: 24 }) + ).rejects.toThrow('Daemon PTY exited during admission') + + const daemon = server as unknown as { + host: { listSessions(): unknown[] } + streamRouteBySessionId: Map + } + const internals = adapter as unknown as { + exitFence: Record< + 'revisions' | 'admissions' | 'sessionGenerations' | 'pendingExits', + Map + > + } + expect(adapter.hasPty('synchronous-admission-exit')).toBe(false) + expect(daemon.host.listSessions()).toHaveLength(0) + expect(daemon.streamRouteBySessionId.size).toBe(0) + expect(internals.exitFence.revisions.size).toBe(0) + expect(internals.exitFence.admissions.size).toBe(0) + expect(internals.exitFence.sessionGenerations.size).toBe(0) + expect(internals.exitFence.pendingExits.size).toBe(0) + }) + + it('serializes same-id admissions before a later failure can overlap ownership', async () => { + const daemon = server as unknown as { host: TerminalHost } + const originalCreateOrAttach = daemon.host.createOrAttach.bind(daemon.host) + let releaseFirst!: () => void + const firstGate = new Promise((resolve) => { + releaseFirst = resolve + }) + const createOrAttach = vi + .spyOn(daemon.host, 'createOrAttach') + .mockImplementationOnce(async (options) => { + await firstGate + return originalCreateOrAttach(options) + }) + .mockRejectedValueOnce(new Error('later admission failed')) + + const first = adapter.spawn({ sessionId: 'serialized-admission', cols: 80, rows: 24 }) + await waitFor(() => createOrAttach.mock.calls.length === 1) + const second = adapter.spawn({ sessionId: 'serialized-admission', cols: 80, rows: 24 }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(createOrAttach).toHaveBeenCalledTimes(1) + + releaseFirst() + await expect(first).resolves.toMatchObject({ id: 'serialized-admission' }) + await expect(second).rejects.toThrow('later admission failed') + + const internals = adapter as unknown as { + sessionAdmissionTails: Map> + } + expect(createOrAttach).toHaveBeenCalledTimes(2) + expect(internals.sessionAdmissionTails.size).toBe(0) + expect(adapter.hasPty('serialized-admission')).toBe(true) + }) + + it('keeps different session admissions concurrent', async () => { + const daemon = server as unknown as { host: TerminalHost } + const originalCreateOrAttach = daemon.host.createOrAttach.bind(daemon.host) + let releaseBoth!: () => void + const gate = new Promise((resolve) => { + releaseBoth = resolve + }) + const createOrAttach = vi + .spyOn(daemon.host, 'createOrAttach') + .mockImplementation(async (options) => { + await gate + return originalCreateOrAttach(options) + }) + + const first = adapter.spawn({ sessionId: 'parallel-a', cols: 80, rows: 24 }) + const second = adapter.spawn({ sessionId: 'parallel-b', cols: 80, rows: 24 }) + await waitFor(() => createOrAttach.mock.calls.length === 2) + + releaseBoth() + await expect(Promise.all([first, second])).resolves.toHaveLength(2) + const internals = adapter as unknown as { + sessionAdmissionTails: Map> + } + expect(internals.sessionAdmissionTails.size).toBe(0) + }) + + it('releases fence and queue state after 100 ownerless spawn failures', async () => { + subprocessSpawnError = new Error('native spawn failed') + + for (let index = 0; index < 100; index++) { + await expect( + adapter.spawn({ sessionId: `ownerless-failure-${index}`, cols: 80, rows: 24 }) + ).rejects.toThrow('native spawn failed') + } + + const internals = adapter as unknown as { + activeSessionIds: Set + sessionAdmissionTails: Map> + exitFence: Record< + 'revisions' | 'admissions' | 'sessionGenerations' | 'pendingExits', + Map + > + } + expect(internals.activeSessionIds.size).toBe(0) + expect(internals.sessionAdmissionTails.size).toBe(0) + expect(internals.exitFence.revisions.size).toBe(0) + expect(internals.exitFence.admissions.size).toBe(0) + expect(internals.exitFence.sessionGenerations.size).toBe(0) + expect(internals.exitFence.pendingExits.size).toBe(0) + }) + + it('fails closed when a preserved legacy session has no observable pane identity', async () => { + const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 21 }) + const internals = legacy as unknown as { + client: { + ensureConnected: () => Promise + request: () => Promise + } + } + vi.spyOn(internals.client, 'ensureConnected').mockResolvedValue(undefined) + const request = vi.spyOn(internals.client, 'request').mockResolvedValue({ + sessions: [ + { + sessionId: 'legacy-session', + isAlive: true, + cwd: '/repo' + } + ] + }) + try { + await legacy.listProcesses() + await expect( + legacy.spawn({ + sessionId: 'legacy-session', + cols: 80, + rows: 24, + env: { ORCA_PANE_KEY: 'tab-b:leaf-b', ORCA_TAB_ID: 'tab-b' } + }) + ).rejects.toThrow('Legacy PTY identity unavailable') + expect(request).toHaveBeenCalledOnce() + } finally { + legacy.dispose() + } + }) + it('uses worktreeId as session prefix when provided', async () => { const result = await adapter.spawn({ cols: 80, rows: 24, worktreeId: 'wt-1' }) expect(result.id).toContain('wt-1') @@ -377,6 +535,45 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { }) describe('shutdown', () => { + it('retires identity-bearing shutdowns that cannot be proven on a restarted legacy adapter', async () => { + const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 21 }) + try { + await expect( + legacy.shutdown('legacy-session', { + immediate: true, + expectedPaneKey: 'tab-a:leaf-a', + expectedTabId: 'tab-a' + }) + ).rejects.toThrow('Legacy PTY identity unavailable') + } finally { + legacy.dispose() + } + }) + + it('carries pane identity through the daemon and rejects a stale kill', async () => { + const { id } = await adapter.spawn({ + cols: 80, + rows: 24, + env: { ORCA_PANE_KEY: 'tab-b:leaf-b', ORCA_TAB_ID: 'tab-b' } + }) + + await expect( + adapter.shutdown(id, { + immediate: true, + expectedPaneKey: 'tab-a:leaf-a', + expectedTabId: 'tab-a' + }) + ).rejects.toThrow('PTY identity mismatch') + expect(lastSubprocess.forceKill).not.toHaveBeenCalled() + expect(await adapter.listProcesses()).toHaveLength(1) + + await adapter.shutdown(id, { + immediate: true, + expectedPaneKey: 'tab-b:leaf-b', + expectedTabId: 'tab-b' + }) + }) + it('kills the session', async () => { const { id } = await adapter.spawn({ cols: 80, rows: 24 }) await adapter.shutdown(id, { immediate: false }) @@ -484,6 +681,228 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { await waitFor(() => exits.length > 0) expect(exits[0]).toEqual({ id, code: 42 }) }) + + it('suppresses an old exit when targeted readback proves a same-id replacement', async () => { + const exits: { id: string; code: number }[] = [] + adapter.onExit((payload) => exits.push(payload)) + const { id } = await adapter.spawn({ sessionId: 'same-id', cols: 80, rows: 24 }) + const internals = adapter as unknown as { + readAppliedSize(sessionId: string): Promise<{ + status: 'alive' | 'absent' | 'unknown' + size?: { cols: number; rows: number } + }> + handleExitEvent(sessionId: string, code: number): Promise + } + vi.spyOn(internals, 'readAppliedSize').mockResolvedValueOnce({ + status: 'alive', + size: { cols: 80, rows: 24 } + }) + + await internals.handleExitEvent(id, 0) + + expect(adapter.hasPty(id)).toBe(true) + expect(exits).toEqual([]) + }) + + it('rejects an exit from an older daemon session generation without probing', async () => { + const exits: { id: string; code: number }[] = [] + adapter.onExit((payload) => exits.push(payload)) + const { id } = await adapter.spawn({ sessionId: 'generation-reuse', cols: 80, rows: 24 }) + const internals = adapter as unknown as { + readAppliedSize(sessionId: string): Promise<{ status: 'alive' | 'absent' | 'unknown' }> + handleExitEvent(sessionId: string, code: number, sessionGeneration?: string): Promise + exitFence: { rememberGeneration(sessionId: string, generation: string): void } + } + internals.exitFence.rememberGeneration(id, 'replacement-generation') + const readback = vi.spyOn(internals, 'readAppliedSize') + + await internals.handleExitEvent(id, 7, 'old-generation') + + expect(readback).not.toHaveBeenCalled() + expect(adapter.hasPty(id)).toBe(true) + expect(exits).toEqual([]) + }) + + it('retains a replacement exit emitted before admission publishes its generation', async () => { + const exits: { id: string; code: number }[] = [] + adapter.onExit((payload) => exits.push(payload)) + const { id } = await adapter.spawn({ sessionId: 'pre-reply-exit', cols: 80, rows: 24 }) + const internals = adapter as unknown as { + readAppliedSize(sessionId: string): Promise<{ status: 'alive' | 'absent' | 'unknown' }> + handleExitEvent(sessionId: string, code: number, sessionGeneration?: string): Promise + reconcilePendingExitAfterAdmission(sessionId: string): Promise + exitFence: { + beginAdmission(sessionId: string): { + complete(): void + } + rememberGeneration( + sessionId: string, + generation: string, + admission?: { complete(): void } + ): boolean + getPending(sessionId: string): { code: number; sessionGeneration?: string } | undefined + } + } + const admission = internals.exitFence.beginAdmission(id) + vi.spyOn(internals, 'readAppliedSize').mockResolvedValue({ status: 'absent' }) + + await internals.handleExitEvent(id, 9, 'replacement-generation') + + expect(internals.exitFence.getPending(id)).toEqual({ + code: 9, + sessionGeneration: 'replacement-generation' + }) + internals.exitFence.rememberGeneration(id, 'replacement-generation', admission) + admission.complete() + await internals.reconcilePendingExitAfterAdmission(id) + + expect(adapter.hasPty(id)).toBe(false) + expect(exits).toEqual([{ id, code: 9 }]) + }) + + it('does not let an older alive readback erase a newer deferred exit', async () => { + const exits: { id: string; code: number }[] = [] + adapter.onExit((payload) => exits.push(payload)) + const { id } = await adapter.spawn({ sessionId: 'overlapping-readback', cols: 80, rows: 24 }) + let resolveOlderReadback!: (value: { status: 'alive'; sessionGeneration: string }) => void + const olderReadback = new Promise<{ + status: 'alive' + sessionGeneration: string + }>((resolve) => { + resolveOlderReadback = resolve + }) + const internals = adapter as unknown as { + readAppliedSize(sessionId: string): Promise<{ + status: 'alive' | 'absent' | 'unknown' + sessionGeneration?: string + }> + handleExitEvent(sessionId: string, code: number, sessionGeneration?: string): Promise + reconcilePendingExitAfterAdmission(sessionId: string): Promise + exitFence: { + beginAdmission(sessionId: string): { complete(): void } + rememberGeneration( + sessionId: string, + generation: string, + admission?: { complete(): void } + ): boolean + getPending(sessionId: string): { code: number; sessionGeneration?: string } | undefined + } + } + const readback = vi + .spyOn(internals, 'readAppliedSize') + .mockImplementationOnce(() => olderReadback) + .mockResolvedValue({ status: 'unknown' }) + const olderExit = internals.handleExitEvent(id, 0) + await waitFor(() => readback.mock.calls.length === 1) + + const admission = internals.exitFence.beginAdmission(id) + await internals.handleExitEvent(id, 11, 'replacement-generation') + resolveOlderReadback({ status: 'alive', sessionGeneration: 'old-generation' }) + await olderExit + + expect(internals.exitFence.getPending(id)).toEqual({ + code: 11, + sessionGeneration: 'replacement-generation' + }) + internals.exitFence.rememberGeneration(id, 'replacement-generation', admission) + admission.complete() + readback.mockResolvedValue({ status: 'absent' }) + await internals.reconcilePendingExitAfterAdmission(id) + + expect(adapter.hasPty(id)).toBe(false) + expect(exits).toEqual([{ id, code: 11 }]) + }) + + it('releases unknown deferred exits after failed admissions publish no owner', async () => { + const internals = adapter as unknown as { + readAppliedSize(sessionId: string): Promise<{ status: 'alive' | 'absent' | 'unknown' }> + reconcilePendingExitAfterAdmission(sessionId: string): Promise + exitFence: { + beginAdmission(sessionId: string): { complete(): void } + defer(sessionId: string, exit: { code: number }): void + revisions: Map + admissions: Map + sessionGenerations: Map + pendingExits: Map + } + } + vi.spyOn(internals, 'readAppliedSize').mockResolvedValue({ status: 'unknown' }) + + for (let index = 0; index < 100; index++) { + const sessionId = `failed-admission-${index}` + const admission = internals.exitFence.beginAdmission(sessionId) + internals.exitFence.defer(sessionId, { code: index }) + admission.complete() + await expect(internals.reconcilePendingExitAfterAdmission(sessionId)).resolves.toBe(true) + } + + expect(internals.exitFence.revisions.size).toBe(0) + expect(internals.exitFence.admissions.size).toBe(0) + expect(internals.exitFence.sessionGenerations.size).toBe(0) + expect(internals.exitFence.pendingExits.size).toBe(0) + }) + + it('retains an unknown exit until authoritative listing reconciles it', async () => { + const exits: { id: string; code: number }[] = [] + adapter.onExit((payload) => exits.push(payload)) + const { id } = await adapter.spawn({ sessionId: 'same-id', cols: 80, rows: 24 }) + const internals = adapter as unknown as { + readAppliedSize(sessionId: string): Promise<{ status: 'alive' | 'absent' | 'unknown' }> + handleExitEvent(sessionId: string, code: number): Promise + exitFence: { + getPending(sessionId: string): { code: number } | undefined + defer(sessionId: string, exit: { code: number }): void + } + activeSessionIds: Set + } + vi.spyOn(internals, 'readAppliedSize').mockResolvedValueOnce({ status: 'unknown' }) + + await internals.handleExitEvent(id, 7) + + expect(adapter.hasPty(id)).toBe(true) + expect(internals.exitFence.getPending(id)?.code).toBe(7) + expect(exits).toEqual([]) + + await adapter.listProcesses() + expect(internals.exitFence.getPending(id)).toBeUndefined() + + internals.activeSessionIds.add('confirmed-absent') + internals.exitFence.defer('confirmed-absent', { code: 9 }) + await adapter.listProcesses() + expect(exits).toContainEqual({ id: 'confirmed-absent', code: 9 }) + }) + + it('retains an old exit when an absent list races a same-id replacement admission', async () => { + const exits: { id: string; code: number }[] = [] + adapter.onExit((payload) => exits.push(payload)) + const { id } = await adapter.spawn({ sessionId: 'same-id-race', cols: 80, rows: 24 }) + const internals = adapter as unknown as { + client: { + request(type: string, payload: unknown): Promise + } + readAppliedSize(sessionId: string): Promise<{ status: 'alive' | 'absent' | 'unknown' }> + handleExitEvent(sessionId: string, code: number): Promise + exitFence: { + beginAdmission(sessionId: string): { complete(): void } + rememberGeneration(sessionId: string, generation: string): boolean + getPending(sessionId: string): { code: number } | undefined + } + } + const admission = internals.exitFence.beginAdmission(id) + vi.spyOn(internals, 'readAppliedSize').mockResolvedValueOnce({ status: 'unknown' }) + await internals.handleExitEvent(id, 7) + + const request = vi.spyOn(internals.client, 'request') + request.mockResolvedValueOnce({ sessions: [] }) + const staleList = adapter.listProcesses() + admission.complete() + internals.exitFence.rememberGeneration(id, 'replacement-generation') + await staleList + + expect(adapter.hasPty(id)).toBe(true) + expect(internals.exitFence.getPending(id)?.code).toBe(7) + expect(exits).toEqual([]) + }) }) describe('spawn with sessionId (reattach)', () => { @@ -560,6 +979,29 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(procs[0]).toHaveProperty('cwd') expect(procs[0]).toHaveProperty('title') }) + + it('reaps active ownership when an authoritative list omits the session', async () => { + const exits: { id: string; code: number }[] = [] + adapter.onExit((payload) => exits.push(payload)) + const { id } = await adapter.spawn({ sessionId: 'authoritative-absence', cols: 80, rows: 24 }) + const internals = adapter as unknown as { + client: { request(type: string, payload: unknown): Promise } + exitFence: { + revisions: Map + sessionGenerations: Map + pendingExits: Map + } + } + vi.spyOn(internals.client, 'request').mockResolvedValueOnce({ sessions: [] }) + + await adapter.listProcesses() + + expect(adapter.hasPty(id)).toBe(false) + expect(exits).toContainEqual({ id, code: -1 }) + expect(internals.exitFence.revisions.size).toBe(0) + expect(internals.exitFence.sessionGenerations.size).toBe(0) + expect(internals.exitFence.pendingExits.size).toBe(0) + }) }) describe('hasChildProcesses / getForegroundProcess', () => { diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index e0d5ec598ca..6a8650435b9 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -5,6 +5,11 @@ import { basename } from 'node:path' import { existsSync } from 'node:fs' import { DaemonClient } from './client' import { getMacDaemonSystemResolverHealth } from './daemon-health' +import { + DaemonSessionExitFence, + type DaemonSessionAdmission, + type PendingDaemonExit +} from './daemon-session-exit-fence' import { HistoryManager } from './history-manager' import { HistoryReader, type ColdRestoreInfo } from './history-reader' import { mintPtySessionId, parsePtySessionId } from './pty-session-id' @@ -24,13 +29,20 @@ import type { PtyBackgroundStreamEvent, PtyProviderBufferSnapshot, PtyProcessInfo, + PtyShutdownOptions, PtySpawnOptions, PtySpawnResult } from '../providers/types' import { isShellProcess } from '../../shared/agent-detection' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' +import { DAEMON_PTY_EXITED_DURING_ADMISSION } from '../../shared/constants' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' +import { + assertPtyPaneIdentity, + getPtyPaneIdentityFromEnv, + type PtyPaneIdentity +} from '../pty/pty-shutdown-identity' type ColdRestorePayload = { scrollback: string @@ -38,6 +50,11 @@ type ColdRestorePayload = { oscLinks?: TerminalOscLinkRange[] } +type AppliedSizeReadback = + | { status: 'alive'; size: { cols: number; rows: number }; sessionGeneration?: string } + | { status: 'absent' } + | { status: 'unknown' } + function getRecoveredHistorySeed(restoreInfo: ColdRestoreInfo): string | null { // Why: alt-screen snapshots represent the TUI buffer; prefer its normal // scrollback so a dead TUI is not revived as the fresh shell's active screen. @@ -69,6 +86,7 @@ export class TerminalKilledError extends Error { } export class DaemonPtyAdapter implements IPtyProvider { + readonly requiresShutdownExitProof = true readonly protocolVersion: number private socketPath: string private tokenPath: string @@ -102,6 +120,7 @@ export class DaemonPtyAdapter implements IPtyProvider { private coldRestoreCache = new Map() private sleepRestoreSessionIds = new Set() private activeSessionIds = new Set() + private paneIdentityBySessionId = new Map() private dirtySessionVersions = new Map() // Why: a cold-restored session is a fresh shell whose on-disk checkpoint and // log belong to the pre-crash session. Incremental appends would land on @@ -130,6 +149,8 @@ export class DaemonPtyAdapter implements IPtyProvider { // resume died with the connection. Owe those sessions a resume on the next // connect; the daemon's 5s failsafe covers the window in between. private producerResumesOwedOnReconnect = new Set() + private exitFence = new DaemonSessionExitFence() + private sessionAdmissionTails = new Map>() private static CHECKPOINT_INTERVAL_MS = 5_000 // Why: a streaming session (build logs, `yes`) re-triggers a full multi-MB // snapshot checkpoint on every 5s tick via pending-buffer overflow or the @@ -168,20 +189,49 @@ export class DaemonPtyAdapter implements IPtyProvider { return this.historyManager } - async spawn(opts: PtySpawnOptions): Promise { - return this.withDaemonRetry(() => this.doSpawn(opts)) + private rememberSessionIdentity(session: SessionInfo): void { + if (session.paneKey || session.tabId) { + this.paneIdentityBySessionId.set(session.sessionId, { + paneKey: session.paneKey ?? null, + tabId: session.tabId ?? null + }) + } } - private async doSpawn(opts: PtySpawnOptions): Promise { + async spawn(opts: PtySpawnOptions): Promise { const sessionId = opts.sessionId ?? mintPtySessionId(opts.worktreeId) + return this.withSerializedSessionAdmission(sessionId, () => + this.spawnAfterAdmissionQueue(opts, sessionId) + ) + } - if (this.killedSessionTombstones.has(sessionId)) { - throw new TerminalKilledError(sessionId) - } - + private async spawnAfterAdmissionQueue( + opts: PtySpawnOptions, + sessionId: string + ): Promise { + this.assertSpawnAllowed(sessionId, opts) if (opts.isNewSession) { await this.replaceUnhealthyMacResolverDaemonBeforeNewPty() } + const admission = this.exitFence.beginAdmission(sessionId) + let result: PtySpawnResult + try { + result = await this.withDaemonRetry(() => this.doSpawn({ ...opts, sessionId }, admission)) + } catch (error) { + await this.finishAdmission(sessionId, admission, false) + throw error + } + await this.finishAdmission(sessionId, admission, true) + return result + } + + private async doSpawn( + opts: PtySpawnOptions, + admission: DaemonSessionAdmission + ): Promise { + const sessionId = opts.sessionId! + const requestedIdentity = getPtyPaneIdentityFromEnv(opts.env) + this.assertSpawnAllowed(sessionId, opts) await this.ensureConnected() // Why before createOrAttach: a preserved v19 daemon may remember this @@ -238,6 +288,8 @@ export class DaemonPtyAdapter implements IPtyProvider { command: opts.command, startupCommandDelivery: opts.startupCommandDelivery, launchAgent: opts.launchAgent, + ...(requestedIdentity.paneKey ? { paneKey: requestedIdentity.paneKey } : {}), + ...(requestedIdentity.tabId ? { tabId: requestedIdentity.tabId } : {}), // Why: without this, the daemon always spawns cmd.exe (COMSPEC) or // PowerShell as a fallback — regardless of which shell the renderer // asked for in the "+" menu or persisted as the default. Forwarding @@ -251,8 +303,15 @@ export class DaemonPtyAdapter implements IPtyProvider { ...(historySeed ? { historySeed } : {}) }) + if (!admission.isCurrent()) { + throw new Error(`Daemon PTY admission superseded for "${sessionId}"`) + } let scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null let result = await createOrAttach(scrollback) + if (!this.exitFence.rememberGeneration(sessionId, result.sessionGeneration, admission)) { + throw new Error(`Daemon PTY admission superseded for "${sessionId}"`) + } + this.paneIdentityBySessionId.set(sessionId, requestedIdentity) const launchIdentity = (): { launchAgent?: NonNullable } => result.launchAgent ? { launchAgent: result.launchAgent } : {} @@ -307,6 +366,9 @@ export class DaemonPtyAdapter implements IPtyProvider { effectiveCols = restoreInfo.cols effectiveRows = restoreInfo.rows result = await createOrAttach(scrollback) + if (!this.exitFence.rememberGeneration(sessionId, result.sessionGeneration, admission)) { + throw new Error(`Daemon PTY admission superseded for "${sessionId}"`) + } pid = typeof result.pid === 'number' && result.pid > 0 ? result.pid : null this.initialCwds.set(sessionId, effectiveCwd) } @@ -424,17 +486,52 @@ export class DaemonPtyAdapter implements IPtyProvider { } } - async attach(id: string): Promise { - await this.ensureConnected() - if (!this.supportsAuthoritativeBufferSnapshots) { - this.setPtyBackgrounded(id, false) + private assertSpawnAllowed(sessionId: string, opts: PtySpawnOptions): void { + if (this.killedSessionTombstones.has(sessionId)) { + throw new TerminalKilledError(sessionId) } + if (this.protocolVersion >= 22 || !this.activeSessionIds.has(sessionId)) { + return + } + const requestedIdentity = getPtyPaneIdentityFromEnv(opts.env) + const knownIdentity = this.paneIdentityBySessionId.get(sessionId) + if (knownIdentity) { + assertPtyPaneIdentity(sessionId, knownIdentity, { + expectedPaneKey: requestedIdentity.paneKey ?? undefined, + expectedTabId: requestedIdentity.tabId ?? undefined + }) + } else if (requestedIdentity.paneKey || requestedIdentity.tabId) { + // Why: preserved legacy daemons cannot report identity, so attaching a + // new pane by a reused ID could later authorize killing the old pane. + throw new Error(`Legacy PTY identity unavailable for "${sessionId}"`) + } + } - await this.client.request('createOrAttach', { - sessionId: id, - cols: 80, - rows: 24 - }) + async attach(id: string): Promise { + return this.withSerializedSessionAdmission(id, () => this.attachAfterAdmissionQueue(id)) + } + + private async attachAfterAdmissionQueue(id: string): Promise { + const admission = this.exitFence.beginAdmission(id) + try { + await this.ensureConnected() + if (!this.supportsAuthoritativeBufferSnapshots) { + this.setPtyBackgrounded(id, false) + } + + const result = await this.client.request('createOrAttach', { + sessionId: id, + cols: 80, + rows: 24 + }) + if (!this.exitFence.rememberGeneration(id, result.sessionGeneration, admission)) { + throw new Error(`Daemon PTY admission superseded for "${id}"`) + } + } catch (error) { + await this.finishAdmission(id, admission, false) + throw error + } + await this.finishAdmission(id, admission, true) } hasPty(id: string): boolean { @@ -485,7 +582,19 @@ export class DaemonPtyAdapter implements IPtyProvider { this.client.notify('setSessionBackground', { sessionId: id, background: safeBackground }) } - async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise { + async shutdown(id: string, opts: PtyShutdownOptions): Promise { + if ( + this.protocolVersion < 22 && + (opts.expectedPaneKey !== undefined || opts.expectedTabId !== undefined) + ) { + // Why: preserved pre-v22 daemons ignore kill identity fields. Fail + // closed unless this main process observed the exact attached pane. + const knownIdentity = this.paneIdentityBySessionId.get(id) + if (!knownIdentity) { + throw new Error(`Legacy PTY identity unavailable for "${id}"`) + } + assertPtyPaneIdentity(id, knownIdentity, opts) + } // Why: sleep/exact-stop kills the live PTY before the periodic checkpoint may run. // Force a final snapshot so wake can restore the pane users left. if (opts.keepHistory) { @@ -500,21 +609,18 @@ export class DaemonPtyAdapter implements IPtyProvider { this.sleepRestoreSessionIds.add(id) } } - await this.client.request('kill', { sessionId: id, immediate: opts.immediate ?? false }) - this.activeSessionIds.delete(id) - this.dirtySessionVersions.delete(id) + await this.client.request('kill', { + sessionId: id, + immediate: opts.immediate ?? false, + expectedPaneKey: opts.expectedPaneKey, + expectedTabId: opts.expectedTabId + }) + // Why: RPC success is only kill acceptance. Exit events or listProcesses + // readback own cache cleanup so retries cannot lose a still-live session. if (!opts.keepHistory) { this.coldRestoreCache.delete(id) this.sleepRestoreSessionIds.delete(id) } - // Why: the !keepHistory close path doesn't take a final checkpoint, so a - // session stranded in sessionsNeedingFullCheckpoint would never be cleared. - // (Under keepHistory the final checkpoint above already cleared the flag, so - // this is a harmless no-op there — kept unconditional to cover both paths.) - this.sessionsNeedingFullCheckpoint.delete(id) - this.lastFullCheckpointAt.delete(id) - this.stopCheckpointTimerIfIdle() - this.initialCwds.delete(id) // Why: history removal is for the "user explicitly closed this terminal" // path. Sleep also calls shutdown but expects scrollback to survive — wake // re-spawns and the cold-restore reader needs the dir intact. Caller @@ -592,14 +698,25 @@ export class DaemonPtyAdapter implements IPtyProvider { // and re-assert. Null (RPC failure / unknown session) means "cannot confirm", // which the renderer treats as a cue to re-forward once. async getAppliedSize(id: string): Promise<{ cols: number; rows: number } | null> { + const readback = await this.readAppliedSize(id) + return readback.status === 'alive' ? readback.size : null + } + + private async readAppliedSize(id: string): Promise { try { - const result = await this.client.request<{ size: { cols: number; rows: number } | null }>( - 'getSize', - { sessionId: id } - ) - return result.size ?? null + const result = await this.client.request<{ + size: { cols: number; rows: number } | null + sessionGeneration?: string + }>('getSize', { sessionId: id }) + return result.size + ? { + status: 'alive', + size: result.size, + ...(result.sessionGeneration ? { sessionGeneration: result.sessionGeneration } : {}) + } + : { status: 'absent' } } catch { - return null + return { status: 'unknown' } } } @@ -714,6 +831,7 @@ export class DaemonPtyAdapter implements IPtyProvider { const killed: string[] = [] for (const session of result.sessions) { + this.rememberSessionIdentity(session) if (!session.isAlive) { continue } @@ -744,8 +862,43 @@ export class DaemonPtyAdapter implements IPtyProvider { } async listProcesses(): Promise { + const activeSnapshots = new Map( + [...this.activeSessionIds].map((id) => [id, this.exitFence.snapshot(id)]) + ) + const pendingSnapshots = new Map( + this.exitFence.pendingEntries().map(([id]) => [id, this.exitFence.snapshot(id)]) + ) await this.ensureConnected() const result = await this.client.request('listSessions', undefined) + for (const session of result.sessions) { + this.rememberSessionIdentity(session) + if (session.isAlive) { + this.exitFence.rememberGeneration(session.sessionId, session.sessionGeneration) + // Why: router startup discovers preserved legacy sessions through this + // method; spawn must know the ID is already live before createOrAttach. + this.activeSessionIds.add(session.sessionId) + } else { + this.activeSessionIds.delete(session.sessionId) + } + } + const liveIds = new Set( + result.sessions.filter((session) => session.isAlive).map((session) => session.sessionId) + ) + for (const [id, snapshot] of activeSnapshots) { + if (!liveIds.has(id) && this.exitFence.isStable(id, snapshot)) { + this.finalizeExitEvent(id, this.exitFence.getPending(id)?.code ?? -1) + } + } + for (const [id, exit] of this.exitFence.pendingEntries()) { + if (liveIds.has(id)) { + this.exitFence.clearPending(id) + } else if ( + pendingSnapshots.has(id) && + this.exitFence.isStable(id, pendingSnapshots.get(id)) + ) { + this.finalizeExitEvent(id, exit.code) + } + } return result.sessions .filter((s) => s.isAlive) .map((s) => ({ @@ -763,6 +916,9 @@ export class DaemonPtyAdapter implements IPtyProvider { async listSessions(): Promise { await this.ensureConnected() const result = await this.client.request('listSessions', undefined) + for (const session of result.sessions) { + this.rememberSessionIdentity(session) + } return result.sessions.filter((s) => s.isAlive) } @@ -785,6 +941,7 @@ export class DaemonPtyAdapter implements IPtyProvider { this.sessionsNeedingFullCheckpoint.clear() this.pausedProducerSessionIds.clear() this.producerResumesOwedOnReconnect.clear() + this.exitFence.clear() this.stopCheckpointTimer() for (const id of ids) { this.coldRestoreCache.delete(id) @@ -860,6 +1017,7 @@ export class DaemonPtyAdapter implements IPtyProvider { this.coldRestoreCache.clear() this.pausedProducerSessionIds.clear() this.producerResumesOwedOnReconnect.clear() + this.exitFence.clear() this.removeEventListener?.() this.removeEventListener = null // Why: final checkpoints are written daemon-side in TerminalHost.dispose() @@ -903,6 +1061,7 @@ export class DaemonPtyAdapter implements IPtyProvider { } this.pausedProducerSessionIds.clear() this.producerResumesOwedOnReconnect.clear() + this.exitFence.clear() this.removeEventListener?.() this.removeEventListener = null this.client.disconnect() @@ -1316,38 +1475,152 @@ export class DaemonPtyAdapter implements IPtyProvider { fact: event.payload }) } else if (event.event === 'exit') { - this.activeSessionIds.delete(event.sessionId) - this.dirtySessionVersions.delete(event.sessionId) - // Why: an exited session must not be owed a resume on reconnect — a - // reused sessionId would receive a stray resumePty. Same for the - // background set: a reused id must start un-thinned. - this.pausedProducerSessionIds.delete(event.sessionId) - this.producerResumesOwedOnReconnect.delete(event.sessionId) - this.backgroundedSessionIds.delete(event.sessionId) - if (!this.sleepRestoreSessionIds.has(event.sessionId)) { - this.coldRestoreCache.delete(event.sessionId) - } - // Why: an exited session can never be checkpointed again, so its pending - // full-checkpoint flag is dead state. Without this, a cold-restored - // session that exits before its first checkpoint leaks a permanent entry. - this.sessionsNeedingFullCheckpoint.delete(event.sessionId) - // Why: a reused sessionId (renderer respawns a persisted ptyId) must - // not inherit the dead session's snapshot cooldown. - this.lastFullCheckpointAt.delete(event.sessionId) - this.stopCheckpointTimerIfIdle() - if (this.historyManager) { - void this.historyManager - .closeSession(event.sessionId, event.payload.code) - .catch((err) => console.warn('[history] closeSession failed:', event.sessionId, err)) - } - this.initialCwds.delete(event.sessionId) - // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration - for (const listener of [...this.exitListeners]) { - listener({ id: event.sessionId, code: event.payload.code }) - } + void this.handleExitEvent( + event.sessionId, + event.payload.code, + event.payload.sessionGeneration + ) } }) } + + private async handleExitEvent( + sessionId: string, + code: number, + sessionGeneration?: string + ): Promise { + if (this.exitFence.isStaleGeneration(sessionId, sessionGeneration)) { + return + } + const exit: PendingDaemonExit = { code, ...(sessionGeneration ? { sessionGeneration } : {}) } + if (this.exitFence.isAdmissionActive(sessionId)) { + // Why before any readback await: create replies and stream exits use + // separate sockets, so admission completion must see the queued exit. + this.exitFence.defer(sessionId, exit) + return + } + const fenceSnapshot = this.exitFence.snapshot(sessionId) + // Why: control and stream sockets can reorder a replacement create reply + // ahead of the old process's queued exit. Targeted liveness proof prevents + // that stale exit from deleting the replacement's same-id ownership. + const readback = await this.readAppliedSize(sessionId) + if (readback.status === 'alive') { + if (!this.exitFence.isStable(sessionId, fenceSnapshot)) { + return + } + this.exitFence.rememberGeneration(sessionId, readback.sessionGeneration) + this.exitFence.clearPending(sessionId) + return + } + if (!this.exitFence.isStable(sessionId, fenceSnapshot)) { + this.exitFence.defer(sessionId, exit) + // Why: admission may have completed while the old readback was in + // flight, before spawn's finally block could observe this deferred exit. + await this.reconcilePendingExitAfterAdmission(sessionId) + return + } + if (readback.status === 'unknown') { + if (!this.activeSessionIds.has(sessionId)) { + this.finalizeExitEvent(sessionId, code) + return + } + // Why: a failed control-socket probe is not absence proof. Retain the + // active id until authoritative listing; this also bounds retention to + // the adapter's already-owned sessions instead of arbitrary exit ids. + this.exitFence.defer(sessionId, exit) + return + } + this.finalizeExitEvent(sessionId, code) + } + + private finalizeExitEvent(sessionId: string, code: number): void { + this.exitFence.forget(sessionId) + this.activeSessionIds.delete(sessionId) + this.dirtySessionVersions.delete(sessionId) + this.pausedProducerSessionIds.delete(sessionId) + this.producerResumesOwedOnReconnect.delete(sessionId) + this.backgroundedSessionIds.delete(sessionId) + if (!this.sleepRestoreSessionIds.has(sessionId)) { + this.coldRestoreCache.delete(sessionId) + this.paneIdentityBySessionId.delete(sessionId) + } + this.sessionsNeedingFullCheckpoint.delete(sessionId) + this.lastFullCheckpointAt.delete(sessionId) + this.stopCheckpointTimerIfIdle() + if (this.historyManager) { + void this.historyManager + .closeSession(sessionId, code) + .catch((err) => console.warn('[history] closeSession failed:', sessionId, err)) + } + this.initialCwds.delete(sessionId) + // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration + for (const listener of [...this.exitListeners]) { + listener({ id: sessionId, code }) + } + } + + private async reconcilePendingExitAfterAdmission(sessionId: string): Promise { + const pending = this.exitFence.getPending(sessionId) + if (!pending || this.exitFence.isStaleGeneration(sessionId, pending.sessionGeneration)) { + this.exitFence.clearPending(sessionId) + return false + } + const fenceSnapshot = this.exitFence.snapshot(sessionId) + const readback = await this.readAppliedSize(sessionId) + if (!this.exitFence.isStable(sessionId, fenceSnapshot)) { + return false + } + if (readback.status === 'alive') { + this.exitFence.rememberGeneration(sessionId, readback.sessionGeneration) + this.exitFence.clearPending(sessionId) + return false + } else if (readback.status === 'absent') { + this.finalizeExitEvent(sessionId, pending.code) + return true + } else if (!this.activeSessionIds.has(sessionId)) { + // Why: the exit event is authoritative for an admission that never + // published ownership; an inconclusive probe must not retain fence maps. + this.finalizeExitEvent(sessionId, pending.code) + return true + } + return false + } + + private async finishAdmission( + sessionId: string, + admission: DaemonSessionAdmission, + rejectIfExited: boolean + ): Promise { + admission.complete() + const exited = await this.reconcilePendingExitAfterAdmission(sessionId) + if (rejectIfExited && exited) { + throw new Error(`${DAEMON_PTY_EXITED_DURING_ADMISSION} for "${sessionId}"`) + } + if (!rejectIfExited && !this.activeSessionIds.has(sessionId)) { + this.exitFence.forget(sessionId) + } + } + + private async withSerializedSessionAdmission( + sessionId: string, + action: () => Promise + ): Promise { + const previous = this.sessionAdmissionTails.get(sessionId) ?? Promise.resolve() + let release!: () => void + const current = new Promise((resolve) => { + release = resolve + }) + this.sessionAdmissionTails.set(sessionId, current) + await previous + try { + return await action() + } finally { + release() + if (this.sessionAdmissionTails.get(sessionId) === current) { + this.sessionAdmissionTails.delete(sessionId) + } + } + } } // Why: ENOENT/ECONNREFUSED with syscall 'connect' mean the socket is diff --git a/src/main/daemon/daemon-pty-provider.test.ts b/src/main/daemon/daemon-pty-provider.test.ts index 2a405326d1b..51fd6a7c7fc 100644 --- a/src/main/daemon/daemon-pty-provider.test.ts +++ b/src/main/daemon/daemon-pty-provider.test.ts @@ -23,7 +23,7 @@ function createMockSubprocess(): SubprocessHandle & { write: vi.fn(), resize: vi.fn(), kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), - forceKill: vi.fn(), + forceKill: vi.fn(() => setTimeout(() => onExitCb?.(-1), 5)), signal: vi.fn(), onData(cb) { onDataCb = cb diff --git a/src/main/daemon/daemon-pty-provider.ts b/src/main/daemon/daemon-pty-provider.ts index 7c569f755b3..b24cd3a4f94 100644 --- a/src/main/daemon/daemon-pty-provider.ts +++ b/src/main/daemon/daemon-pty-provider.ts @@ -1,5 +1,7 @@ import { DaemonClient } from './client' import type { DaemonEvent, CreateOrAttachResult } from './types' +import type { PtyShutdownOptions } from '../providers/types' +import { getPtyPaneIdentityFromEnv } from '../pty/pty-shutdown-identity' export type DaemonPtyProviderOptions = { socketPath: string @@ -38,6 +40,7 @@ export class DaemonPtyProvider { async spawn(opts: DaemonSpawnOptions): Promise { await this.client.ensureConnected() this.setupEventRouting() + const identity = getPtyPaneIdentityFromEnv(opts.env) const result = await this.client.request('createOrAttach', { sessionId: opts.sessionId, @@ -46,7 +49,9 @@ export class DaemonPtyProvider { cwd: opts.cwd, env: opts.env, envToDelete: opts.envToDelete, - command: opts.command + command: opts.command, + ...(identity.paneKey ? { paneKey: identity.paneKey } : {}), + ...(identity.tabId ? { tabId: identity.tabId } : {}) }) return { @@ -64,8 +69,13 @@ export class DaemonPtyProvider { this.client.notify('resize', { sessionId: id, cols, rows }) } - async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise { - await this.client.request('kill', { sessionId: id, immediate: opts.immediate ?? false }) + async shutdown(id: string, opts: PtyShutdownOptions): Promise { + await this.client.request('kill', { + sessionId: id, + immediate: opts.immediate ?? false, + expectedPaneKey: opts.expectedPaneKey, + expectedTabId: opts.expectedTabId + }) } onData(callback: (payload: { id: string; data: string }) => void): () => void { diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts index eb8acb86488..ea6cb80a073 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -122,6 +122,132 @@ function createAdapter( } describe('DaemonPtyRouter', () => { + it('retains the exact adapter route until exit proof', async () => { + const current = createAdapter('current') + const legacy = createAdapter('legacy', ['shared-session']) + vi.mocked(legacy.shutdown).mockResolvedValueOnce(undefined) + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + await router.discoverLegacySessions() + + expect(router.requiresShutdownExitProof).toBe(true) + await router.shutdown('shared-session', { immediate: true }) + router.write('shared-session', 'before-exit') + + expect(legacy.write).toHaveBeenCalledWith('shared-session', 'before-exit') + legacy.emitExit('shared-session', 0) + router.write('shared-session', 'after-exit') + expect(current.write).toHaveBeenCalledWith('shared-session', 'after-exit') + }) + + it('fences a late legacy exit after readback and id reuse', async () => { + const current = createAdapter('current') + const legacy = createAdapter('legacy', ['shared-session']) + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + const exit = vi.fn() + router.onExit(exit) + await router.discoverLegacySessions() + + await router.shutdown('shared-session', { immediate: true }) + await router.listProcesses() + await router.spawn({ sessionId: 'shared-session', cols: 80, rows: 24 }) + legacy.emitExit('shared-session', 0) + router.write('shared-session', 'replacement') + + expect(current.write).toHaveBeenCalledWith('shared-session', 'replacement') + expect(exit).not.toHaveBeenCalled() + }) + + it('defers a same-provider old exit until replacement spawn proves the id live', async () => { + const sessions: string[] = [] + const current = createAdapter('current', sessions) + const router = new DaemonPtyRouter({ current, legacy: [] }) + const exit = vi.fn() + router.onExit(exit) + await router.spawn({ sessionId: 'same-id', cols: 80, rows: 24 }) + await router.shutdown('same-id', { immediate: true }) + await router.listProcesses() + + let resolveSpawn!: (result: PtySpawnResult) => void + vi.mocked(current.spawn).mockImplementationOnce( + () => new Promise((resolve) => (resolveSpawn = resolve)) + ) + const replacement = router.spawn({ sessionId: 'same-id', cols: 80, rows: 24 }) + await vi.waitFor(() => expect(current.spawn).toHaveBeenCalledTimes(2)) + current.emitExit('same-id', 7) + expect(exit).not.toHaveBeenCalled() + + sessions.push('same-id') + resolveSpawn({ id: 'same-id' }) + await replacement + router.write('same-id', 'replacement') + + expect(exit).not.toHaveBeenCalled() + expect(current.write).toHaveBeenCalledWith('same-id', 'replacement') + }) + + it('delivers a deferred exit when replacement spawn fails with no live id', async () => { + const sessions: string[] = [] + const current = createAdapter('current', sessions) + const router = new DaemonPtyRouter({ current, legacy: [] }) + const exit = vi.fn() + router.onExit(exit) + let rejectSpawn!: (error: Error) => void + vi.mocked(current.spawn).mockImplementationOnce( + () => new Promise((_resolve, reject) => (rejectSpawn = reject)) + ) + + const replacement = router.spawn({ sessionId: 'same-id', cols: 80, rows: 24 }) + await vi.waitFor(() => expect(current.spawn).toHaveBeenCalledOnce()) + current.emitExit('same-id', 7) + rejectSpawn(new Error('spawn failed')) + + await expect(replacement).rejects.toThrow('spawn failed') + expect(exit).toHaveBeenCalledWith({ id: 'same-id', code: 7 }) + }) + + it('rejects overlapping same-id spawn before it can overwrite deferred exit proof', async () => { + const current = createAdapter('current') + const router = new DaemonPtyRouter({ current, legacy: [] }) + const exit = vi.fn() + router.onExit(exit) + let rejectSpawn!: (error: Error) => void + vi.mocked(current.spawn).mockImplementationOnce( + () => new Promise((_resolve, reject) => (rejectSpawn = reject)) + ) + + const first = router.spawn({ sessionId: 'same-id', cols: 80, rows: 24 }) + await vi.waitFor(() => expect(current.spawn).toHaveBeenCalledOnce()) + await expect(router.spawn({ sessionId: 'same-id', cols: 80, rows: 24 })).rejects.toThrow( + 'PTY spawn already in progress' + ) + current.emitExit('same-id', 7) + rejectSpawn(new Error('first spawn failed')) + + await expect(first).rejects.toThrow('first spawn failed') + expect(current.spawn).toHaveBeenCalledOnce() + expect(exit).toHaveBeenCalledOnce() + }) + + it('retains a same-adapter replacement across an older process listing', async () => { + const current = createAdapter('current') + const legacy = createAdapter('legacy', ['shared-session']) + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + await router.discoverLegacySessions() + let finishLegacyListing!: (sessions: []) => void + vi.mocked(legacy.listProcesses).mockImplementationOnce( + () => new Promise((resolve) => (finishLegacyListing = resolve)) + ) + + const staleListing = router.listProcesses() + await router.spawn({ sessionId: 'shared-session', cols: 80, rows: 24 }) + finishLegacyListing([]) + await staleListing + router.write('shared-session', 'replacement') + + expect(legacy.write).toHaveBeenCalledWith('shared-session', 'replacement') + expect(current.write).not.toHaveBeenCalledWith('shared-session', 'replacement') + }) + it('routes fresh foreground confirmation to the session-owning daemon', async () => { const current = createAdapter('current', ['current-session']) const legacy = createAdapter('legacy', ['legacy-session']) diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 18bf0f926b5..990f6b52696 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -1,17 +1,36 @@ import type { DaemonPtyAdapter } from './daemon-pty-adapter' +import { + appendProviderReconciliationIds, + discoverProviderSessionsAndBindRoutes, + forwardProviderRouteExit, + listProviderProcessesAndReconcileRoutes, + ProviderRouteSpawnExitFence, + reconcileProviderRoutesOnStartup, + type ProviderRoute +} from './pty-provider-route-reconciliation' import type { IPtyProvider, PtyBackgroundStreamEvent, PtyProviderBufferSnapshot, PtyProcessInfo, + PtyShutdownOptions, PtySpawnOptions, PtySpawnResult } from '../providers/types' export class DaemonPtyRouter implements IPtyProvider { + readonly requiresShutdownExitProof = true private current: DaemonPtyAdapter private legacy: DaemonPtyAdapter[] - private sessionAdapters = new Map() + private sessionAdapters = new Map>() + private spawnExitFence = new ProviderRouteSpawnExitFence(this.sessionAdapters, (deferred) => + forwardProviderRouteExit( + this.sessionAdapters, + deferred.provider, + deferred.payload, + this.exitListeners + ) + ) private unsubscribers: (() => void)[] = [] private dataListeners: ((payload: { id: string @@ -32,10 +51,10 @@ export class DaemonPtyRouter implements IPtyProvider { } }), adapter.onExit((payload) => { - this.sessionAdapters.delete(payload.id) - for (const listener of this.exitListeners) { - listener(payload) + if (this.spawnExitFence.deferExit(adapter, payload)) { + return } + forwardProviderRouteExit(this.sessionAdapters, adapter, payload, this.exitListeners) }) ) } @@ -44,10 +63,7 @@ export class DaemonPtyRouter implements IPtyProvider { async discoverLegacySessions(): Promise { for (const adapter of this.legacy) { try { - const sessions = await adapter.listProcesses() - for (const session of sessions) { - this.sessionAdapters.set(session.id, adapter) - } + await discoverProviderSessionsAndBindRoutes(adapter, this.sessionAdapters) } catch (error) { console.warn('[daemon] Failed to discover legacy daemon sessions', error) } @@ -55,11 +71,9 @@ export class DaemonPtyRouter implements IPtyProvider { } async spawn(opts: PtySpawnOptions): Promise { - const adapter = opts.sessionId ? this.sessionAdapters.get(opts.sessionId) : undefined + const adapter = opts.sessionId ? this.sessionAdapters.get(opts.sessionId)?.provider : undefined const target = adapter ?? this.current - const result = await target.spawn(opts) - this.sessionAdapters.set(result.id, target) - return result + return this.spawnExitFence.spawn(opts.sessionId, target, () => target.spawn(opts)) } async attach(id: string): Promise { @@ -67,7 +81,7 @@ export class DaemonPtyRouter implements IPtyProvider { } hasPty(id: string): boolean { - const routed = this.sessionAdapters.get(id) + const routed = this.sessionAdapters.get(id)?.provider if (routed) { return routed.hasPty(id) } @@ -94,17 +108,10 @@ export class DaemonPtyRouter implements IPtyProvider { this.adapterFor(id).setPtyBackgrounded(id, background) } - async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise { + async shutdown(id: string, opts: PtyShutdownOptions): Promise { await this.adapterFor(id).shutdown(id, opts) - // Why: sleep passes keepHistory=true and re-spawns against the same - // sessionId on wake. If we delete the routing entry here, adapterFor() - // falls back to `this.current` on wake — for a session that originally - // lived on a legacy adapter (different protocolVersion), the wake-side - // createOrAttach lands on the wrong adapter and creates a fresh session, - // losing the cold-restore from the legacy adapter's history dir. - if (!opts.keepHistory) { - this.sessionAdapters.delete(id) - } + // Why: native shutdown acceptance is not exit proof. onExit or a later + // successful listProcesses readback retires the exact adapter route. } async sendSignal(id: string, signal: string): Promise { @@ -161,8 +168,7 @@ export class DaemonPtyRouter implements IPtyProvider { async listProcesses(): Promise { // Why: runtime exact-stop/liveness flows must fail closed if any adapter // cannot provide a trustworthy process list. - const results = await Promise.all(this.allAdapters().map((adapter) => adapter.listProcesses())) - return results.flat() + return listProviderProcessesAndReconcileRoutes(this.allAdapters(), this.sessionAdapters) } async getDefaultShell(): Promise { @@ -225,21 +231,12 @@ export class DaemonPtyRouter implements IPtyProvider { const alive: string[] = [] const killed: string[] = [] for (const adapter of this.allAdapters()) { - const result = await adapter.reconcileOnStartup(validWorktreeIds) - // Why: daemon startup can reconcile many restored sessions; spreading - // those arrays into push can exceed JavaScript's argument limit. - for (const id of result.alive) { - alive.push(id) - } - for (const id of result.killed) { - killed.push(id) - } - for (const id of result.alive) { - this.sessionAdapters.set(id, adapter) - } - for (const id of result.killed) { - this.sessionAdapters.delete(id) - } + const result = await reconcileProviderRoutesOnStartup( + adapter, + this.sessionAdapters, + validWorktreeIds + ) + appendProviderReconciliationIds({ alive, killed }, result) } return { alive, killed } } @@ -261,6 +258,7 @@ export class DaemonPtyRouter implements IPtyProvider { // Without this, each restart leaked a router instance pinned by the legacy // adapters' listener arrays (one pair per adapter per restart). disposeRouterOnly(): void { + this.spawnExitFence.clear() for (const unsubscribe of this.unsubscribers.splice(0)) { unsubscribe() } @@ -292,7 +290,7 @@ export class DaemonPtyRouter implements IPtyProvider { } private adapterFor(sessionId: string): DaemonPtyAdapter { - return this.sessionAdapters.get(sessionId) ?? this.current + return this.sessionAdapters.get(sessionId)?.provider ?? this.current } private allAdapters(): DaemonPtyAdapter[] { diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index 3760ee89de2..4062b4e2dd3 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -8,6 +8,7 @@ import { DaemonClient } from './client' import { encodeNdjson } from './ndjson' import { PROTOCOL_VERSION, type DaemonRequest } from './types' import type { SubprocessHandle } from './session' +import type { TerminalHost } from './terminal-host' import { getDaemonSocketPath } from './daemon-spawner' const confirmForegroundProcessMock = vi.fn(async () => 'droid') @@ -29,7 +30,7 @@ function createMockSubprocess(): SubprocessHandle & { write: vi.fn(), resize: vi.fn(), kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), - forceKill: vi.fn(), + forceKill: vi.fn(() => setTimeout(() => onExitCb?.(-1), 5)), signal: vi.fn(), onData(cb) { onDataCb = cb @@ -49,6 +50,8 @@ function createMockSubprocess(): SubprocessHandle & { type DaemonServerPrivate = { server: Server | null + host: TerminalHost + streamRouteBySessionId: Map clients: Map< string, { @@ -174,10 +177,197 @@ describe('DaemonServer', () => { expect(result).toMatchObject({ isNew: true, - pid: 55555 + pid: 55555, + sessionGeneration: expect.any(String) }) }) + it('does not retain routes when subscription flushes output and exit synchronously', async () => { + server = new DaemonServer({ + socketPath, + tokenPath, + spawnSubprocess: () => { + const subprocess = createMockSubprocess() + subprocess.onData = vi.fn((callback) => callback('BOOT')) + subprocess.onExit = vi.fn((callback) => callback(0)) + return subprocess + } + }) + await server.start() + const c = await connectClient() + const daemon = server as unknown as DaemonServerPrivate + + for (let index = 0; index < 100; index++) { + await c.request('createOrAttach', { + sessionId: `synchronous-exit-${index}`, + cols: 80, + rows: 24 + }) + expect(daemon.streamRouteBySessionId.size).toBe(0) + expect(daemon.host.listSessions()).toHaveLength(0) + } + }) + + it('restores the prior route when a new attach fails before taking ownership', async () => { + server = new DaemonServer({ + socketPath, + tokenPath, + spawnSubprocess: () => { + throw new Error('spawn failed') + } + }) + const daemon = server as unknown as DaemonServerPrivate + const previousRoute = { clientId: 'previous-client', active: true } + daemon.streamRouteBySessionId.set('failed-attach', previousRoute) + + await expect( + daemon.routeRequest('replacement-client', { + id: 'failed-request', + type: 'createOrAttach', + payload: { sessionId: 'failed-attach', cols: 80, rows: 24 } + }) + ).rejects.toThrow('spawn failed') + + expect(daemon.streamRouteBySessionId.get('failed-attach')).toBe(previousRoute) + }) + + it('does not resurrect an older failed route when overlapping attaches both fail', async () => { + server = new DaemonServer({ + socketPath, + tokenPath, + spawnSubprocess: () => createMockSubprocess() + }) + const daemon = server as unknown as DaemonServerPrivate + await daemon.routeRequest('previous-client', { + id: 'previous-request', + type: 'createOrAttach', + payload: { sessionId: 'overlapping-failure', cols: 80, rows: 24 } + }) + expect(daemon.host.listSessions()).toHaveLength(1) + let rejectOlder!: (reason: unknown) => void + let rejectNewer!: (reason: unknown) => void + const olderResult = new Promise((_resolve, reject) => { + rejectOlder = reject + }) + const newerResult = new Promise((_resolve, reject) => { + rejectNewer = reject + }) + vi.spyOn(daemon.host, 'createOrAttach') + .mockReturnValueOnce(olderResult) + .mockReturnValueOnce(newerResult) + + const olderRequest = daemon.routeRequest('older-client', { + id: 'older-request', + type: 'createOrAttach', + payload: { sessionId: 'overlapping-failure', cols: 80, rows: 24 } + }) + const newerRequest = daemon.routeRequest('newer-client', { + id: 'newer-request', + type: 'createOrAttach', + payload: { sessionId: 'overlapping-failure', cols: 80, rows: 24 } + }) + + rejectOlder(new Error('older failed')) + await expect(olderRequest).rejects.toThrow('older failed') + expect(daemon.streamRouteBySessionId.get('overlapping-failure')).toMatchObject({ + clientId: 'newer-client', + active: true + }) + + rejectNewer(new Error('newer failed')) + await expect(newerRequest).rejects.toThrow('newer failed') + expect(daemon.streamRouteBySessionId.get('overlapping-failure')).toMatchObject({ + clientId: 'previous-client', + active: true + }) + expect(daemon.host.listSessions()).toHaveLength(1) + }) + + it('keeps a newer successful route when an overlapping older attach fails', async () => { + server = new DaemonServer({ + socketPath, + tokenPath, + spawnSubprocess: () => createMockSubprocess() + }) + const daemon = server as unknown as DaemonServerPrivate + let rejectOlder!: (reason: unknown) => void + const olderResult = new Promise((_resolve, reject) => { + rejectOlder = reject + }) + vi.spyOn(daemon.host, 'createOrAttach') + .mockReturnValueOnce(olderResult) + .mockResolvedValueOnce({ + isNew: true, + snapshot: null, + pid: 55555, + shellState: 'ready', + sessionGeneration: 'newer-generation', + attachToken: Symbol('newer-attach') + }) + + const olderRequest = daemon.routeRequest('older-client', { + id: 'older-request', + type: 'createOrAttach', + payload: { sessionId: 'overlapping-success', cols: 80, rows: 24 } + }) + const newerRequest = daemon.routeRequest('newer-client', { + id: 'newer-request', + type: 'createOrAttach', + payload: { sessionId: 'overlapping-success', cols: 80, rows: 24 } + }) + + rejectOlder(new Error('older failed')) + await expect(olderRequest).rejects.toThrow('older failed') + await expect(newerRequest).resolves.toMatchObject({ + sessionGeneration: 'newer-generation' + }) + expect(daemon.streamRouteBySessionId.get('overlapping-success')).toMatchObject({ + clientId: 'newer-client', + active: true + }) + }) + + it('carries one immutable session generation through create, list, size, and exit', async () => { + let subprocess: ReturnType + server = new DaemonServer({ + socketPath, + tokenPath, + spawnSubprocess: () => { + subprocess = createMockSubprocess() + return subprocess + } + }) + await server.start() + const c = await connectClient() + const created = await c.request<{ sessionGeneration: string }>('createOrAttach', { + sessionId: 'generation-session', + cols: 80, + rows: 24 + }) + const listed = await c.request<{ + sessions: { sessionGeneration: string }[] + }>('listSessions', undefined) + const sized = await c.request<{ sessionGeneration: string }>('getSize', { + sessionId: 'generation-session' + }) + const exitEvent = new Promise((resolve) => c.onEvent(resolve)) + + subprocess!._simulateExit(42) + + expect(listed.sessions[0]?.sessionGeneration).toBe(created.sessionGeneration) + expect(sized.sessionGeneration).toBe(created.sessionGeneration) + await expect(exitEvent).resolves.toMatchObject({ + event: 'exit', + payload: { code: 42, sessionGeneration: created.sessionGeneration } + }) + const replacement = await c.request<{ sessionGeneration: string }>('createOrAttach', { + sessionId: 'generation-session', + cols: 80, + rows: 24 + }) + expect(replacement.sessionGeneration).not.toBe(created.sessionGeneration) + }) + it('persists only an allowlisted launch identity across reattach', async () => { await startServer() const c = await connectClient() @@ -626,5 +816,29 @@ describe('DaemonServer', () => { const c = new DaemonClient({ socketPath, tokenPath }) await expect(c.ensureConnected()).rejects.toThrow() }) + + it('contains an RPC drain rejection and requests process exit', async () => { + const requestProcessExit = vi.fn() + server = new DaemonServer({ + socketPath, + tokenPath, + requestProcessExit, + spawnSubprocess: () => createMockSubprocess() + }) + const daemon = server as unknown as DaemonServerPrivate + const drain = vi + .spyOn(daemon.host, 'shutdownAndWait') + .mockRejectedValue(new Error('native drain failed')) + + await daemon.routeRequest('rpc-client', { + id: 'shutdown-1', + type: 'shutdown', + payload: { killSessions: true } + }) + await new Promise((resolve) => process.nextTick(resolve)) + await vi.waitFor(() => expect(requestProcessExit).toHaveBeenCalledWith(1)) + + drain.mockRestore() + }) }) }) diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index fe87022e5c5..56c84af4412 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -36,6 +36,7 @@ export type DaemonServerOptions = { tokenPath: string ptySpawnHealthCheck?: () => Promise log?: DaemonFileLog + requestProcessExit?: (exitCode: number) => void spawnSubprocess: (opts: { sessionId: string cols: number @@ -53,6 +54,12 @@ type ConnectedClient = { streamSocket: Socket | null } +type SessionStreamRoute = { + clientId: string + active: boolean + previous?: SessionStreamRoute +} + export class DaemonServer { private server: Server | null = null private token: string @@ -61,6 +68,7 @@ export class DaemonServer { private tokenPath: string private ptySpawnHealthCheck: () => Promise private log: DaemonFileLog + private requestProcessExit: DaemonServerOptions['requestProcessExit'] private clients = new Map() private streamDataBatcher = new DaemonStreamDataBatcher( @@ -84,9 +92,9 @@ export class DaemonServer { // them (a fact jumping the queue could arrive after the reveal snapshot // that already reflects it). private transientFactRelay = new BackgroundTransientFactRelay((sessionId, fact) => { - const clientId = this.streamClientIdBySessionId.get(sessionId) - if (clientId) { - this.streamDataBatcher.enqueueControlEvent(clientId, sessionId, { + const route = this.streamRouteBySessionId.get(sessionId) + if (route) { + this.streamDataBatcher.enqueueControlEvent(route.clientId, sessionId, { type: 'event', event: 'transientFact', sessionId, @@ -94,7 +102,7 @@ export class DaemonServer { }) } }) - private streamClientIdBySessionId = new Map() + private streamRouteBySessionId = new Map() private lastInputAtBySessionId = new Map() private stopStreamBacklogProbe: () => void = () => {} @@ -120,6 +128,7 @@ export class DaemonServer { backgroundedSessionIdSuffixes: this.transientFactRelay.backgroundedSessionIdSuffixes() })) this.log = opts.log ?? createNoopDaemonFileLog() + this.requestProcessExit = opts.requestProcessExit } async start(): Promise { @@ -149,7 +158,14 @@ export class DaemonServer { async shutdown(): Promise { this.stopStreamBacklogProbe() this.transientFactRelay.dispose() - this.host.dispose() + // Why: process exit must not erase a still-live PTY after native kill + // failure or acceptance. Preserve listeners while the bounded drain retries. + let drainError: unknown = null + try { + await this.host.shutdownAndWait(4_000) + } catch (error) { + drainError = error + } this.streamDataBatcher.clear() for (const [, client] of this.clients) { @@ -158,7 +174,7 @@ export class DaemonServer { } this.clients.clear() - return new Promise((resolve) => { + await new Promise((resolve) => { if (this.server) { this.server.close(() => { try { @@ -171,6 +187,9 @@ export class DaemonServer { resolve() } }) + if (drainError) { + throw drainError + } } private handleConnection(socket: Socket): void { @@ -333,7 +352,16 @@ export class DaemonServer { switch (request.type) { case 'createOrAttach': { const p = request.payload - const result = await this.host.createOrAttach({ + const previousStreamRoute = this.streamRouteBySessionId.get(p.sessionId) + const streamRoute: SessionStreamRoute = { + clientId, + active: true, + ...(previousStreamRoute ? { previous: previousStreamRoute } : {}) + } + // Why before host subscription: node-pty may flush data and exit + // synchronously, so routing ownership must exist before callbacks run. + this.streamRouteBySessionId.set(p.sessionId, streamRoute) + const pendingResult = this.host.createOrAttach({ sessionId: p.sessionId, cols: p.cols, rows: p.rows, @@ -345,6 +373,8 @@ export class DaemonServer { // Why: daemon RPC payloads are untrusted JSON. Persist only the // allowlisted enum used for byte routing, never arbitrary identity. ...(isTuiAgent(p.launchAgent) ? { launchAgent: p.launchAgent } : {}), + ...(typeof p.paneKey === 'string' ? { paneKey: p.paneKey } : {}), + ...(typeof p.tabId === 'string' ? { tabId: p.tabId } : {}), shellOverride: p.shellOverride, terminalWindowsWslDistro: p.terminalWindowsWslDistro, terminalWindowsPowerShellImplementation: p.terminalWindowsPowerShellImplementation, @@ -368,7 +398,7 @@ export class DaemonServer { flushMaxChars: DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS }) }, - onExit: (code) => { + onExit: (code, sessionGeneration) => { // Why: exit tears down renderer handlers, so it must ride the // ordered queue behind final output even when the shallow socket // gate holds that output for a later drain pass. @@ -377,19 +407,36 @@ export class DaemonServer { type: 'event', event: 'exit', sessionId: p.sessionId, - payload: { code } + payload: { code, sessionGeneration } }) this.streamDataBatcher.flush(clientId) recordDaemonStreamBacklogEvent('sessionExit', { sessionIdSuffix: p.sessionId.slice(-10) }) this.transientFactRelay.onSessionExit(p.sessionId) - this.streamClientIdBySessionId.delete(p.sessionId) + streamRoute.active = false + if (this.streamRouteBySessionId.get(p.sessionId) === streamRoute) { + this.streamRouteBySessionId.delete(p.sessionId) + } this.lastInputAtBySessionId.delete(p.sessionId) } } }) - this.streamClientIdBySessionId.set(p.sessionId, clientId) + const result = await pendingResult.catch((error: unknown) => { + streamRoute.active = false + // Why identity-fenced rollback: a failed older attach must not + // erase a newer concurrent route for the reused session id. + if (this.streamRouteBySessionId.get(p.sessionId) === streamRoute) { + const activePredecessor = this.findActiveStreamRoute(streamRoute.previous) + if (activePredecessor) { + this.streamRouteBySessionId.set(p.sessionId, activePredecessor) + } else { + this.streamRouteBySessionId.delete(p.sessionId) + } + } + throw error + }) + this.retireStreamRoutePredecessors(streamRoute) // Why an attach-time marker: the adapter resyncs the background set on // a fresh connection, which can precede this attach — main's scan // suppression must still start at the head of the new stream. @@ -411,7 +458,8 @@ export class DaemonServer { pid: result.pid, shellState: result.shellState, ...(result.launchAgent ? { launchAgent: result.launchAgent } : {}), - ...(result.historySeeded !== undefined ? { historySeeded: result.historySeeded } : {}) + ...(result.historySeeded !== undefined ? { historySeeded: result.historySeeded } : {}), + sessionGeneration: result.sessionGeneration } } @@ -469,8 +517,8 @@ export class DaemonServer { this.host.getPartialEscapeTailAnsi(sessionId) ) } - const streamClientId = this.streamClientIdBySessionId.get(sessionId) - if (!streamClientId) { + const streamRoute = this.streamRouteBySessionId.get(sessionId) + if (!streamRoute) { // Not attached yet — the attach-time marker covers the handoff. return {} } @@ -480,7 +528,7 @@ export class DaemonServer { // and the normal flush/drain loop delivers them within milliseconds // (bounded ≤ the keep-tail drop cap), in order, ahead of the marker. const scanSeedAnsi = background ? '' : this.host.getPartialEscapeTailAnsi(sessionId) - this.streamDataBatcher.enqueueControlEvent(streamClientId, sessionId, { + this.streamDataBatcher.enqueueControlEvent(streamRoute.clientId, sessionId, { type: 'event', event: 'sessionBackgroundMarker', sessionId, @@ -498,7 +546,11 @@ export class DaemonServer { sessionId: request.payload.sessionId, immediate: request.payload.immediate === true }) - this.host.kill(request.payload.sessionId, { immediate: request.payload.immediate }) + this.host.kill(request.payload.sessionId, { + immediate: request.payload.immediate, + expectedPaneKey: request.payload.expectedPaneKey, + expectedTabId: request.payload.expectedTabId + }) return {} case 'signal': @@ -551,7 +603,7 @@ export class DaemonServer { } case 'getSize': - return { size: this.host.getAppliedSize(request.payload.sessionId) } + return this.host.getAppliedSizeWithGeneration(request.payload.sessionId) ?? { size: null } case 'takePendingOutput': // Why no await before this call: with includeSnapshot, drain and @@ -579,15 +631,52 @@ export class DaemonServer { reason: 'rpc', killSessions: request.payload.killSessions === true }) - if (request.payload.killSessions) { - this.host.dispose() - } - process.nextTick(() => this.shutdown()) + // shutdownForRpc owns the exit-proof drain and contains failures before + // explicitly terminating the detached daemon process. + process.nextTick(() => void this.shutdownForRpc()) return {} } throw new Error(`Unknown request type: ${(request as { type: string }).type}`) } + private async shutdownForRpc(): Promise { + let exitCode = 0 + try { + await this.shutdown() + } catch (error) { + exitCode = 1 + this.log.log('shutdown-error', { + reason: 'rpc', + message: error instanceof Error ? error.message : String(error) + }) + } finally { + // Why: a failed native kill keeps PTY handles alive; explicit process + // exit prevents an untracked old daemon from surviving after socket removal. + this.requestProcessExit?.(exitCode) + } + } + + private findActiveStreamRoute( + route: SessionStreamRoute | undefined + ): SessionStreamRoute | undefined { + let candidate = route + while (candidate && !candidate.active) { + candidate = candidate.previous + } + return candidate + } + + private retireStreamRoutePredecessors(route: SessionStreamRoute): void { + let predecessor = route.previous + route.previous = undefined + while (predecessor) { + predecessor.active = false + const next = predecessor.previous + predecessor.previous = undefined + predecessor = next + } + } + private sendExitEvent( client: ConnectedClient | undefined, sessionId: string, diff --git a/src/main/daemon/daemon-session-exit-fence.test.ts b/src/main/daemon/daemon-session-exit-fence.test.ts new file mode 100644 index 00000000000..4c38bffd338 --- /dev/null +++ b/src/main/daemon/daemon-session-exit-fence.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { DaemonSessionExitFence } from './daemon-session-exit-fence' + +describe('DaemonSessionExitFence', () => { + it('invalidates an absent-list snapshot across same-id admission completion', () => { + const fence = new DaemonSessionExitFence() + fence.rememberGeneration('same-id', 'old-generation') + fence.defer('same-id', { code: 7, sessionGeneration: 'old-generation' }) + const staleListSnapshot = fence.snapshot('same-id') + + const admission = fence.beginAdmission('same-id') + fence.rememberGeneration('same-id', 'replacement-generation', admission) + admission.complete() + + expect(fence.isStable('same-id', staleListSnapshot)).toBe(false) + expect(fence.isStaleGeneration('same-id', 'old-generation')).toBe(true) + expect(fence.getPending('same-id')).toEqual({ + code: 7, + sessionGeneration: 'old-generation' + }) + }) + + it('defers generation mismatch classification while admission is active', () => { + const fence = new DaemonSessionExitFence() + fence.rememberGeneration('same-id', 'old-generation') + + const admission = fence.beginAdmission('same-id') + expect(fence.isAdmissionActive('same-id')).toBe(true) + expect(fence.isStaleGeneration('same-id', 'replacement-generation')).toBe(false) + + fence.rememberGeneration('same-id', 'replacement-generation', admission) + admission.complete() + expect(fence.isAdmissionActive('same-id')).toBe(false) + expect(fence.isStaleGeneration('same-id', 'old-generation')).toBe(true) + }) + + it('releases every per-session map after repeated exit finalization', () => { + const fence = new DaemonSessionExitFence() + for (let i = 0; i < 2_000; i++) { + const id = `session-${i}` + const admission = fence.beginAdmission(id) + fence.rememberGeneration(id, `generation-${i}`, admission) + fence.defer(id, { code: i }) + admission.complete() + fence.forget(id) + } + const internals = fence as unknown as Record< + 'revisions' | 'admissions' | 'sessionGenerations' | 'pendingExits', + Map + > + + expect(internals.revisions.size).toBe(0) + expect(internals.admissions.size).toBe(0) + expect(internals.sessionGenerations.size).toBe(0) + expect(internals.pendingExits.size).toBe(0) + }) + + it('does not recreate fence state when an admission completes after clear', () => { + const fence = new DaemonSessionExitFence() + const admission = fence.beginAdmission('late-session') + fence.clear() + + expect(fence.rememberGeneration('late-session', 'late-generation', admission)).toBe(false) + admission.complete() + + expect(fence.snapshot('late-session')).toBeUndefined() + }) +}) diff --git a/src/main/daemon/daemon-session-exit-fence.ts b/src/main/daemon/daemon-session-exit-fence.ts new file mode 100644 index 00000000000..9b620c8975f --- /dev/null +++ b/src/main/daemon/daemon-session-exit-fence.ts @@ -0,0 +1,124 @@ +export type PendingDaemonExit = { + code: number + sessionGeneration?: string +} + +export type DaemonSessionAdmission = { + sessionId: string + isCurrent: () => boolean + complete: () => void +} + +export class DaemonSessionExitFence { + // Why global-monotonic: forgetting and then reusing the same id must not + // recreate a revision that an older async list snapshot can mistake as live. + private nextRevision = 0 + private clearEpoch = 0 + private revisions = new Map() + private admissions = new Map() + private sessionGenerations = new Map() + private pendingExits = new Map() + + beginAdmission(sessionId: string): DaemonSessionAdmission { + const admissionEpoch = this.clearEpoch + this.bump(sessionId) + this.admissions.set(sessionId, (this.admissions.get(sessionId) ?? 0) + 1) + let completed = false + const isCurrent = (): boolean => !completed && admissionEpoch === this.clearEpoch + const complete = (): void => { + if (completed) { + return + } + completed = true + // Why: dispose/fanout clears every owner; a late spawn finally block + // must not recreate per-session fence state after that terminal event. + if (admissionEpoch !== this.clearEpoch) { + return + } + const remaining = (this.admissions.get(sessionId) ?? 1) - 1 + if (remaining === 0) { + this.admissions.delete(sessionId) + } else { + this.admissions.set(sessionId, remaining) + } + this.bump(sessionId) + } + return { sessionId, isCurrent, complete } + } + + rememberGeneration( + sessionId: string, + generation: string | undefined, + admission?: DaemonSessionAdmission + ): boolean { + if (admission && (admission.sessionId !== sessionId || !admission.isCurrent())) { + return false + } + if (!generation || this.sessionGenerations.get(sessionId) === generation) { + return true + } + this.sessionGenerations.set(sessionId, generation) + this.bump(sessionId) + return true + } + + isStaleGeneration(sessionId: string, generation: string | undefined): boolean { + const current = this.sessionGenerations.get(sessionId) + // Why: a replacement can exit before its create reply publishes the new + // generation; admission completion decides whether that queued exit won. + return ( + !this.admissions.has(sessionId) && + generation !== undefined && + current !== undefined && + generation !== current + ) + } + + isAdmissionActive(sessionId: string): boolean { + return this.admissions.has(sessionId) + } + + snapshot(sessionId: string): number | undefined { + return this.revisions.get(sessionId) + } + + isStable(sessionId: string, snapshot: number | undefined): boolean { + return !this.admissions.has(sessionId) && this.revisions.get(sessionId) === snapshot + } + + defer(sessionId: string, exit: PendingDaemonExit): void { + this.pendingExits.set(sessionId, exit) + } + + getPending(sessionId: string): PendingDaemonExit | undefined { + return this.pendingExits.get(sessionId) + } + + pendingEntries(): [string, PendingDaemonExit][] { + return [...this.pendingExits] + } + + clearPending(sessionId: string): void { + this.pendingExits.delete(sessionId) + } + + forget(sessionId: string): void { + this.bump(sessionId) + this.pendingExits.delete(sessionId) + this.sessionGenerations.delete(sessionId) + this.admissions.delete(sessionId) + this.revisions.delete(sessionId) + } + + clear(): void { + this.clearEpoch++ + this.pendingExits.clear() + this.sessionGenerations.clear() + this.admissions.clear() + this.revisions.clear() + } + + private bump(sessionId: string): void { + this.revisions.set(sessionId, ++this.nextRevision) + } +} diff --git a/src/main/daemon/daemon-spawner.test.ts b/src/main/daemon/daemon-spawner.test.ts index 429a864f841..6ae301ee71a 100644 --- a/src/main/daemon/daemon-spawner.test.ts +++ b/src/main/daemon/daemon-spawner.test.ts @@ -25,7 +25,7 @@ function createMockSubprocess(): SubprocessHandle { write: vi.fn(), resize: vi.fn(), kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), - forceKill: vi.fn(), + forceKill: vi.fn(() => setTimeout(() => onExitCb?.(-1), 5)), signal: vi.fn(), onData(_cb: (data: string) => void) {}, onExit(cb: (code: number) => void) { diff --git a/src/main/daemon/daemon-stream-events.ts b/src/main/daemon/daemon-stream-events.ts index 1644642bd09..2f3e4ac9554 100644 --- a/src/main/daemon/daemon-stream-events.ts +++ b/src/main/daemon/daemon-stream-events.ts @@ -12,7 +12,7 @@ export type ExitEvent = { type: 'event' event: 'exit' sessionId: string - payload: { code: number } + payload: { code: number; sessionGeneration?: string } } export type TerminalErrorEvent = { diff --git a/src/main/daemon/degraded-daemon-fallback-shutdown.ts b/src/main/daemon/degraded-daemon-fallback-shutdown.ts index 0d4932d6c20..d72e359baf3 100644 --- a/src/main/daemon/degraded-daemon-fallback-shutdown.ts +++ b/src/main/daemon/degraded-daemon-fallback-shutdown.ts @@ -1,25 +1,55 @@ import type { IPtyProvider } from '../providers/types' +import { deleteProviderRoute, type ProviderRoute } from './pty-provider-route-reconciliation' export async function shutdownDegradedFallbackSessions( - sessionProviders: Map, + sessionProviders: Map>, fallback: T ): Promise { - const ids = [...sessionProviders] - .filter(([, provider]) => provider === fallback) - .map(([id]) => id) - const results = await Promise.allSettled( - ids.map(async (id) => { - await fallback.shutdown(id, { immediate: true }) - sessionProviders.delete(id) - }) + const routes = [...sessionProviders].filter(([, route]) => route.provider === fallback) + const shutdownResults = await Promise.allSettled( + routes.map(([id]) => fallback.shutdown(id, { immediate: true })) ) - // Why: fallback cleanup must not abort the user's daemon-restart recovery path. - const failed = results.filter((result) => result.status === 'rejected') + let liveIds: Set | null = null + let listingError: unknown = null + if (fallback.requiresShutdownExitProof && routes.length > 0) { + try { + // Why: daemon restart can drain many fallback PTYs at once. One shared + // inventory proves every accepted shutdown without an O(N²) scan. + liveIds = new Set((await fallback.listProcesses()).map((session) => session.id)) + } catch (error) { + listingError = error + } + } + const failed: unknown[] = [] + for (const [index, [id, route]] of routes.entries()) { + const shutdownResult = shutdownResults[index] + const failure = fallback.requiresShutdownExitProof + ? (listingError ?? + (liveIds?.has(id) + ? shutdownResult.status === 'rejected' + ? shutdownResult.reason + : new Error(`Fallback PTY "${id}" shutdown is accepted but exit is not confirmed`) + : null)) + : shutdownResult.status === 'rejected' + ? shutdownResult.reason + : null + if (failure) { + failed.push(failure) + } else { + deleteProviderRoute(sessionProviders, id, route) + } + } if (failed.length > 0) { console.warn( - `[daemon] ${failed.length} local fallback PTY session(s) failed to shut down during daemon restart; continuing restart`, - ...failed.map((result) => (result as PromiseRejectedResult).reason) + `[daemon] ${failed.length} local fallback PTY session(s) could not prove exit; blocking daemon replacement`, + ...failed + ) + // Why: replacing the degraded provider would make these exact local PTYs + // unreachable. Keep the old provider and routes intact until exit is proved. + throw new AggregateError( + failed, + 'Cannot restart daemon while local fallback PTY exit remains unconfirmed' ) } - return results.length - failed.length + return routes.length } diff --git a/src/main/daemon/degraded-daemon-pty-provider.test.ts b/src/main/daemon/degraded-daemon-pty-provider.test.ts index 188173816db..722312daecb 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.test.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.test.ts @@ -109,6 +109,119 @@ function createDaemonAdapter( } describe('DegradedDaemonPtyProvider', () => { + it('retains the exact child route until exit proof', async () => { + const daemonSessions = ['shared-session'] + const current = createDaemonAdapter('daemon', daemonSessions) + const fallback = createProvider('fallback') + vi.mocked(current.shutdown).mockResolvedValueOnce(undefined) + const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback }) + await provider.discoverDaemonSessions() + + expect(provider.requiresShutdownExitProof).toBe(true) + await provider.shutdown('shared-session', { immediate: true }) + provider.write('shared-session', 'before-exit') + + expect(current.write).toHaveBeenCalledWith('shared-session', 'before-exit') + daemonSessions.length = 0 + current.emitExit('shared-session', 0) + provider.write('shared-session', 'after-exit') + expect(fallback.write).toHaveBeenCalledWith('shared-session', 'after-exit') + }) + + it('retains a same-provider replacement across an older process listing', async () => { + const current = createDaemonAdapter('daemon', ['shared-session']) + const fallback = createProvider('fallback') + const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback }) + await provider.discoverDaemonSessions() + let finishDaemonListing!: (sessions: []) => void + vi.mocked(current.listProcesses).mockImplementationOnce( + () => new Promise((resolve) => (finishDaemonListing = resolve)) + ) + + const staleListing = provider.listProcesses() + await provider.spawn({ sessionId: 'shared-session', cols: 80, rows: 24 }) + finishDaemonListing([]) + await staleListing + provider.write('shared-session', 'replacement') + + expect(provider.getCurrentDaemonSessionIds()).toEqual(['shared-session']) + expect(current.write).toHaveBeenCalledWith('shared-session', 'replacement') + expect(fallback.write).not.toHaveBeenCalledWith('shared-session', 'replacement') + }) + + it('defers a fallback old exit until replacement spawn proves the id live', async () => { + const fallbackSessions: string[] = [] + const current = createDaemonAdapter('daemon') + const fallback = createProvider('fallback', fallbackSessions) + const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback }) + const exit = vi.fn() + provider.onExit(exit) + await provider.spawn({ sessionId: 'same-id', cols: 80, rows: 24 }) + await provider.shutdown('same-id', { immediate: true }) + await provider.listProcesses() + + let resolveSpawn!: (result: PtySpawnResult) => void + vi.mocked(fallback.spawn).mockImplementationOnce( + () => new Promise((resolve) => (resolveSpawn = resolve)) + ) + const replacement = provider.spawn({ sessionId: 'same-id', cols: 80, rows: 24 }) + await vi.waitFor(() => expect(fallback.spawn).toHaveBeenCalledTimes(2)) + fallback.emitExit('same-id', 7) + expect(exit).not.toHaveBeenCalled() + + fallbackSessions.push('same-id') + resolveSpawn({ id: 'same-id' }) + await replacement + provider.write('same-id', 'replacement') + + expect(exit).not.toHaveBeenCalled() + expect(fallback.write).toHaveBeenCalledWith('same-id', 'replacement') + }) + + it('delivers a deferred fallback exit when replacement spawn fails with no live id', async () => { + const current = createDaemonAdapter('daemon') + const fallback = createProvider('fallback') + const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback }) + const exit = vi.fn() + provider.onExit(exit) + let rejectSpawn!: (error: Error) => void + vi.mocked(fallback.spawn).mockImplementationOnce( + () => new Promise((_resolve, reject) => (rejectSpawn = reject)) + ) + + const replacement = provider.spawn({ sessionId: 'same-id', cols: 80, rows: 24 }) + await vi.waitFor(() => expect(fallback.spawn).toHaveBeenCalledOnce()) + fallback.emitExit('same-id', 7) + rejectSpawn(new Error('spawn failed')) + + await expect(replacement).rejects.toThrow('spawn failed') + expect(exit).toHaveBeenCalledWith({ id: 'same-id', code: 7 }) + }) + + it('rejects overlapping fallback spawn before it can overwrite deferred exit proof', async () => { + const current = createDaemonAdapter('daemon') + const fallback = createProvider('fallback') + const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback }) + const exit = vi.fn() + provider.onExit(exit) + let rejectSpawn!: (error: Error) => void + vi.mocked(fallback.spawn).mockImplementationOnce( + () => new Promise((_resolve, reject) => (rejectSpawn = reject)) + ) + + const first = provider.spawn({ sessionId: 'same-id', cols: 80, rows: 24 }) + await vi.waitFor(() => expect(fallback.spawn).toHaveBeenCalledOnce()) + await expect(provider.spawn({ sessionId: 'same-id', cols: 80, rows: 24 })).rejects.toThrow( + 'PTY spawn already in progress' + ) + fallback.emitExit('same-id', 7) + rejectSpawn(new Error('first spawn failed')) + + await expect(first).rejects.toThrow('first spawn failed') + expect(fallback.spawn).toHaveBeenCalledOnce() + expect(exit).toHaveBeenCalledOnce() + }) + it('routes fresh foreground confirmation to the session owner', async () => { const current = createDaemonAdapter('daemon', ['daemon-session']) const fallback = createProvider('fallback') @@ -262,7 +375,7 @@ describe('DegradedDaemonPtyProvider', () => { expect(provider.hasPty(fresh.id)).toBe(false) }) - it('is best-effort: counts only successful shutdowns and never throws (keeps restart alive)', async () => { + it('blocks provider replacement when a fallback shutdown rejects', async () => { const current = createDaemonAdapter('daemon') const fallback = createProvider('fallback') const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback }) @@ -275,18 +388,59 @@ describe('DegradedDaemonPtyProvider', () => { } }) - // Why: a single un-killable local PTY must not abort the daemon restart. - const killedCount = await provider.shutdownFallbackSessions() + await expect(provider.shutdownFallbackSessions()).rejects.toThrow( + 'Cannot restart daemon while local fallback PTY exit remains unconfirmed' + ) - // Best-effort: the one that shut down is counted, the stuck one is not, and - // crucially it does not throw — so the daemon restart sequence proceeds. - expect(killedCount).toBe(1) expect(warn).toHaveBeenCalled() expect(fallback.shutdown).toHaveBeenCalledWith('stuck', { immediate: true }) expect(fallback.shutdown).toHaveBeenCalledWith('ok', { immediate: true }) + expect(provider.hasPty('stuck')).toBe(true) warn.mockRestore() }) + it('blocks provider replacement after accepted fallback shutdown without exit proof', async () => { + const fallbackSessions: string[] = [] + const current = createDaemonAdapter('daemon') + const fallback = createProvider('fallback', fallbackSessions) + Object.defineProperty(fallback, 'requiresShutdownExitProof', { value: true }) + vi.mocked(fallback.shutdown).mockResolvedValue(undefined) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback }) + await provider.spawn({ sessionId: 'still-live-a', cols: 80, rows: 24 }) + await provider.spawn({ sessionId: 'still-live-b', cols: 80, rows: 24 }) + + await expect(provider.shutdownFallbackSessions()).rejects.toThrow( + 'Cannot restart daemon while local fallback PTY exit remains unconfirmed' + ) + + expect(fallback.shutdown).toHaveBeenCalledTimes(2) + expect(fallback.listProcesses).toHaveBeenCalledOnce() + expect(provider.hasPty('still-live-a')).toBe(true) + expect(provider.hasPty('still-live-b')).toBe(true) + provider.write('still-live-a', 'route-retained') + expect(fallback.write).toHaveBeenCalledWith('still-live-a', 'route-retained') + warn.mockRestore() + }) + + it('allows replacement when inventory proves death after fallback shutdown throws', async () => { + const fallbackSessions: string[] = [] + const current = createDaemonAdapter('daemon') + const fallback = createProvider('fallback', fallbackSessions) + Object.defineProperty(fallback, 'requiresShutdownExitProof', { value: true }) + vi.mocked(fallback.shutdown).mockImplementation(async (id: string) => { + fallbackSessions.splice(fallbackSessions.indexOf(id), 1) + throw new Error('native close threw after process exit') + }) + const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback }) + await provider.spawn({ sessionId: 'proved-dead', cols: 80, rows: 24 }) + + await expect(provider.shutdownFallbackSessions()).resolves.toBe(1) + + expect(fallback.listProcesses).toHaveBeenCalledOnce() + expect(provider.hasPty('proved-dead')).toBe(false) + }) + it('fans synthetic exits for discovered current-daemon sessions only', async () => { const current = createDaemonAdapter('daemon', ['current-session']) const legacy = createDaemonAdapter('legacy', ['legacy-session']) diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index 572cb0aca37..e3bdd0a69b5 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -1,5 +1,6 @@ import type { DaemonPtyAdapter } from './daemon-pty-adapter' import { shutdownDegradedFallbackSessions } from './degraded-daemon-fallback-shutdown' +import * as PtyRoutes from './pty-provider-route-reconciliation' import type { IPtyProvider, PtyBackgroundStreamEvent, @@ -9,22 +10,28 @@ import type { PtySpawnResult } from '../providers/types' -type ManagedPtyProvider = IPtyProvider & { - disconnectOnly?: () => Promise - dispose?: () => void -} +type ManagedPtyProvider = PtyRoutes.ManagedPtyProvider export class DegradedDaemonPtyProvider implements IPtyProvider { + readonly requiresShutdownExitProof = true readonly routesFreshSpawnsToLocalProvider = true - // Why: the preserved daemon answers protocol but cannot spawn fresh PTYs. - // Surfaced (e.g. via pty:management:listSessions) so the UI can warn that - // new terminals are running without daemon persistence until a restart. + // Why: the preserved daemon answers protocol but cannot spawn fresh PTYs; + // surface degraded state so UI warns that new terminals lack persistence. readonly isDegraded = true - private current: DaemonPtyAdapter private legacy: DaemonPtyAdapter[] private fallback: ManagedPtyProvider - private sessionProviders = new Map() + private sessionProviders = new Map>() + private spawnExitFence = new PtyRoutes.ProviderRouteSpawnExitFence( + this.sessionProviders, + (deferred) => + PtyRoutes.forwardProviderRouteExit( + this.sessionProviders, + deferred.provider, + deferred.payload, + this.exitListeners + ) + ) private unsubscribers: (() => void)[] = [] private dataListeners: ((payload: { id: string @@ -50,10 +57,15 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { } }), provider.onExit((payload) => { - this.sessionProviders.delete(payload.id) - for (const listener of this.exitListeners) { - listener(payload) + if (this.spawnExitFence.deferExit(provider, payload)) { + return } + PtyRoutes.forwardProviderRouteExit( + this.sessionProviders, + provider, + payload, + this.exitListeners + ) }) ) } @@ -62,10 +74,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { async discoverDaemonSessions(): Promise { for (const adapter of this.allDaemonAdapters()) { try { - const sessions = await adapter.listProcesses() - for (const session of sessions) { - this.sessionProviders.set(session.id, adapter) - } + await PtyRoutes.discoverProviderSessionsAndBindRoutes(adapter, this.sessionProviders) } catch (error) { console.warn('[daemon] Failed to discover degraded daemon sessions', error) } @@ -73,11 +82,9 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { } async spawn(opts: PtySpawnOptions): Promise { - const mapped = opts.sessionId ? this.sessionProviders.get(opts.sessionId) : undefined + const mapped = opts.sessionId ? this.sessionProviders.get(opts.sessionId)?.provider : undefined const target = mapped ?? this.fallback - const result = await target.spawn(opts) - this.sessionProviders.set(result.id, target) - return result + return this.spawnExitFence.spawn(opts.sessionId, target, () => target.spawn(opts)) } async attach(id: string): Promise { @@ -85,7 +92,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { } hasPty(id: string): boolean { - const mapped = this.sessionProviders.get(id) + const mapped = this.sessionProviders.get(id)?.provider if (mapped) { return mapped.hasPty?.(id) ?? true } @@ -114,9 +121,8 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise { await this.providerFor(id).shutdown(id, opts) - if (!opts.keepHistory) { - this.sessionProviders.delete(id) - } + // Why: shutdown acceptance is not physical-exit proof. Keep routing until + // the owning provider emits onExit or listProcesses proves the id absent. } async sendSignal(id: string, signal: string): Promise { @@ -173,10 +179,10 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { } async listProcesses(): Promise { - const results = await Promise.all( - this.allProviders().map((provider) => provider.listProcesses()) + return PtyRoutes.listProviderProcessesAndReconcileRoutes( + this.allProviders(), + this.sessionProviders ) - return results.flat() } async getDefaultShell(): Promise { @@ -255,15 +261,12 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { const alive: string[] = [] const killed: string[] = [] for (const adapter of this.allDaemonAdapters()) { - const result = await adapter.reconcileOnStartup(validWorktreeIds) - for (const id of result.alive) { - alive.push(id) - this.sessionProviders.set(id, adapter) - } - for (const id of result.killed) { - killed.push(id) - this.sessionProviders.delete(id) - } + const result = await PtyRoutes.reconcileProviderRoutesOnStartup( + adapter, + this.sessionProviders, + validWorktreeIds + ) + PtyRoutes.appendProviderReconciliationIds({ alive, killed }, result) } return { alive, killed } } @@ -276,6 +279,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { } disposeProviderOnly(): void { + this.spawnExitFence.clear() for (const unsubscribe of this.unsubscribers.splice(0)) { unsubscribe() } @@ -286,12 +290,12 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { } getCurrentDaemonSessionIds(): string[] { - return this.sessionIdsForProvider(this.current) + return PtyRoutes.providerSessionIds(this.sessionProviders, this.current) } fanoutCurrentDaemonSyntheticExits(code: number): void { for (const id of this.getCurrentDaemonSessionIds()) { - this.sessionProviders.delete(id) + PtyRoutes.deleteProviderRoute(this.sessionProviders, id) // Why: sessions discovered from listProcesses may not exist in the // adapter's active-session set, but restart still kills that daemon. // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration @@ -320,7 +324,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { private providerFor(sessionId: string): ManagedPtyProvider { return ( - this.sessionProviders.get(sessionId) ?? + this.sessionProviders.get(sessionId)?.provider ?? this.findProviderForExistingSession(sessionId) ?? this.fallback ) @@ -329,21 +333,15 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { private findProviderForExistingSession(sessionId: string): ManagedPtyProvider | null { for (const provider of this.allProviders()) { if (provider.hasPty?.(sessionId) === true) { - this.sessionProviders.set(sessionId, provider) + PtyRoutes.bindProviderRoute(this.sessionProviders, sessionId, provider) return provider } } return null } - private sessionIdsForProvider(provider: ManagedPtyProvider): string[] { - return [...this.sessionProviders] - .filter(([, mappedProvider]) => mappedProvider === provider) - .map(([id]) => id) - } - private daemonAdapterFor(sessionId: string): DaemonPtyAdapter | null { - const provider = this.sessionProviders.get(sessionId) + const provider = this.sessionProviders.get(sessionId)?.provider return provider && this.allDaemonAdapters().includes(provider as DaemonPtyAdapter) ? (provider as DaemonPtyAdapter) : null diff --git a/src/main/daemon/pty-provider-route-reconciliation.test.ts b/src/main/daemon/pty-provider-route-reconciliation.test.ts new file mode 100644 index 00000000000..5431f1577b6 --- /dev/null +++ b/src/main/daemon/pty-provider-route-reconciliation.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { + bindProviderRoute, + captureProviderRouteSnapshot, + deleteProviderRoute, + reconcileProviderRoutesAfterStartup, + type ProviderRoute +} from './pty-provider-route-reconciliation' + +describe('provider route reconciliation generations', () => { + it('fences absent-present-absent ABA during startup readback', () => { + const provider = { label: 'startup-owner' } + const routes = new Map>() + const snapshot = captureProviderRouteSnapshot(routes) + + bindProviderRoute(routes, 'same-id', provider) + deleteProviderRoute(routes, 'same-id') + reconcileProviderRoutesAfterStartup(routes, snapshot, provider, { + alive: ['same-id'], + killed: [] + }) + snapshot.dispose() + + expect(routes.has('same-id')).toBe(false) + }) + + it('stops retaining route mutation ids after snapshot disposal', () => { + const provider = { label: 'owner' } + const routes = new Map>() + const snapshot = captureProviderRouteSnapshot(routes) + bindProviderRoute(routes, 'observed', provider) + snapshot.dispose() + + bindProviderRoute(routes, 'after-dispose', provider) + + expect([...snapshot.mutatedIds]).toEqual(['observed']) + }) +}) diff --git a/src/main/daemon/pty-provider-route-reconciliation.ts b/src/main/daemon/pty-provider-route-reconciliation.ts new file mode 100644 index 00000000000..baaadf4d09f --- /dev/null +++ b/src/main/daemon/pty-provider-route-reconciliation.ts @@ -0,0 +1,326 @@ +import type { IPtyProvider, PtyProcessInfo } from '../providers/types' + +export type ProviderRoute = Readonly<{ provider: T }> + +export type ManagedPtyProvider = IPtyProvider & { + disconnectOnly?: () => Promise + dispose?: () => void +} + +type ProviderRouteDeferredExit = { + provider: T + payload: { id: string; code: number } +} + +type ProviderRouteSpawnAdmission = { + id: string + deferredExits: ProviderRouteDeferredExit[] +} + +type RouteMutationObserver = { mutatedIds: Set } + +export type ProviderRouteSnapshot = RouteMutationObserver & { + routesAtStart: Map> + dispose: () => void +} + +const routeMutationObservers = new WeakMap>() + +function beginProviderRouteSpawnAdmission( + admissions: Map>, + id: string +): ProviderRouteSpawnAdmission { + // Why: one provider session belongs to one pane identity. Concurrent + // same-id admission cannot be ordered safely, so fail closed before spawn. + if (admissions.has(id)) { + throw new Error(`PTY spawn already in progress for "${id}"`) + } + const admission = { id, deferredExits: [] } + admissions.set(id, admission) + return admission +} + +function deferProviderRouteExitDuringSpawn( + admissions: Map>, + provider: T, + payload: { id: string; code: number } +): boolean { + const admission = admissions.get(payload.id) + if (!admission) { + return false + } + const existing = admission.deferredExits.find((exit) => exit.provider === provider) + if (existing) { + existing.payload = payload + } else { + // Why: providers may duplicate an exit callback; retain at most one event + // per bounded provider while a replacement spawn is unresolved. + admission.deferredExits.push({ provider, payload }) + } + return true +} + +function settleProviderRouteSpawnAdmission( + admissions: Map>, + admission: ProviderRouteSpawnAdmission, + routes: Map> +): ProviderRouteDeferredExit[] { + if (admissions.get(admission.id) !== admission) { + return [] + } + admissions.delete(admission.id) + const route = routes.get(admission.id) + return admission.deferredExits.filter(({ provider }) => { + if (route && route.provider !== provider) { + return false + } + const owner = route?.provider ?? provider + // Why: an exit that races replacement admission is ambiguous. Deliver it + // only when the exact provider can prove no same-id process survived. + return owner.hasPty?.(admission.id) === false + }) +} + +export class ProviderRouteSpawnExitFence { + private admissions = new Map>() + + constructor( + private routes: Map>, + private handleExit: (exit: ProviderRouteDeferredExit) => void + ) {} + + deferExit(provider: T, payload: { id: string; code: number }): boolean { + return deferProviderRouteExitDuringSpawn(this.admissions, provider, payload) + } + + async spawn( + requestedId: string | undefined, + provider: T, + spawn: () => Promise + ): Promise { + const admission = requestedId + ? beginProviderRouteSpawnAdmission(this.admissions, requestedId) + : null + try { + const result = await spawn() + bindProviderRoute(this.routes, result.id, provider) + this.settle(admission) + return result + } catch (error) { + this.settle(admission) + throw error + } + } + + clear(): void { + this.admissions.clear() + } + + private settle(admission: ProviderRouteSpawnAdmission | null): void { + if (!admission) { + return + } + for (const deferred of settleProviderRouteSpawnAdmission( + this.admissions, + admission, + this.routes + )) { + this.handleExit(deferred) + } + } +} + +export function forwardProviderRouteExit( + routes: Map>, + provider: T, + payload: { id: string; code: number }, + listeners: ((payload: { id: string; code: number }) => void)[] +): void { + // Why: a late child exit must not delete or fan out across a route already + // rebound to a replacement provider generation. + const route = routes.get(payload.id) + if (route && route.provider !== provider) { + return + } + if (route?.provider === provider) { + deleteProviderRoute(routes, payload.id, route) + } + for (const listener of listeners) { + listener(payload) + } +} + +function markProviderRouteMutation(routes: Map>, id: string): void { + for (const observer of routeMutationObservers.get(routes) ?? []) { + observer.mutatedIds.add(id) + } +} + +export function captureProviderRouteSnapshot( + routes: Map> +): ProviderRouteSnapshot { + const observer: RouteMutationObserver = { mutatedIds: new Set() } + const observers = routeMutationObservers.get(routes) ?? new Set() + observers.add(observer) + routeMutationObservers.set(routes, observers) + let active = true + return { + routesAtStart: new Map(routes), + mutatedIds: observer.mutatedIds, + dispose: () => { + if (!active) { + return + } + active = false + observers.delete(observer) + if (observers.size === 0) { + routeMutationObservers.delete(routes) + } + } + } +} + +function providerRouteIsUnchanged( + routes: Map>, + snapshot: ProviderRouteSnapshot, + id: string +): boolean { + return !snapshot.mutatedIds.has(id) && routes.get(id) === snapshot.routesAtStart.get(id) +} + +export function bindProviderRoute( + routes: Map>, + id: string, + provider: T +): ProviderRoute { + // Why: the immutable binding object is the generation token; rebinding the + // same id to the same provider must still fence older async readbacks. + const route = { provider } + markProviderRouteMutation(routes, id) + routes.set(id, route) + return route +} + +export function deleteProviderRoute( + routes: Map>, + id: string, + expectedRoute?: ProviderRoute +): boolean { + if (expectedRoute && routes.get(id) !== expectedRoute) { + return false + } + const deleted = routes.delete(id) + if (deleted) { + markProviderRouteMutation(routes, id) + } + return deleted +} + +export function reconcileProviderRoutesAfterStartup( + routes: Map>, + snapshot: ProviderRouteSnapshot, + provider: T, + result: { alive: string[]; killed: string[] } +): void { + // Why: startup reconciliation may overlap a respawn; only its unchanged + // binding generation may be replaced or removed by the older readback. + for (const id of result.alive) { + if (providerRouteIsUnchanged(routes, snapshot, id)) { + bindProviderRoute(routes, id, provider) + } + } + for (const id of result.killed) { + const routeAtStart = snapshot.routesAtStart.get(id) + if (routeAtStart) { + deleteProviderRoute(routes, id, routeAtStart) + } + } +} + +export async function discoverProviderSessionsAndBindRoutes( + provider: T, + routes: Map> +): Promise { + const snapshot = captureProviderRouteSnapshot(routes) + try { + const sessions = await provider.listProcesses() + for (const session of sessions) { + if (providerRouteIsUnchanged(routes, snapshot, session.id)) { + bindProviderRoute(routes, session.id, provider) + } + } + } finally { + snapshot.dispose() + } +} + +type StartupReconcilingProvider = IPtyProvider & { + reconcileOnStartup(validWorktreeIds: Set): Promise<{ alive: string[]; killed: string[] }> +} + +export async function reconcileProviderRoutesOnStartup< + T extends IPtyProvider, + P extends T & StartupReconcilingProvider +>( + provider: P, + routes: Map>, + validWorktreeIds: Set +): Promise<{ alive: string[]; killed: string[] }> { + const snapshot = captureProviderRouteSnapshot(routes) + try { + const result = await provider.reconcileOnStartup(validWorktreeIds) + reconcileProviderRoutesAfterStartup(routes, snapshot, provider, result) + return result + } finally { + snapshot.dispose() + } +} + +export function appendProviderReconciliationIds( + target: { alive: string[]; killed: string[] }, + result: { alive: string[]; killed: string[] } +): void { + // Why: daemon startup can return enough sessions to exceed JavaScript's + // argument limit if these arrays are appended with spread syntax. + for (const id of result.alive) { + target.alive.push(id) + } + for (const id of result.killed) { + target.killed.push(id) + } +} + +export async function listProviderProcessesAndReconcileRoutes( + providers: readonly T[], + routes: Map> +): Promise { + const snapshot = captureProviderRouteSnapshot(routes) + try { + const listings = await Promise.all( + providers.map(async (provider) => ({ provider, sessions: await provider.listProcesses() })) + ) + const liveIdsByProvider = new Map( + listings.map(({ provider, sessions }) => [provider, new Set(sessions.map(({ id }) => id))]) + ) + for (const [id, route] of snapshot.routesAtStart) { + // Why: spawn/exit can mutate routing while remote listings are in flight. + // Exact snapshot mutation tracking also fences absent-present-absent ABA. + if ( + providerRouteIsUnchanged(routes, snapshot, id) && + !liveIdsByProvider.get(route.provider)?.has(id) + ) { + deleteProviderRoute(routes, id, route) + } + } + return listings.flatMap(({ sessions }) => sessions) + } finally { + snapshot.dispose() + } +} + +export function providerSessionIds( + routes: Map>, + provider: T +): string[] { + return [...routes].filter(([, route]) => route.provider === provider).map(([id]) => id) +} diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index ca21dd5e58b..2823847243e 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -9,10 +9,12 @@ const { spawnMock, isPwshAvailableMock, validateWorkingDirectoryMock, + resolveUnixShellPathMock, resolveAgentForegroundProcessMock } = vi.hoisted(() => ({ spawnMock: vi.fn(), isPwshAvailableMock: vi.fn(), + resolveUnixShellPathMock: vi.fn((shellPath: string) => shellPath), resolveAgentForegroundProcessMock: vi.fn(), validateWorkingDirectoryMock: vi.fn((cwd: string) => { if (cwd.includes('definitely-missing')) { @@ -51,6 +53,7 @@ vi.mock('../providers/local-pty-utils', async (importOriginal) => { const actual = await importOriginal() return { ...actual, + resolveUnixShellPath: resolveUnixShellPathMock, validateWorkingDirectory: validateWorkingDirectoryMock } }) @@ -116,6 +119,8 @@ describe('createPtySubprocess', () => { async (_pid: number, fallbackProcess: string | null) => fallbackProcess ) validateWorkingDirectoryMock.mockClear() + resolveUnixShellPathMock.mockReset() + resolveUnixShellPathMock.mockImplementation((shellPath: string) => shellPath) isPwshAvailableMock.mockReturnValue(false) previousUserDataPath = process.env.ORCA_USER_DATA_PATH previousPowerlevelWizardDisable = process.env[POWERLEVEL10K_WIZARD_DISABLE_ENV] @@ -181,6 +186,117 @@ describe('createPtySubprocess', () => { ) }) + it('resolves a missing Unix default before spawning node-pty', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + resolveUnixShellPathMock.mockReturnValue('/bin/sh') + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + const previousShell = process.env.SHELL + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + delete process.env.SHELL + + try { + createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24, env: {} }) + + expect(resolveUnixShellPathMock).toHaveBeenCalledWith('/bin/zsh') + expect(spawnMock).toHaveBeenCalledWith( + '/bin/sh', + ['-l'], + expect.objectContaining({ env: expect.objectContaining({ SHELL: '/bin/sh' }) }) + ) + expect(warn).toHaveBeenCalledWith( + '[daemon/pty] Preferred shell "/bin/zsh" is unavailable, fell back to "/bin/sh"' + ) + } finally { + warn.mockRestore() + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + if (previousShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = previousShell + } + } + }) + + it('derives shell-ready launch config from the resolved fallback shell', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + resolveUnixShellPathMock.mockReturnValue('/bin/sh') + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + const previousShell = process.env.SHELL + const previousMarker = process.env.ORCA_SHELL_READY_MARKER + const previousZdotdir = process.env.ZDOTDIR + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + delete process.env.SHELL + // Why: the test runner itself can execute inside an Orca-wrapped shell + // whose exported wrapper vars would leak through the process.env spread. + delete process.env.ORCA_SHELL_READY_MARKER + delete process.env.ZDOTDIR + + try { + const handle = createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + env: {}, + command: 'echo hi' + }) + + expect(handle.shellPath).toBe('/bin/sh') + const [shellPath, shellArgs, spawnOptions] = spawnMock.mock.calls[0] + expect(shellPath).toBe('/bin/sh') + expect(shellArgs).toEqual(['-l']) + // A launch config derived from the missing preferred zsh would inject + // ZDOTDIR and ORCA_SHELL_READY_MARKER; /bin/sh must spawn without them. + expect(spawnOptions.env.ZDOTDIR).toBeUndefined() + expect(spawnOptions.env.ORCA_SHELL_READY_MARKER).toBeUndefined() + expect(spawnOptions.env.SHELL).toBe('/bin/sh') + } finally { + warn.mockRestore() + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + if (previousShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = previousShell + } + if (previousMarker === undefined) { + delete process.env.ORCA_SHELL_READY_MARKER + } else { + process.env.ORCA_SHELL_READY_MARKER = previousMarker + } + if (previousZdotdir === undefined) { + delete process.env.ZDOTDIR + } else { + process.env.ZDOTDIR = previousZdotdir + } + } + }) + + it('surfaces the no-executable-shell error before node-pty forks', () => { + resolveUnixShellPathMock.mockImplementation(() => { + throw new Error('No executable Unix shell found (tried: /bin/zsh, /bin/bash, /bin/sh)') + }) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + + try { + expect(() => createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24, env: {} })).toThrow( + 'No executable Unix shell found' + ) + expect(spawnMock).not.toHaveBeenCalled() + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + }) + it('uses bundled ConPTY for native Windows daemon terminals', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) @@ -1135,6 +1251,26 @@ describe('createPtySubprocess', () => { killSpy.mockRestore() }) + it('propagates force-kill failure while OS liveness still proves the child exists', () => { + const proc = mockPtyProcess(77) + proc.kill.mockImplementation(() => { + throw new Error('native kill failed') + }) + spawnMock.mockReturnValue(proc) + const killSpy = vi.spyOn(process, 'kill').mockImplementation((_pid, signal) => { + if (signal === 0) { + return true + } + throw new Error('signal kill failed') + }) + + const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + + expect(() => handle.forceKill()).toThrow('native kill failed') + expect(handle.hasExited?.()).toBe(false) + killSpy.mockRestore() + }) + it('routes onData events', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) @@ -2475,6 +2611,7 @@ describe('createPtySubprocess', () => { } proc.destroy = vi.fn(() => proc.kill()) spawnMock.mockReturnValue(proc) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) const origPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { value: 'win32' }) try { @@ -2484,11 +2621,12 @@ describe('createPtySubprocess', () => { expect(proc.kill).toHaveBeenCalledOnce() expect(proc.destroy).not.toHaveBeenCalled() } finally { + killSpy.mockRestore() restorePlatform(origPlatform) } }) - it('does not issue a second Windows ConPTY kill when force follows graceful kill', () => { + it('does not reissue Windows ConPTY close after accepted graceful kill', () => { const proc = mockPtyProcess(123456) as ReturnType & { destroy: ReturnType } @@ -2506,6 +2644,7 @@ describe('createPtySubprocess', () => { handle.dispose() expect(proc.kill).toHaveBeenCalledOnce() + expect(proc.kill.mock.calls).toEqual([[]]) expect(killSpy).not.toHaveBeenCalled() expect(proc.destroy).not.toHaveBeenCalled() } finally { @@ -2514,7 +2653,63 @@ describe('createPtySubprocess', () => { } }) - it('dispose() on Windows skips destroy after forceKill falls back to node-pty kill()', () => { + it('retries Windows ConPTY close after graceful native throw', () => { + const proc = mockPtyProcess(123456) as ReturnType & { + destroy: ReturnType + } + proc.kill.mockImplementationOnce(() => { + throw new Error('native close failed') + }) + proc.destroy = vi.fn(() => proc.kill()) + spawnMock.mockReturnValue(proc) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + const origPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'win32' }) + try { + const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + expect(() => handle.kill()).toThrow('native close failed') + expect(() => handle.forceKill()).not.toThrow() + handle.dispose() + + expect(proc.kill.mock.calls).toEqual([[], []]) + expect(proc.destroy).not.toHaveBeenCalled() + } finally { + killSpy.mockRestore() + restorePlatform(origPlatform) + } + }) + + it('does not reissue Windows ConPTY close after native throw with OS exit proof', () => { + const proc = mockPtyProcess(123456) as ReturnType & { + destroy: ReturnType + } + proc.kill.mockImplementation(() => { + throw new Error('native close raced exit') + }) + proc.destroy = vi.fn(() => proc.kill()) + spawnMock.mockReturnValue(proc) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { + const error = new Error('already gone') as NodeJS.ErrnoException + error.code = 'ESRCH' + throw error + }) + const origPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'win32' }) + try { + const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + expect(() => handle.kill()).not.toThrow() + handle.forceKill() + handle.dispose() + + expect(proc.kill).toHaveBeenCalledOnce() + expect(proc.destroy).not.toHaveBeenCalled() + } finally { + killSpy.mockRestore() + restorePlatform(origPlatform) + } + }) + + it('dispose() on Windows skips destroy after signal-free forceKill', () => { const proc = mockPtyProcess(123456) as ReturnType & { destroy: ReturnType } @@ -2529,8 +2724,9 @@ describe('createPtySubprocess', () => { const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) handle.forceKill() handle.dispose() - expect(killSpy).toHaveBeenCalledWith(123456, 'SIGKILL') + expect(killSpy).not.toHaveBeenCalled() expect(proc.kill).toHaveBeenCalledOnce() + expect(proc.kill).toHaveBeenCalledWith() expect(proc.destroy).not.toHaveBeenCalled() } finally { killSpy.mockRestore() diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 6a285301406..d5761bc11ce 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -14,6 +14,7 @@ import { isValidPtySize, normalizePtySize } from './daemon-pty-size' import { ensureNodePtySpawnHelperExecutable, getNodePtySpawnHelperCandidates, + resolveUnixShellPath, validateWorkingDirectory } from '../providers/local-pty-utils' import { wrapShellSpawnForMacosTccAttribution } from '../providers/macos-tcc-login-shell' @@ -742,6 +743,17 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl if (opts.env?.TERM) { env.TERM = opts.env.TERM } + // Why after the scrub: SHELL must reflect the shell that actually spawns, + // matching LocalPtyProvider; and before the launch-config derivation below + // so shell-ready wrappers target the resolved shell, not a missing one. + const preferredShellPath = shellPath + shellPath = resolveUnixShellPath(shellPath) + if (shellPath !== preferredShellPath) { + env.SHELL = shellPath + console.warn( + `[daemon/pty] Preferred shell "${preferredShellPath}" is unavailable, fell back to "${shellPath}"` + ) + } // Why: OpenCode/Codex path restoration and OMP's typed-command status // wrapper need shell-ready code after user startup files run. let shellLaunch: ReturnType | null = null @@ -879,6 +891,21 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl // exiting and the JS onExit callback firing. An uncaught Napi::Error // propagates to std::terminate, killing the entire daemon process. let dead = false + const hasExited = (): boolean => { + if (dead) { + return true + } + try { + process.kill(proc.pid, 0) + return false + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') { + return false + } + dead = true + return true + } + } let disposed = false let nodePtyKillIssued = false let cachedAgentForeground: { processName: string; refreshedAt: number } | null = null @@ -1007,6 +1034,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl return { pid: proc.pid, + shellPath, ...(startupCommandDeliveredInShellArgs ? { startupCommandDeliveredInShellArgs: true } : {}), getForegroundProcess: () => { // Why: node-pty's `.process` getter reports the PTY's live foreground @@ -1166,8 +1194,11 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl try { nodePtyKillIssued = true proc.kill() - } catch { - dead = true + } catch (error) { + if (!hasExited()) { + nodePtyKillIssued = false + throw error + } } }, forceKill: () => { @@ -1175,22 +1206,45 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl // has run, proc.pid refers to a recycled pid. Sending SIGKILL would // terminate an unrelated process. The fd release is handled by // dispose()/destroy(); forceKill is strictly for signalling a live child. - // Why: Windows node-pty kill already closes ConPTY; retrying it through - // forceKill can double-close the native handle during workspace teardown. - if (dead || (process.platform === 'win32' && nodePtyKillIssued)) { + if (dead) { + return + } + if (process.platform === 'win32') { + // Why: graceful and force shutdown share one native ConPTY close. + // Once accepted, wait for exit proof instead of closing twice. + if (nodePtyKillIssued) { + return + } + try { + nodePtyKillIssued = true + proc.kill() + } catch (error) { + if (!hasExited()) { + nodePtyKillIssued = false + throw error + } + } return } try { process.kill(proc.pid, 'SIGKILL') - } catch { + } catch (signalError) { + if ((signalError as NodeJS.ErrnoException).code === 'ESRCH') { + dead = true + return + } try { nodePtyKillIssued = true proc.kill() - } catch { - // Process may already be dead + } catch (fallbackError) { + if (!hasExited()) { + nodePtyKillIssued = false + throw fallbackError + } } } }, + hasExited, signal: (sig) => { // Why: same recycled-pid hazard as forceKill. Once dead, silently drop // the signal rather than risk sending it to an unrelated process. diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index ade8b18fe15..f42453280ca 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -57,6 +57,9 @@ function createMockSubprocess() { forceKill() { killed = true }, + hasExited() { + return killed + }, signal(sig: string) { signals.push(sig) }, @@ -422,7 +425,7 @@ describe('Session', () => { expect(exitCodes).toEqual([0]) }) - it('force-disposes after 5s if subprocess does not exit', () => { + it('uses OS exit proof after the force-kill fallback', () => { createSession() // Override kill to NOT trigger exit subprocess.kill = () => {} @@ -436,6 +439,60 @@ describe('Session', () => { expect(forceKillSpy).toHaveBeenCalled() }) + it('retains the session after accepted force kill until exit is proved', () => { + createSession() + subprocess.kill = () => {} + subprocess.hasExited = () => false + + session.kill() + vi.advanceTimersByTime(5_000) + + expect(session.state).not.toBe('exited') + expect(vi.getTimerCount()).toBeGreaterThan(0) + subprocess.simulateExit(137) + expect(session.state).toBe('exited') + }) + + it('does not reissue an accepted Windows force kill while exit remains unproved', () => { + createSession() + subprocess.kill = () => {} + subprocess.hasExited = () => false + const forceKillSpy = vi.spyOn(subprocess, 'forceKill') + + session.kill() + vi.advanceTimersByTime(5_000) + vi.advanceTimersByTime(5_000) + + expect(forceKillSpy).toHaveBeenCalledOnce() + expect(session.state).not.toBe('exited') + subprocess.simulateExit(137) + expect(vi.getTimerCount()).toBe(0) + }) + + it('retains one retry timer after more than eight force-kill failures', () => { + createSession() + subprocess.kill = () => {} + subprocess.hasExited = () => false + const forceKillSpy = vi.spyOn(subprocess, 'forceKill').mockImplementation(() => { + throw new Error('native force kill failed') + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + try { + session.kill() + vi.advanceTimersByTime(45_000) + + expect(forceKillSpy).toHaveBeenCalledTimes(9) + expect(warn).toHaveBeenCalledTimes(3) + expect(session.state).not.toBe('exited') + expect(vi.getTimerCount()).toBe(1) + subprocess.simulateExit(137) + expect(vi.getTimerCount()).toBe(0) + } finally { + warn.mockRestore() + } + }) + it('ignores late data and exit after force-dispose', () => { createSession() subprocess.kill = () => {} @@ -451,7 +508,7 @@ describe('Session', () => { expect(onData).not.toHaveBeenCalled() expect(onExit).toHaveBeenCalledTimes(1) - expect(onExit).toHaveBeenCalledWith(-1) + expect(onExit).toHaveBeenCalledWith(-1, expect.any(String)) expect(session.exitCode).toBe(-1) }) }) diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 06404fd9246..8d13c0feef3 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -1,4 +1,5 @@ /* oxlint-disable max-lines */ +import { randomUUID } from 'node:crypto' import { HeadlessEmulator } from './headless-emulator' import { isValidPtySize, normalizePtySize } from './daemon-pty-size' import { PostReadyFlushGate } from './post-ready-flush-gate' @@ -23,6 +24,8 @@ const SHELL_READY_TIMEOUT_MS = 15_000 // older daemon/local paths that still report shell-ready support for Codex. export const CODEX_SHELL_READY_TIMEOUT_MS = 300 const KILL_TIMEOUT_MS = 5_000 +const EXIT_PROOF_POLL_MS = 1_000 +const SHUTDOWN_DRAIN_RETRY_MS = 100 // Why: pending records exist so the 5s checkpoint can persist increments // instead of re-serializing the whole buffer. If no client drains them (main // process gone, history disabled), memory must stay bounded — past the cap we @@ -48,6 +51,10 @@ export type SubprocessHandle = { /** True when shell launch args already delivered the startup command, so the * terminal host must skip its stdin fallback write. */ startupCommandDeliveredInShellArgs?: boolean + /** Shell the subprocess actually spawned, after Unix/Windows fallbacks. The + * host reconciles the caller's shell-ready assumption against it so a + * fallback shell without a ready marker never gates startup commands. */ + shellPath?: string write(data: string): void resize(cols: number, rows: number): void /** Stop reading the PTY fd (node-pty pause()) so the kernel/ConPTY buffer @@ -61,17 +68,22 @@ export type SubprocessHandle = { clear?(): void kill(): void forceKill(): void + /** Conservative OS/onExit proof; false includes unknown and still alive. */ + hasExited?(): boolean + /** Windows ConPTY may accept a pre-ready close before it can act on it. */ signal(sig: string): void onData(cb: (data: string) => void): void onExit(cb: (code: number) => void): void /** Release the native PTY handle via node-pty's own destroy() path. * Idempotent. Safe to call after exit. Called by Session on every teardown - * path (natural exit, kill, force-kill, native throw, session dispose). */ + * path after physical exit proof, or during whole-session disposal. */ dispose(): void } export type SessionOptions = { sessionId: string + paneKey?: string + tabId?: string cols: number rows: number terminalHandle?: string @@ -81,6 +93,9 @@ export type SessionOptions = { shellReadyTimeoutMs?: number historySeed?: string scrollback?: number + // Why: TerminalHost must publish the Session and attach its first client + // before node-pty flushes buffered data/exit synchronously on subscription. + deferSubprocessSubscription?: boolean // Why: fired once the session reaches a terminal state (natural exit or // kill-timeout force-dispose) so the owner (TerminalHost) can reap it — // dispose the headless emulator and drop it from its session map. Without a @@ -92,11 +107,14 @@ export type SessionOptions = { type AttachedClient = { token: symbol onData: (data: string) => void - onExit: (code: number) => void + onExit: (code: number, sessionGeneration: string) => void } export class Session { readonly sessionId: string + readonly generation = randomUUID() + readonly paneKey: string | null + readonly tabId: string | null readonly terminalHandle: string | null readonly launchAgent: TuiAgent | null private _state: SessionState = 'running' @@ -112,6 +130,9 @@ export class Session { private shellReadyScanState: ShellReadyScanState | null = null private shellReadyTimer: ReturnType | null = null private killTimer: ReturnType | null = null + private forceKillAccepted = false + private forceKillFailureCount = 0 + private forceKillRetryDelayMs = KILL_TIMEOUT_MS private postReadyFlushGate: PostReadyFlushGate private pendingOutputRecords: PendingOutputRecord[] = [] private pendingOutputBytes = 0 @@ -121,13 +142,21 @@ export class Session { private producerPaused = false private producerPauseFailsafeTimer: ReturnType | null = null private readonly _historySeeded: boolean | undefined + private readonly exitProofPromise: Promise + private resolveExitProof!: () => void + private subprocessSubscriptionStarted = false constructor(opts: SessionOptions) { this.sessionId = opts.sessionId + this.paneKey = opts.paneKey ?? null + this.tabId = opts.tabId ?? null this.terminalHandle = opts.terminalHandle ?? null this.launchAgent = opts.launchAgent ?? null this.subprocess = opts.subprocess this.onSessionExit = opts.onExit + this.exitProofPromise = new Promise((resolve) => { + this.resolveExitProof = resolve + }) const size = normalizePtySize(opts.cols, opts.rows) this.emulator = new HeadlessEmulator({ cols: size.cols, @@ -154,6 +183,16 @@ export class Session { } this.postReadyFlushGate = new PostReadyFlushGate(() => this.flushPreReadyQueue()) + if (!opts.deferSubprocessSubscription) { + this.startSubprocessSubscription() + } + } + + startSubprocessSubscription(): void { + if (this.subprocessSubscriptionStarted) { + return + } + this.subprocessSubscriptionStarted = true this.subprocess.onData((data) => this.handleSubprocessData(data)) this.subprocess.onExit((code) => this.handleSubprocessExit(code)) } @@ -182,6 +221,10 @@ export class Session { return this._isTerminating } + waitForExitProof(): Promise { + return this._state === 'exited' ? Promise.resolve() : this.exitProofPromise + } + get pid(): number { return this.subprocess.pid } @@ -263,13 +306,53 @@ export class Session { // Why: a paused child can be blocked inside write(); resume before // signalling so it can run signal handlers and actually exit. this.releaseProducerPause({ resume: true }) - this.subprocess.kill() + try { + this.subprocess.kill() + } catch (error) { + this._isTerminating = false + throw error + } - this.killTimer = setTimeout(() => { - if (this._state !== 'exited') { - this.forceDispose() - } - }, KILL_TIMEOUT_MS) + this.scheduleForceShutdownCheck(KILL_TIMEOUT_MS) + } + + /** Request immediate shutdown while retaining the session until exit proof. */ + requestImmediateShutdown(): void { + if (this._state === 'exited' || this.forceKillAccepted) { + return + } + this._isTerminating = true + this.releaseProducerPause({ resume: true }) + try { + this.issueForceShutdown() + } catch (error) { + this._isTerminating = false + throw error + } + this.scheduleForceShutdownCheck(this.nextForceShutdownCheckDelay()) + } + + /** Whole-process shutdown keeps retry/listener ownership until exit proof. */ + beginShutdownDrain(): void { + if (this._state === 'exited') { + return + } + this.forceKillRetryDelayMs = SHUTDOWN_DRAIN_RETRY_MS + // Why: a prior graceful/accepted kill may own a 5s timer, longer than the + // process drain budget. Re-arm it on the bounded shutdown cadence. + if (this.killTimer) { + clearTimeout(this.killTimer) + this.killTimer = null + } + try { + this.requestImmediateShutdown() + } catch (error) { + this._isTerminating = true + this.recordForceKillFailure(error) + } + if (this.isAlive) { + this.scheduleForceShutdownCheck(this.nextForceShutdownCheckDelay()) + } } signal(sig: string): void { @@ -279,7 +362,10 @@ export class Session { this.subprocess.signal(sig) } - attachClient(client: { onData: (data: string) => void; onExit: (code: number) => void }): symbol { + attachClient(client: { + onData: (data: string) => void + onExit: (code: number, sessionGeneration: string) => void + }): symbol { const token = Symbol('attach') this.attachedClients.push({ token, ...client }) return token @@ -444,7 +530,7 @@ export class Session { this.emulator.dispose() for (const client of clientsToNotify) { - client.onExit(-1) + client.onExit(-1, this.generation) } } @@ -547,7 +633,7 @@ export class Session { } private handleSubprocessData(data: string): void { - if (this._disposed) { + if (this._disposed || this._state === 'exited') { return } @@ -584,12 +670,13 @@ export class Session { } private handleSubprocessExit(code: number): void { - if (this._disposed) { + if (this._disposed || this._state === 'exited') { return } this._exitCode = code this._state = 'exited' + this.resolveExitProof() // Why resume:false — the child is reaped, so there is nothing to unblock; // only the failsafe timer must not outlive the session. this.releaseProducerPause({ resume: false }) @@ -619,7 +706,7 @@ export class Session { } for (const client of this.attachedClients) { - client.onExit(code) + client.onExit(code, this.generation) } // Why: hand off to the owner's reaper so the emulator is disposed and the @@ -672,40 +759,59 @@ export class Session { } } - private forceDispose(): void { + private issueForceShutdown(): void { if (this._state === 'exited') { return } - // Why: forceKill BEFORE #teardownSubprocess. Order is load-bearing — the - // helper's subprocess.dispose() neutralizes proc.kill on POSIX (to defuse - // the SIGHUP-to-recycled-pid hazard inside node-pty). forceKill uses - // process.kill(pid, 'SIGKILL') directly and is unaffected by that - // neutralization. Must NOT flip `_disposed` here before #teardownSubprocess - // runs, or the helper would early-return and skip subprocess.dispose() — - // the ptmx fd would leak on every kill-timeout (this whole doc's target). - try { - this.subprocess.forceKill() - } catch { - /* already dead */ + this.subprocess.forceKill() + if (!this.isAlive) { + return } - this._exitCode = -1 - this._isTerminating = false - - this.#teardownSubprocess() - this._state = 'exited' - - const clients = this.attachedClients - this.attachedClients = [] - this.preReadyStdinQueue = [] - this.postReadyFlushGate.clear() - this.emulator.dispose() - - for (const client of clients) { - client.onExit(-1) + this.forceKillAccepted = true + this.forceKillFailureCount = 0 + if (this.subprocess.hasExited?.() === true) { + this.handleSubprocessExit(-1) } + } - // Why: reap from the host map on the kill-timeout path too (emulator already - // disposed above; reapSession's dispose() call is a no-op and just drops it). - this.onSessionExit?.(-1) + private scheduleForceShutdownCheck(delayMs: number): void { + if (this.killTimer || this._state === 'exited') { + return + } + this.killTimer = setTimeout(() => { + this.killTimer = null + if (this._state === 'exited') { + return + } + if (this.forceKillAccepted) { + if (this.subprocess.hasExited?.() === true) { + this.handleSubprocessExit(-1) + return + } + // Why: a successful Windows native close is one-shot. Poll for the + // exit event/proof without closing the same ConPTY handle twice. + this.scheduleForceShutdownCheck(this.nextForceShutdownCheckDelay()) + return + } + try { + this.issueForceShutdown() + } catch (error) { + this.recordForceKillFailure(error) + } + if (this.isAlive) { + this.scheduleForceShutdownCheck(this.nextForceShutdownCheckDelay()) + } + }, delayMs) + } + + private nextForceShutdownCheckDelay(): number { + return this.forceKillAccepted ? EXIT_PROOF_POLL_MS : this.forceKillRetryDelayMs + } + + private recordForceKillFailure(error: unknown): void { + this.forceKillFailureCount += 1 + if (this.forceKillFailureCount <= 2 || this.forceKillFailureCount === 8) { + console.warn('[Session] retained force shutdown failed:', error) + } } } diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index 909a75ddf1f..d602f5f7fa5 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -348,13 +348,16 @@ export function resolvePtyShellPath(env: Record): string { return env.SHELL || process.env.SHELL || '/bin/zsh' } +export function shellPathSupportsPtyStartupBarrier(shellPath: string): boolean { + const shellName = pathWin32.basename(basename(shellPath)).toLowerCase() + return shellName === 'zsh' || shellName === 'bash' +} + export function supportsPtyStartupBarrier(env: Record): boolean { if (process.platform === 'win32') { return false } - const resolvedShell = resolvePtyShellPath(env) - const shellName = pathWin32.basename(basename(resolvedShell)).toLowerCase() - return shellName === 'zsh' || shellName === 'bash' + return shellPathSupportsPtyStartupBarrier(resolvePtyShellPath(env)) } type ShellLaunchConfig = { diff --git a/src/main/daemon/slow-daemon-session-verification.test.ts b/src/main/daemon/slow-daemon-session-verification.test.ts index 15634530dcd..60150ebc51c 100644 --- a/src/main/daemon/slow-daemon-session-verification.test.ts +++ b/src/main/daemon/slow-daemon-session-verification.test.ts @@ -21,16 +21,19 @@ import type { SubprocessHandle } from './session' const RESPONSE_DELAY_MS = 3_500 function createMockSubprocess(): SubprocessHandle { + let onExitCb: ((code: number) => void) | null = null return { pid: 55555, getForegroundProcess: vi.fn(() => null), write: vi.fn(), resize: vi.fn(), kill: vi.fn(), - forceKill: vi.fn(), + forceKill: vi.fn(() => setTimeout(() => onExitCb?.(-1), 5)), signal: vi.fn(), onData: vi.fn(), - onExit: vi.fn(), + onExit(cb) { + onExitCb = cb + }, dispose: vi.fn() } } diff --git a/src/main/daemon/terminal-host-create-contract.ts b/src/main/daemon/terminal-host-create-contract.ts index a60570ac5fe..412c13e0528 100644 --- a/src/main/daemon/terminal-host-create-contract.ts +++ b/src/main/daemon/terminal-host-create-contract.ts @@ -12,6 +12,8 @@ export type CreateOrAttachOptions = { command?: string startupCommandDelivery?: StartupCommandDelivery launchAgent?: TuiAgent + paneKey?: string + tabId?: string /** Explicit shell the renderer asked for, forwarded to the subprocess. */ shellOverride?: string terminalWindowsWslDistro?: string | null @@ -19,7 +21,10 @@ export type CreateOrAttachOptions = { shellReadySupported?: boolean shellReadyTimeoutMs?: number historySeed?: string - streamClient: { onData: (data: string) => void; onExit: (code: number) => void } + streamClient: { + onData: (data: string) => void + onExit: (code: number, sessionGeneration: string) => void + } } export type CreateOrAttachResult = { @@ -29,5 +34,6 @@ export type CreateOrAttachResult = { shellState: ShellReadyState historySeeded?: boolean launchAgent?: TuiAgent + sessionGeneration: string attachToken: symbol } diff --git a/src/main/daemon/terminal-host-session-reaping-leak.test.ts b/src/main/daemon/terminal-host-session-reaping-leak.test.ts index 30d1736e42d..08c73c26081 100644 --- a/src/main/daemon/terminal-host-session-reaping-leak.test.ts +++ b/src/main/daemon/terminal-host-session-reaping-leak.test.ts @@ -106,7 +106,7 @@ describe('TerminalHost dead-session reaping (leak regression)', () => { expect(host.listSessions()).toHaveLength(0) }) - it('reaps a session killed immediately (forceKill path)', async () => { + it('retains an immediate kill until onExit proves shutdown, then reaps it', async () => { await host.createOrAttach({ sessionId: 'session-1', cols: 80, @@ -116,20 +116,25 @@ describe('TerminalHost dead-session reaping (leak regression)', () => { host.kill('session-1', { immediate: true }) - // Emulator freed and session dropped from the map (no lingering dead entry). + expect(emulatorDispose).not.toHaveBeenCalled() + expect(host.listSessions()).toHaveLength(1) + + lastSubprocess._onExitCb?.(-1) expect(emulatorDispose).toHaveBeenCalledTimes(1) expect(host.listSessions()).toHaveLength(0) }) - it('reaps a session whose graceful kill times out (forceDispose path)', async () => { + it('retains a timed-out graceful kill until onExit proves shutdown, then reaps it', async () => { vi.useFakeTimers() try { + let stubbornSubprocess: ReturnType | null = null const stubbornHost = new TerminalHost({ spawnSubprocess: () => { const sub = createMockSubprocess() // Stubborn child: ignores graceful kill, so the KILL_TIMEOUT_MS timer // must force-dispose it. sub.kill = vi.fn() + stubbornSubprocess = sub return sub } }) @@ -144,10 +149,12 @@ describe('TerminalHost dead-session reaping (leak regression)', () => { stubbornHost.kill('stubborn') expect(emulatorDispose).not.toHaveBeenCalled() - // The 5s KILL_TIMEOUT_MS fallback fires forceDispose, which disposes the - // emulator and reaps the session via the onExit hook. + // The fallback kill is accepted, but ownership remains until exit proof. vi.advanceTimersByTime(5000) + expect(emulatorDispose).not.toHaveBeenCalled() + expect(stubbornHost.listSessions()).toHaveLength(1) + stubbornSubprocess!._onExitCb?.(-1) expect(emulatorDispose).toHaveBeenCalledTimes(1) expect(stubbornHost.listSessions()).toHaveLength(0) stubbornHost.dispose() @@ -155,4 +162,36 @@ describe('TerminalHost dead-session reaping (leak regression)', () => { vi.useRealTimers() } }) + + it('whole-host shutdown retries native failure and waits for exit proof', async () => { + vi.useFakeTimers() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + streamClient: streamClient() + }) + const forceKill = lastSubprocess.forceKill as ReturnType + forceKill + .mockImplementationOnce(() => { + throw new Error('native force kill failed') + }) + .mockImplementationOnce(() => lastSubprocess._onExitCb?.(-1)) + lastSubprocess.kill = vi.fn() + host.kill('session-1') + + const shutdown = host.shutdownAndWait(1_000) + expect(host.listSessions()).toHaveLength(1) + await vi.advanceTimersByTimeAsync(100) + + await expect(shutdown).resolves.toBeUndefined() + expect(forceKill).toHaveBeenCalledTimes(2) + expect(host.listSessions()).toHaveLength(0) + } finally { + warn.mockRestore() + vi.useRealTimers() + } + }) }) diff --git a/src/main/daemon/terminal-host-shutdown-drain.ts b/src/main/daemon/terminal-host-shutdown-drain.ts new file mode 100644 index 00000000000..f2f6b1f19d9 --- /dev/null +++ b/src/main/daemon/terminal-host-shutdown-drain.ts @@ -0,0 +1,98 @@ +import type { Session } from './session' +import type { TakePendingOutputResult, TerminalSnapshot } from './types' + +type FinalCheckpointWriter = ( + sessionId: string, + snapshot: TerminalSnapshot, + records: TakePendingOutputResult['records'] +) => void + +export class TerminalHostShutdownDrain { + private emptyWaiters = new Set<() => void>() + + constructor( + private readonly sessions: Map, + private readonly onFinalCheckpoint?: FinalCheckpointWriter + ) {} + + async run(timeoutMs: number): Promise { + this.writeFinalCheckpoints() + for (const [sessionId, session] of this.sessions) { + session.detachAllClients() + if (session.isAlive) { + session.beginShutdownDrain() + } else { + session.disposeSubprocess() + this.sessions.delete(sessionId) + } + } + this.notifyIfEmpty() + if (!(await this.waitForEmpty(timeoutMs))) { + throw new Error(`Timed out waiting for ${this.sessions.size} daemon PTY session(s) to exit`) + } + } + + disposeImmediately(): void { + this.writeFinalCheckpoints() + for (const session of this.sessions.values()) { + session.detachAllClients() + // Why: kill only sessions still proven live. Killing an already-reaped + // PID can target an unrelated process after POSIX PID reuse. + if (session.isAlive) { + session.forceKillAndDisposeSubprocess() + } else { + session.disposeSubprocess() + } + } + this.sessions.clear() + this.notifyIfEmpty() + } + + writeFinalCheckpoints(): void { + if (!this.onFinalCheckpoint) { + return + } + // Why: checkpoint synchronously before shutdown starts so pending terminal + // output survives even when native process teardown later fails. + for (const [sessionId, session] of this.sessions) { + if (!session.isAlive) { + continue + } + const take = session.takePendingOutput(true, { teardownSnapshot: true }) + if (take?.snapshot) { + try { + this.onFinalCheckpoint(sessionId, take.snapshot, take.records) + } catch { + // Best-effort checkpointing must not prevent process cleanup. + } + } + } + } + + notifyIfEmpty(): void { + if (this.sessions.size !== 0) { + return + } + for (const waiter of this.emptyWaiters) { + waiter() + } + } + + private waitForEmpty(timeoutMs: number): Promise { + if (this.sessions.size === 0) { + return Promise.resolve(true) + } + return new Promise((resolve) => { + const waiter = (): void => { + clearTimeout(timer) + this.emptyWaiters.delete(waiter) + resolve(true) + } + const timer = setTimeout(() => { + this.emptyWaiters.delete(waiter) + resolve(false) + }, timeoutMs) + this.emptyWaiters.add(waiter) + }) + } +} diff --git a/src/main/daemon/terminal-host.test.ts b/src/main/daemon/terminal-host.test.ts index 4f92d4c1e1f..7569dfc70d7 100644 --- a/src/main/daemon/terminal-host.test.ts +++ b/src/main/daemon/terminal-host.test.ts @@ -3,7 +3,7 @@ import { Session, type SubprocessHandle } from './session' import { TerminalHost } from './terminal-host' function createMockSubprocess( - options: { startupCommandDeliveredInShellArgs?: boolean } = {} + options: { startupCommandDeliveredInShellArgs?: boolean; shellPath?: string } = {} ): SubprocessHandle { let onDataCb: ((data: string) => void) | null = null let onExitCb: ((code: number) => void) | null = null @@ -12,6 +12,7 @@ function createMockSubprocess( ...(options.startupCommandDeliveredInShellArgs ? { startupCommandDeliveredInShellArgs: true } : {}), + ...(options.shellPath ? { shellPath: options.shellPath } : {}), getForegroundProcess: vi.fn(() => null), write: vi.fn(), resize: vi.fn(), @@ -71,6 +72,29 @@ describe('TerminalHost', () => { }) describe('createOrAttach', () => { + it('rejects attaching a replacement pane to a reused live session id', async () => { + await host.createOrAttach({ + sessionId: 'session-1', + paneKey: 'tab-a:leaf-a', + tabId: 'tab-a', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + await expect( + host.createOrAttach({ + sessionId: 'session-1', + paneKey: 'tab-b:leaf-b', + tabId: 'tab-b', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + ).rejects.toThrow('PTY identity mismatch') + expect(spawnFn).toHaveBeenCalledOnce() + }) + it('creates a new session when none exists', async () => { const result = await host.createOrAttach({ sessionId: 'session-1', @@ -104,6 +128,69 @@ describe('TerminalHost', () => { expect(spawnFn).toHaveBeenCalledOnce() }) + it('retains a terminating same-id session until physical exit proof', async () => { + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + const terminatingSubprocess = lastSubprocess + + host.kill('session-1', { immediate: true }) + const replacement = host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + await Promise.resolve() + + expect(spawnFn).toHaveBeenCalledOnce() + expect(terminatingSubprocess.forceKill).toHaveBeenCalledOnce() + expect(terminatingSubprocess.dispose).not.toHaveBeenCalled() + expect(host.listSessions()).toHaveLength(1) + + terminatingSubprocess._onExitCb?.(0) + await expect(replacement).resolves.toMatchObject({ isNew: true }) + expect(spawnFn).toHaveBeenCalledTimes(2) + }) + + it('publishes ownership before buffered data and exit flush on subscription', async () => { + const buffered = createMockSubprocess() + buffered.onData = vi.fn((callback) => callback('BOOT')) + buffered.onExit = vi.fn((callback) => callback(0)) + const replacement = createMockSubprocess() + spawnFn = vi.fn().mockReturnValueOnce(buffered).mockReturnValue(replacement) + host.dispose() + host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn }) + const onData = vi.fn() + const onExit = vi.fn() + + const first = await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + streamClient: { onData, onExit } + }) + + expect(first).toMatchObject({ isNew: true, snapshot: null }) + expect(onData).toHaveBeenCalledWith('BOOT') + expect(onExit).toHaveBeenCalledWith(0, first.sessionGeneration) + expect(host.listSessions()).toHaveLength(0) + + await expect( + host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + ).resolves.toMatchObject({ isNew: true }) + expect(host.listSessions()).toHaveLength(1) + expect(spawnFn).toHaveBeenCalledTimes(2) + }) + it('returns snapshot when attaching to existing session', async () => { await host.createOrAttach({ sessionId: 'session-1', @@ -193,6 +280,88 @@ describe('TerminalHost', () => { } }) + it('delivers startup commands immediately when the spawned shell cannot emit the ready marker', async () => { + spawnFn = vi.fn(() => { + const sub = createMockSubprocess({ shellPath: '/bin/sh' }) as ReturnType< + typeof createMockSubprocess + > & { + _onDataCb: ((data: string) => void) | null + _onExitCb: ((code: number) => void) | null + } + lastSubprocess = sub + return sub + }) + host.dispose() + host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn }) + + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + command: 'echo hello', + shellReadySupported: true, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + expect(lastSubprocess.write).toHaveBeenCalledWith( + process.platform === 'win32' ? 'echo hello\r' : 'echo hello\n' + ) + }) + + it('does not bracketed-paste-wrap multiline commands for a fallback shell without paste mode', async () => { + spawnFn = vi.fn(() => { + const sub = createMockSubprocess({ shellPath: '/bin/sh' }) as ReturnType< + typeof createMockSubprocess + > & { + _onDataCb: ((data: string) => void) | null + _onExitCb: ((code: number) => void) | null + } + lastSubprocess = sub + return sub + }) + host.dispose() + host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn }) + + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + command: 'claude "line one\nline two"', + shellReadySupported: true, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + const written = (lastSubprocess.write as ReturnType).mock.calls[0]?.[0] + expect(written).not.toContain('\x1b[200~') + expect(written).toContain('line one\nline two') + }) + + it('keeps the shell-ready barrier when the spawned shell supports the marker', async () => { + spawnFn = vi.fn(() => { + const sub = createMockSubprocess({ shellPath: '/bin/bash' }) as ReturnType< + typeof createMockSubprocess + > & { + _onDataCb: ((data: string) => void) | null + _onExitCb: ((code: number) => void) | null + } + lastSubprocess = sub + return sub + }) + host.dispose() + host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn }) + + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + command: 'echo hello', + shellReadySupported: true, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + expect(lastSubprocess.write).not.toHaveBeenCalled() + }) + it('does not write startup commands already embedded in shell args', async () => { spawnFn = vi.fn(() => { const sub = createMockSubprocess({ @@ -278,6 +447,27 @@ describe('TerminalHost', () => { }) describe('kill', () => { + it('rejects a stale pane kill before force-killing the current session', async () => { + await host.createOrAttach({ + sessionId: 'session-1', + paneKey: 'tab-b:leaf-b', + tabId: 'tab-b', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + expect(() => + host.kill('session-1', { + immediate: true, + expectedPaneKey: 'tab-a:leaf-a', + expectedTabId: 'tab-a' + }) + ).toThrow('PTY identity mismatch') + expect(lastSubprocess.forceKill).not.toHaveBeenCalled() + expect(host.listSessions()).toHaveLength(1) + }) + it('kills the session and tombstones it', async () => { await host.createOrAttach({ sessionId: 'session-1', @@ -299,6 +489,9 @@ describe('TerminalHost', () => { streamClient: { onData: vi.fn(), onExit: vi.fn() } }) + ;(lastSubprocess.forceKill as ReturnType).mockImplementation(() => + lastSubprocess._onExitCb?.(-1) + ) host.kill('session-1', { immediate: true }) expect(lastSubprocess.kill).not.toHaveBeenCalled() @@ -307,6 +500,28 @@ describe('TerminalHost', () => { expect(host.isKilled('session-1')).toBe(true) }) + it('retains immediate shutdown ownership when native force kill throws', async () => { + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + ;(lastSubprocess.forceKill as ReturnType).mockImplementationOnce(() => { + throw new Error('native force kill failed') + }) + + expect(() => host.kill('session-1', { immediate: true })).toThrow('native force kill failed') + expect(host.listSessions()).toHaveLength(1) + expect(lastSubprocess.dispose).not.toHaveBeenCalled() + + ;(lastSubprocess.forceKill as ReturnType).mockImplementation(() => + lastSubprocess._onExitCb?.(-1) + ) + host.kill('session-1', { immediate: true }) + expect(host.listSessions()).toHaveLength(0) + }) + it('throws for non-existent session', () => { expect(() => host.kill('missing')).toThrow('Session not found') }) diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 203719d354e..42417f86483 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -1,11 +1,14 @@ import { Session, type SubprocessHandle } from './session' import { normalizePtySize } from './daemon-pty-size' +import { shellPathSupportsPtyStartupBarrier } from './shell-ready' import { resolveProcessCwd } from '../providers/process-cwd' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import { buildStartupCommandSubmission } from '../../shared/startup-command-submission' import type { SessionInfo, TakePendingOutputResult, TerminalSnapshot } from './types' import { SessionNotFoundError } from './types' import type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract' +import { assertPtyPaneIdentity } from '../pty/pty-shutdown-identity' +import { TerminalHostShutdownDrain } from './terminal-host-shutdown-drain' export type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract' @@ -42,13 +45,13 @@ export class TerminalHost { private sessions = new Map() private killedTombstones = new Map() private spawnSubprocess: TerminalHostOptions['spawnSubprocess'] - private onFinalCheckpoint: TerminalHostOptions['onFinalCheckpoint'] private maxTombstones: number + private shutdownDrain: TerminalHostShutdownDrain constructor(opts: TerminalHostOptions) { this.spawnSubprocess = opts.spawnSubprocess - this.onFinalCheckpoint = opts.onFinalCheckpoint this.maxTombstones = opts.maxTombstones ?? DEFAULT_MAX_TOMBSTONES + this.shutdownDrain = new TerminalHostShutdownDrain(this.sessions, opts.onFinalCheckpoint) } /** @@ -58,14 +61,20 @@ export class TerminalHost { * already deliver them through shell launch arguments. */ async createOrAttach(opts: CreateOrAttachOptions): Promise { - const existing = this.sessions.get(opts.sessionId) + let existing = this.sessions.get(opts.sessionId) - // Why: a session that has been asked to terminate (kill() called but the - // subprocess hasn't exited yet) must not be reattached. Reattaching would - // hand the caller a handle that races with the in-flight exit, and any - // subsequent operation (write/kill/resize) would fail once the subprocess - // finally exits. Treat terminating sessions the same as fully-exited ones. - if (existing && existing.isAlive && !existing.isTerminating) { + // Why: replacing a terminating session would dispose its exit listener and + // retry timer before physical exit proof, orphaning the old native process. + while (existing?.isAlive && existing.isTerminating) { + await existing.waitForExitProof() + existing = this.sessions.get(opts.sessionId) + } + if (existing?.isAlive) { + assertPtyPaneIdentity( + opts.sessionId, + { paneKey: existing.paneKey, tabId: existing.tabId }, + { expectedPaneKey: opts.paneKey, expectedTabId: opts.tabId } + ) const snapshot = existing.getSnapshot() existing.detachAllClients() const token = existing.attachClient(opts.streamClient) @@ -76,14 +85,16 @@ export class TerminalHost { shellState: existing.shellState, ...(existing.launchAgent ? { launchAgent: existing.launchAgent } : {}), ...(existing.historySeeded !== undefined ? { historySeeded: existing.historySeeded } : {}), + sessionGeneration: existing.generation, attachToken: token } } - // Clean up dead session if present + // Clean up only a physically exited session if its exit reaper has not run yet. if (existing) { existing.dispose() this.sessions.delete(opts.sessionId) + this.shutdownDrain.notifyIfEmpty() } // Clear tombstone if re-creating a killed session @@ -104,15 +115,28 @@ export class TerminalHost { terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation }) + // Why: the caller computed shellReadySupported from the preferred shell, + // before spawn. A Unix fallback (e.g. /bin/sh) never emits the ready + // marker, so keeping the stale flag would queue startup commands until the + // shell-ready timeout and bracketed-paste-wrap them for a line editor + // without paste mode. + const shellReadySupported = + (opts.shellReadySupported ?? false) && + (subprocess.shellPath === undefined || + shellPathSupportsPtyStartupBarrier(subprocess.shellPath)) + const session = new Session({ sessionId: opts.sessionId, + paneKey: opts.paneKey, + tabId: opts.tabId, cols: size.cols, rows: size.rows, terminalHandle: opts.env?.ORCA_TERMINAL_HANDLE, launchAgent: opts.launchAgent, subprocess, - shellReadySupported: opts.shellReadySupported ?? false, + shellReadySupported, historySeed: opts.historySeed, + deferSubprocessSubscription: true, // Why: reap the dead session (dispose emulator + drop from the map) the // moment its subprocess exits, instead of retaining it for the daemon's // lifetime. Nothing reads a dead session's emulator (getSnapshot/ @@ -126,6 +150,7 @@ export class TerminalHost { this.sessions.set(opts.sessionId, session) const token = session.attachClient(opts.streamClient) + session.startSubprocessSubscription() if (opts.command && !subprocess.startupCommandDeliveredInShellArgs) { // Why: startup commands must run inside the long-lived interactive shell @@ -143,7 +168,7 @@ export class TerminalHost { session.write( buildStartupCommandSubmission(opts.command, { submit, - bracketedPasteSafe: opts.shellReadySupported ?? false + bracketedPasteSafe: shellReadySupported }) ) } @@ -155,6 +180,7 @@ export class TerminalHost { shellState: session.shellState, ...(session.launchAgent ? { launchAgent: session.launchAgent } : {}), ...(session.historySeeded !== undefined ? { historySeeded: session.historySeeded } : {}), + sessionGeneration: session.generation, attachToken: token } } @@ -182,15 +208,17 @@ export class TerminalHost { this.sessions.get(sessionId)?.resumeProducer() } - kill(sessionId: string, opts: { immediate?: boolean } = {}): void { + kill( + sessionId: string, + opts: { immediate?: boolean; expectedPaneKey?: string; expectedTabId?: string } = {} + ): void { const session = this.getAliveSession(sessionId) + assertPtyPaneIdentity(sessionId, { paneKey: session.paneKey, tabId: session.tabId }, opts) this.recordTombstone(sessionId) if (opts.immediate) { - session.forceKillAndDisposeSubprocess() - // Why: the immediate path tears down synchronously without firing the - // session's onExit hook, so reap it here. The graceful path below funnels - // through Session.handleSubprocessExit -> onExit -> reapSession. - this.reapSession(sessionId) + // Why: native force-kill acceptance is not physical-exit proof. The + // Session stays listed until onExit or conservative OS readback reaps it. + session.requestImmediateShutdown() return } session.kill() @@ -208,6 +236,7 @@ export class TerminalHost { } session.dispose() this.sessions.delete(sessionId) + this.shutdownDrain.notifyIfEmpty() } signal(sessionId: string, sig: string): void { @@ -288,6 +317,14 @@ export class TerminalHost { return session.getAppliedSize() } + getAppliedSizeWithGeneration( + sessionId: string + ): { size: { cols: number; rows: number }; sessionGeneration: string } | null { + const session = this.sessions.get(sessionId) + const size = session?.isAlive ? session.getAppliedSize() : null + return session && size ? { size, sessionGeneration: session.generation } : null + } + // Why: same null-not-throw semantics as getSnapshot — incremental // checkpoints are best-effort against sessions that may have just exited. takePendingOutput( @@ -315,6 +352,9 @@ export class TerminalHost { const size = session.getAppliedSize() result.push({ sessionId: session.sessionId, + sessionGeneration: session.generation, + ...(session.paneKey ? { paneKey: session.paneKey } : {}), + ...(session.tabId ? { tabId: session.tabId } : {}), state: session.state, shellState: session.shellState, isAlive: true, @@ -330,41 +370,12 @@ export class TerminalHost { } dispose(): void { - // Why: write final checkpoints before killing sessions so graceful shutdown - // has zero data loss. The checkpoint callback writes synchronously to disk. - if (this.onFinalCheckpoint) { - for (const [sessionId, session] of this.sessions) { - if (!session.isAlive) { - continue - } - const take = session.takePendingOutput(true, { teardownSnapshot: true }) - if (take?.snapshot) { - try { - this.onFinalCheckpoint(sessionId, take.snapshot, take.records) - } catch { - // Best-effort — don't block shutdown - } - } - } - } + this.shutdownDrain.disposeImmediately() + this.killedTombstones.clear() + } - for (const [, session] of this.sessions) { - session.detachAllClients() - // Why: live-vs-exited is load-bearing. For LIVE sessions we use - // forceKillAndDisposeSubprocess (SIGKILL + destroy) to reap stubborn - // children AND release the ptmx fd on the same tick, bypassing the 5s - // KILL_TIMEOUT_MS fallback that would otherwise outlive the daemon - // process. For sessions that have already exited but are still in the - // map, SIGKILL would target a reaped pid — on POSIX that pid can be - // recycled to an unrelated process, so we MUST only release the fd via - // disposeSubprocess() (destroy without kill). See docs/fix-pty-fd-leak.md. - if (session.isAlive) { - session.forceKillAndDisposeSubprocess() - } else { - session.disposeSubprocess() - } - } - this.sessions.clear() + async shutdownAndWait(timeoutMs: number): Promise { + await this.shutdownDrain.run(timeoutMs) this.killedTombstones.clear() } diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 550b211aa74..4148d5e945e 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -12,13 +12,12 @@ export type { // ─── Protocol Version ──────────────────────────────────────────────── import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import type { TuiAgent } from '../../shared/types' -// Why: daemons can survive app updates. Bump for IPC wire-shape changes, or -// when daemon-baked behavior cannot be delivered by on-disk wrapper refresh. -// Why: bump when adding daemon wire behavior so same-version old daemons do -// not silently accept the handshake and then reject new RPCs. -export const PROTOCOL_VERSION = 21 +import type * as PtyIdentity from '../pty/pty-shutdown-identity' +// Why: daemons survive app updates; bump for wire changes so old daemons do +// not accept handshakes for unsupported RPC behavior. +export const PROTOCOL_VERSION = 23 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20 + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22 ] as const // ─── Session State Machine ────────────────────────────────────────── @@ -98,7 +97,7 @@ export type HelloResponse = { export type CreateOrAttachRequest = { id: string type: 'createOrAttach' - payload: { + payload: PtyIdentity.PtyPaneIdentityFields & { sessionId: string cols: number rows: number @@ -190,7 +189,7 @@ export type SetSessionBackgroundRequest = { export type KillRequest = { id: string type: 'kill' - payload: { + payload: PtyIdentity.PtyExpectedPaneIdentityFields & { sessionId: string immediate?: boolean } @@ -365,7 +364,7 @@ export type CreateOrAttachResult = { shellState: ShellReadyState historySeeded?: boolean launchAgent?: TuiAgent -} +} & { sessionGeneration?: string } export type GetSnapshotResult = { snapshot: TerminalSnapshot | null } @@ -380,7 +379,7 @@ export type SystemResolverHealthResult = { health: SystemResolverHealth } -export type SessionInfo = { +export type SessionInfo = PtyIdentity.PtyPaneIdentityFields & { sessionGeneration?: string } & { sessionId: string state: SessionState shellState: ShellReadyState diff --git a/src/main/devin/hook-service.test.ts b/src/main/devin/hook-service.test.ts index 6bc105ffddc..98a83993809 100644 --- a/src/main/devin/hook-service.test.ts +++ b/src/main/devin/hook-service.test.ts @@ -126,9 +126,12 @@ describe('DevinHookService', () => { Object.defineProperty(process, 'platform', { value: 'win32' }) try { const scriptPath = 'C:\\Users\\Ada Lovelace\\.orca\\agent-hooks\\devin-hook.cmd' - expect(getDevinManagedCommand(scriptPath)).toBe( - 'cmd /d /s /c ""C:\\Users\\Ada Lovelace\\.orca\\agent-hooks\\devin-hook.cmd""' - ) + const command = getDevinManagedCommand(scriptPath) + const encoded = command.match(/ -EncodedCommand (\S+)$/)?.[1] + expect(encoded).toBeDefined() + const decoded = Buffer.from(encoded!, 'base64').toString('utf16le') + expect(decoded).toContain(`Test-Path -LiteralPath '${scriptPath}' -PathType Leaf`) + expect(decoded).toContain('[Console]::In.ReadToEnd() | Out-Null') } finally { Object.defineProperty(process, 'platform', { value: previous }) } diff --git a/src/main/devin/hook-service.ts b/src/main/devin/hook-service.ts index b0d63b96ebd..8b4cc439058 100644 --- a/src/main/devin/hook-service.ts +++ b/src/main/devin/hook-service.ts @@ -10,6 +10,11 @@ import { writeHooksJsonRemote, writeManagedScriptRemote } from '../agent-hooks/installer-utils-remote' +import { + buildPosixHookPayloadCapture, + buildWindowsHookEnvironmentGuardLines, + buildWindowsHookStdinDrainEpilogue +} from '../agent-hooks/hook-stdin-contract' import { applyDevinManagedHooks, DEVIN_EVENTS, @@ -41,17 +46,17 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { // reaches the current server. Falls through to PTY env if the file // is missing (first run / pre-endpoint-file / running outside Orca). 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', - 'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0', - 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0', - 'if "%ORCA_PANE_KEY%"=="" exit /b 0', + ...buildWindowsHookEnvironmentGuardLines(), buildWindowsAgentHookPostCommand('devin'), 'exit /b 0', + ...buildWindowsHookStdinDrainEpilogue(), '' ].join('\r\n') } return [ '#!/bin/sh', + ...buildPosixHookPayloadCapture(), // Why: the endpoint file holds the *live* port/token for this Orca // install. PTYs that survive an Orca restart have stale PORT/TOKEN // baked into their env from the old instance — sourcing the file here @@ -72,10 +77,6 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', ' exit 0', 'fi', - 'payload=$(cat)', - 'if [ -z "$payload" ]; then', - ' exit 0', - 'fi', // Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX // shell is not safe once a path contains quotes or newlines. Post the raw // hook payload plus metadata as form fields and let the receiver parse it. diff --git a/src/main/devin/hook-settings.ts b/src/main/devin/hook-settings.ts index a27f4752388..674ddb6c233 100644 --- a/src/main/devin/hook-settings.ts +++ b/src/main/devin/hook-settings.ts @@ -6,6 +6,7 @@ import { getSharedManagedScriptPath, removeManagedCommands, wrapPosixHookCommand, + wrapWindowsCmdHookCommand, type HookDefinition, type HooksConfig } from '../agent-hooks/installer-utils' @@ -51,9 +52,9 @@ export function getDevinRemoteConfigPath(remoteHome: string): string { export function getDevinManagedCommand(scriptPath: string): string { if (process.platform === 'win32') { - // Why: Devin runs hooks through the platform shell on Windows; invoking the - // .cmd via cmd.exe preserves spaces in the shared ~/.orca script path. - return `cmd /d /s /c ""${scriptPath.replaceAll('"', '""')}""` + // Why: keep safe paths on cmd.exe's fast path; the fallback protects spaced + // paths, and both forms drain stdin for a stale missing-script entry. + return wrapWindowsCmdHookCommand(scriptPath) } return wrapPosixHookCommand(scriptPath) } diff --git a/src/main/droid/hook-service.ts b/src/main/droid/hook-service.ts index 946f5ac8809..508f24e98c8 100644 --- a/src/main/droid/hook-service.ts +++ b/src/main/droid/hook-service.ts @@ -21,6 +21,11 @@ import { writeHooksJsonRemote, writeManagedScriptRemote } from '../agent-hooks/installer-utils-remote' +import { + buildPosixHookPayloadCapture, + buildWindowsHookEnvironmentGuardLines, + buildWindowsHookStdinDrainEpilogue +} from '../agent-hooks/hook-stdin-contract' // Why: SessionStart is installed (not just listened for) so that resuming a // droid session via `droid --resume` resets the per-pane prompt/tool caches @@ -77,27 +82,23 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { '@echo off', 'setlocal', 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', - 'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0', - 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0', - 'if "%ORCA_PANE_KEY%"=="" exit /b 0', + ...buildWindowsHookEnvironmentGuardLines(), buildWindowsAgentHookPostCommand('droid'), 'exit /b 0', + ...buildWindowsHookStdinDrainEpilogue(), '' ].join('\r\n') } return [ '#!/bin/sh', + ...buildPosixHookPayloadCapture(), 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', ' exit 0', 'fi', - 'payload=$(cat)', - 'if [ -z "$payload" ]; then', - ' exit 0', - 'fi', // Timeout caps best-effort hook posts if the local listener stalls. // Why: pipe payload to curl's stdin (`payload@-`) instead of an inline // `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl diff --git a/src/main/emulator/serve-sim-state-watcher.test.ts b/src/main/emulator/serve-sim-state-watcher.test.ts index 69b57d60b3b..e528ea324bb 100644 --- a/src/main/emulator/serve-sim-state-watcher.test.ts +++ b/src/main/emulator/serve-sim-state-watcher.test.ts @@ -2,7 +2,11 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { ServeSimStateWatcher, type ServeSimStateDetectedEvent } from './serve-sim-state-watcher' +import { + SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS, + ServeSimStateWatcher, + type ServeSimStateDetectedEvent +} from './serve-sim-state-watcher' const TEST_UDID = '11111111-2222-3333-4444-555555555555' @@ -45,7 +49,9 @@ describe('ServeSimStateWatcher', () => { const parentDir = await mkdtemp(join(tmpdir(), 'orca-serve-sim-watch-')) cleanupPaths.push(parentDir) const stateDir = join(parentDir, 'serve-sim') - const watcher = new ServeSimStateWatcher({ stateDir }) + // Force darwin (the poll is macOS-only) and a fast interval so this + // cross-platform test exercises the existence-poll -> attach path quickly. + const watcher = new ServeSimStateWatcher({ stateDir, platform: 'darwin', existencePollMs: 50 }) const events: ServeSimStateDetectedEvent[] = [] watcher.bindPty('pty-1', 'worktree-1') @@ -78,6 +84,29 @@ describe('ServeSimStateWatcher', () => { watcher.stop() }) + it('does not arm the existence poll on non-macOS platforms', () => { + // serve-sim state never appears off macOS, so start() must not leave a + // recurring timer waking the daemon for a directory that can never exist. + for (const platform of ['win32', 'linux'] as const) { + const watcher = new ServeSimStateWatcher({ + stateDir: join(tmpdir(), `orca-serve-sim-nonmac-${process.pid}-${platform}`), + platform + }) + watcher.start() + const poll = (watcher as unknown as { stateDirPoll: unknown }).stateDirPoll + expect(poll).toBeNull() + watcher.stop() + } + }) + + it('defaults the existence poll to a coarse (battery-friendly) interval', () => { + expect(SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS).toBe(2_000) + const watcher = new ServeSimStateWatcher() + expect((watcher as unknown as { existencePollMs: number }).existencePollMs).toBe( + SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS + ) + }) + it('does not buffer repeated brace-free PTY output while waiting for metadata', () => { const watcher = createIsolatedWatcher() const buffers = (watcher as unknown as { ptyBuffers: Map }).ptyBuffers diff --git a/src/main/emulator/serve-sim-state-watcher.ts b/src/main/emulator/serve-sim-state-watcher.ts index 1a42b2d0353..dae8e57bc39 100644 --- a/src/main/emulator/serve-sim-state-watcher.ts +++ b/src/main/emulator/serve-sim-state-watcher.ts @@ -23,6 +23,10 @@ export type ServeSimStateDetectedEvent = { } const DEFAULT_STATE_DIR = join(tmpdir(), 'serve-sim') +// Why: this only waits for a rarely-created dir to appear; sub-second detection +// of a detached emulator is not user-perceptible, so a coarse interval avoids +// waking the daemon 4x/sec for the whole session on machines that never use it. +export const SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS = 2_000 const STATE_FILE_RE = /^server-([0-9A-F-]{36})\.json$/i const PTY_JSON_RE = /\{[^{}]*"streamUrl"\s*:\s*"[^"]+"[^{}]*"wsUrl"\s*:\s*"[^"]+"[^{}]*\}/g @@ -73,6 +77,8 @@ function trailingIncompletePtyJsonObject(data: string): string { export class ServeSimStateWatcher { private readonly stateDir: string + private readonly platform: NodeJS.Platform + private readonly existencePollMs: number private readonly ptyToWorktree = new Map() private readonly ptyBuffers = new Map() private readonly seenExternalKeys = new Set() @@ -81,8 +87,12 @@ export class ServeSimStateWatcher { private stateWatcher: FSWatcher | null = null private stateDirPoll: ReturnType | null = null - constructor(options: { stateDir?: string } = {}) { + constructor( + options: { stateDir?: string; platform?: NodeJS.Platform; existencePollMs?: number } = {} + ) { this.stateDir = options.stateDir ?? DEFAULT_STATE_DIR + this.platform = options.platform ?? process.platform + this.existencePollMs = options.existencePollMs ?? SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS } onDetected(listener: (event: ServeSimStateDetectedEvent) => void): () => void { @@ -171,6 +181,13 @@ export class ServeSimStateWatcher { if (this.stateDirPoll || this.stateWatcher) { return } + // Why: serve-sim (the iOS Simulator bridge) only ever writes state on macOS, + // so $TMPDIR/serve-sim never appears on Windows/Linux. Skip arming the + // existence poll there instead of waking the daemon every interval for a + // directory that can never exist. + if (this.platform !== 'darwin') { + return + } try { // Why: $TMPDIR/serve-sim/ may not exist until the first terminal `serve-sim --detach`. // Poll for it instead of fs.watch on the parent tmpdir: watching $TMPDIR @@ -182,13 +199,16 @@ export class ServeSimStateWatcher { return } + // attachStateDirWatch() clears this poll once the dir appears and the + // native watcher takes over, so it only ticks while waiting for a + // rarely-created dir — a coarse interval keeps that wait off the idle floor. this.stateDirPoll = setInterval(() => { this.attachStateDirWatch() this.scanExistingStateFiles() - }, 250) + }, this.existencePollMs) this.stateDirPoll.unref?.() } catch { - // Non-mac or permission issues: watcher is best-effort. + // Permission issues: watcher is best-effort. } } diff --git a/src/main/gemini/hook-service.ts b/src/main/gemini/hook-service.ts index 423df12f8fb..e078476edce 100644 --- a/src/main/gemini/hook-service.ts +++ b/src/main/gemini/hook-service.ts @@ -21,6 +21,11 @@ import { writeHooksJsonRemote, writeManagedScriptRemote } from '../agent-hooks/installer-utils-remote' +import { + buildPosixHookPayloadCapture, + buildWindowsHookEnvironmentGuardLines, + buildWindowsHookStdinDrainEpilogue +} from '../agent-hooks/hook-stdin-contract' // Why: Gemini CLI fires `BeforeAgent` when a turn starts and `AfterAgent` when // it completes. `AfterTool` marks the resumption of model work after a tool @@ -65,11 +70,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { // surviving PTY reach the current server even though its env points at // the prior Orca's coordinates. 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', - 'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0', - 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0', - 'if "%ORCA_PANE_KEY%"=="" exit /b 0', + ...buildWindowsHookEnvironmentGuardLines(), buildWindowsAgentHookPostCommand('gemini'), 'exit /b 0', + ...buildWindowsHookStdinDrainEpilogue(), '' ].join('\r\n') } @@ -80,6 +84,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { // to return. Emit `{}` first so the agent never stalls parsing our output, // even if the env-var guards below cause an early exit. 'printf "{}\\n"', + ...buildPosixHookPayloadCapture(), // Why: see claude/hook-service.ts for rationale. Sourcing refreshes // PORT/TOKEN/ENV/VERSION from the current Orca so a surviving PTY keeps // reporting after a restart. @@ -89,10 +94,6 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', ' exit 0', 'fi', - 'payload=$(cat)', - 'if [ -z "$payload" ]; then', - ' exit 0', - 'fi', // Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX // shell is not safe once a path contains quotes or newlines. Post the raw // hook payload plus metadata as form fields and let the receiver parse it. diff --git a/src/main/git/remote.test.ts b/src/main/git/remote.test.ts index 166db56079d..61b95d0e50d 100644 --- a/src/main/git/remote.test.ts +++ b/src/main/git/remote.test.ts @@ -363,6 +363,90 @@ describe('git remote operations', () => { ]) }) + it('retries a divergent pull as a merge when no strategy is configured', async () => { + const divergentError = new Error( + 'Command failed: git pull\n' + 'fatal: Need to specify how to reconcile divergent branches.' + ) + gitExecFileAsyncMock + // First attempt: plain pull rejects with git's reconciliation error. + .mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'origin/feature\n', stderr: '' }) + .mockRejectedValueOnce(divergentError) + // Fallback attempt: pull --no-rebase (merge) succeeds. + .mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'origin/feature\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + + await gitPull('/repo') + + expect(gitExecFileAsyncMock.mock.calls).toEqual([ + [['symbolic-ref', '--quiet', '--short', 'HEAD'], { cwd: '/repo' }], + [['rev-parse', '--abbrev-ref', 'HEAD@{u}'], { cwd: '/repo' }], + [['pull'], { cwd: '/repo' }], + [['symbolic-ref', '--quiet', '--short', 'HEAD'], { cwd: '/repo' }], + [['rev-parse', '--abbrev-ref', 'HEAD@{u}'], { cwd: '/repo' }], + [['pull', '--no-rebase'], { cwd: '/repo' }] + ]) + }) + + it('does not retry a fast-forward-only pull that fails on divergence', async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'origin/feature\n', stderr: '' }) + .mockRejectedValueOnce( + new Error('Command failed: git pull\nfatal: Not possible to fast-forward, aborting.') + ) + + await expect(gitFastForward('/repo')).rejects.toThrow('Not possible to fast-forward') + // No fallback attempt: only the three probe/pull calls ran. + expect(gitExecFileAsyncMock.mock.calls).toHaveLength(3) + }) + + it('retries a divergent pushTarget pull as a merge when no strategy is configured', async () => { + const divergentError = new Error( + 'Command failed: git pull\n' + 'fatal: Need to specify how to reconcile divergent branches.' + ) + gitExecFileAsyncMock + // First attempt: validate the target, then the plain pull rejects. + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockRejectedValueOnce(divergentError) + // Fallback attempt: re-validate, then pull --no-rebase (merge) succeeds. + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + + await gitPull('/repo', { remoteName: 'fork', branchName: 'feature/fix' }) + + // The merge flag is spliced ahead of the positional remote/branch args. + expect(gitExecFileAsyncMock.mock.calls).toEqual([ + [['check-ref-format', '--branch', 'feature/fix'], { cwd: '/repo' }], + [['pull', 'fork', 'feature/fix'], { cwd: '/repo' }], + [['check-ref-format', '--branch', 'feature/fix'], { cwd: '/repo' }], + [['pull', '--no-rebase', 'fork', 'feature/fix'], { cwd: '/repo' }] + ]) + }) + + it('surfaces a normalized error and does not loop when the merge fallback itself fails', async () => { + const divergentError = new Error( + 'Command failed: git pull\n' + 'fatal: Need to specify how to reconcile divergent branches.' + ) + const mergeConflictError = new Error( + 'Command failed: git pull --no-rebase\nCONFLICT (content): Merge conflict in file.txt' + ) + gitExecFileAsyncMock + // First attempt fails with the reconciliation error. + .mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'origin/feature\n', stderr: '' }) + .mockRejectedValueOnce(divergentError) + // The single merge fallback then fails on a real conflict. + .mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'origin/feature\n', stderr: '' }) + .mockRejectedValueOnce(mergeConflictError) + + await expect(gitPull('/repo')).rejects.toThrow() + // At-most-once retry: probe+pull, then probe+fallback-pull — no further attempts. + expect(gitExecFileAsyncMock.mock.calls).toHaveLength(6) + }) + it('pulls the same-name origin branch for legacy base-tracking worktrees', async () => { gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' }) diff --git a/src/main/git/remote.ts b/src/main/git/remote.ts index ab7fba775f9..5c3f40cafe0 100644 --- a/src/main/git/remote.ts +++ b/src/main/git/remote.ts @@ -1,4 +1,7 @@ -import { normalizeGitErrorMessage } from '../../shared/git-remote-error' +import { + normalizeGitErrorMessage, + runPullWithDivergenceFallback +} 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' @@ -214,11 +217,11 @@ async function gitPullWithArgs( pushTarget?: GitPushTarget, options: GitRuntimeOptions = {} ): Promise { - try { + const runPull = async (effectiveArgs: string[]): Promise => { if (pushTarget) { const target = await validateGitPushTarget(worktreePath, pushTarget, options) await gitExecFileAsync( - ['pull', ...pullArgs, target.remoteName, target.branchName], + ['pull', ...effectiveArgs, target.remoteName, target.branchName], gitOptionsForWorktree(worktreePath, options) ) return @@ -230,13 +233,17 @@ async function gitPullWithArgs( // Why: legacy Orca branches may still track origin/main while pushes // target origin/. Pull the same effective branch the UI reports. await gitExecFileAsync( - ['pull', ...pullArgs, upstream.remoteName, upstream.branchName], + ['pull', ...effectiveArgs, upstream.remoteName, upstream.branchName], gitOptionsForWorktree(worktreePath, options) ) return } - await gitExecFileAsync(['pull', ...pullArgs], gitOptionsForWorktree(worktreePath, options)) + await gitExecFileAsync(['pull', ...effectiveArgs], gitOptionsForWorktree(worktreePath, options)) + } + + try { + await runPullWithDivergenceFallback(pullArgs, runPull) } catch (error) { throw new Error(normalizeGitErrorMessage(error, 'pull')) } diff --git a/src/main/git/status-submodule-path-cache.test.ts b/src/main/git/status-submodule-path-cache.test.ts index 18b4ba69371..60adccf142c 100644 --- a/src/main/git/status-submodule-path-cache.test.ts +++ b/src/main/git/status-submodule-path-cache.test.ts @@ -114,7 +114,7 @@ describe('submodule path cache', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) }) - it('does not reuse another branch\'s submodule paths after local or WSL checkout', async () => { + it("does not reuse another branch's submodule paths after local or WSL checkout", async () => { let modulePath = 'main-lib' gitExecFileAsyncMock.mockImplementation((args: string[]) => { if (args[0] === 'checkout') { @@ -219,9 +219,7 @@ describe('submodule path cache', () => { ...runtime, checkoutExistingBranch: true }) - await expect(listSubmodulePaths('/repo-feature', runtime)).resolves.toEqual([ - 'recreated-lib' - ]) + await expect(listSubmodulePaths('/repo-feature', runtime)).resolves.toEqual(['recreated-lib']) const configReads = gitExecFileAsyncMock.mock.calls.filter( ([args]) => args[0] === 'config' && args.includes('.gitmodules') diff --git a/src/main/git/worktree.test.ts b/src/main/git/worktree.test.ts index 54216ab9ad7..035a7f77405 100644 --- a/src/main/git/worktree.test.ts +++ b/src/main/git/worktree.test.ts @@ -23,6 +23,8 @@ import { clearGitCapabilityStateForTests } from './git-capability-state' import { addSparseWorktree, addWorktree, + _getWorktreeScanCacheSizesForTests, + _resetWorktreeScanCacheForTests, listWorktreeGraph, listWorktrees, moveWorktree, @@ -36,7 +38,13 @@ beforeEach(() => { }) describe('listWorktrees in-flight sharing', () => { - beforeEach(() => { + beforeEach(async () => { + gitExecFileAsyncMock.mockReset() + _resetWorktreeScanCacheForTests() + // Why: capability discovery serializes same-host callers; warming it lets + // these tests isolate the scan-generation overlap they are exercising. + gitExecFileAsyncMock.mockResolvedValue({ stdout: '' }) + await listWorktrees('/capability-warmup') gitExecFileAsyncMock.mockReset() }) @@ -87,6 +95,126 @@ describe('listWorktrees in-flight sharing', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) }) + it('does not retain scan generations after mutations without active scans', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '' }) + + await Promise.all( + Array.from({ length: 128 }, (_, index) => + moveWorktree(`/repo-${index}`, `/repo-${index}-old`, `/repo-${index}-new`) + ) + ) + + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 0, generations: 0 }) + }) + + it('cleans mutation generations after stale scans settle and supports repo reuse', async () => { + const scanResolvers: ((stdout: string) => void)[] = [] + let listCalls = 0 + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args[0] === 'worktree' && args[1] === 'list') { + listCalls += 1 + return new Promise((resolve) => { + scanResolvers.push((stdout) => resolve({ stdout })) + }) + } + return Promise.resolve({ stdout: '' }) + }) + + const staleScan = listWorktrees('/repo') + expect(scanResolvers).toHaveLength(1) + + await moveWorktree('/repo', '/repo-old', '/repo-new') + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 1, generations: 1 }) + + const freshScan = listWorktrees('/repo') + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 2, generations: 1 }) + + scanResolvers[1]?.('worktree /repo-new\nHEAD fresh\nbranch refs/heads/main\n') + expect((await freshScan)[0]?.path).toBe('/repo-new') + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 1, generations: 1 }) + + scanResolvers[0]?.('worktree /repo\nHEAD stale\nbranch refs/heads/main\n') + expect((await staleScan)[0]?.path).toBe('/repo') + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 0, generations: 0 }) + + const firstReuse = listWorktrees('/repo') + const secondReuse = listWorktrees('/repo') + expect(listCalls).toBe(3) + + scanResolvers[2]?.('worktree /repo-reused\nHEAD reused\nbranch refs/heads/main\n') + const [firstResult, secondResult] = await Promise.all([firstReuse, secondReuse]) + expect(firstResult).toEqual(secondResult) + expect(firstResult[0]?.path).toBe('/repo-reused') + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 0, generations: 0 }) + }) + + it('retires every overlapping scan generation across repeated mutations', async () => { + const scanResolvers: (() => void)[] = [] + let listCalls = 0 + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args[0] === 'worktree' && args[1] === 'list') { + listCalls += 1 + return new Promise((resolve) => { + scanResolvers.push(() => resolve({ stdout: '' })) + }) + } + return Promise.resolve({ stdout: '' }) + }) + + const oldestScan = listWorktrees('/repo') + await moveWorktree('/repo', '/old-0', '/new-0') + const middleScan = listWorktrees('/repo') + await moveWorktree('/repo', '/old-1', '/new-1') + const newestScan = listWorktrees('/repo') + + expect(listCalls).toBe(3) + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 3, generations: 1 }) + + scanResolvers[0]?.() + scanResolvers[2]?.() + scanResolvers[1]?.() + await Promise.all([oldestScan, middleScan, newestScan]) + + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 0, generations: 0 }) + }) + + it('keeps WSL scans distinct while retiring both after a mutation', async () => { + const scanResolvers: (() => void)[] = [] + gitExecFileAsyncMock.mockResolvedValue({ stdout: '' }) + await Promise.all([ + listWorktrees('/capability-warmup', { wslDistro: 'Ubuntu' }), + listWorktrees('/capability-warmup', { wslDistro: 'Debian' }) + ]) + gitExecFileAsyncMock.mockReset() + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args[0] === 'worktree' && args[1] === 'list') { + return new Promise((resolve) => { + scanResolvers.push(() => resolve({ stdout: '' })) + }) + } + return Promise.resolve({ stdout: '' }) + }) + + const staleUbuntu = listWorktrees('/repo', { wslDistro: 'Ubuntu' }) + const staleDebian = listWorktrees('/repo', { wslDistro: 'Debian' }) + expect(scanResolvers).toHaveLength(2) + + await removeWorktree('/repo', '/worktree', false, { + knownRemovedWorktree: { branch: '', head: '' }, + wslDistro: 'Ubuntu' + }) + const freshUbuntu = listWorktrees('/repo', { wslDistro: 'Ubuntu' }) + const freshDebian = listWorktrees('/repo', { wslDistro: 'Debian' }) + expect(scanResolvers).toHaveLength(4) + + for (const resolve of scanResolvers) { + resolve() + } + await Promise.all([staleUbuntu, staleDebian, freshUbuntu, freshDebian]) + + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 0, generations: 0 }) + }) + it('does not share scans for callers with an AbortSignal', async () => { const scanOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' gitExecFileAsyncMock.mockResolvedValue({ stdout: scanOutput }) diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index 37c017c04d5..c9175b3dcb4 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -650,10 +650,48 @@ const inFlightWorktreeScans = new Map>() // settle for their original callers). const worktreeScanGenerations = new Map() +function hasInFlightWorktreeScanForRepo(repoPath: string): boolean { + const keyPrefix = `${repoPath}\0` + for (const key of inFlightWorktreeScans.keys()) { + if (key.startsWith(keyPrefix)) { + return true + } + } + return false +} + function bumpWorktreeScanGeneration(repoPath: string): void { + // Why: generations only prevent joining a pre-mutation scan. Without an + // active scan, retaining the repo path just leaks completed mutation keys. + if (!hasInFlightWorktreeScanForRepo(repoPath)) { + return + } worktreeScanGenerations.set(repoPath, (worktreeScanGenerations.get(repoPath) ?? 0) + 1) } +function pruneWorktreeScanGeneration(repoPath: string): void { + // Why: ordinary scan settlement should stay O(1); only repos invalidated + // during an active scan need the cross-generation in-flight check. + if (!worktreeScanGenerations.has(repoPath)) { + return + } + if (!hasInFlightWorktreeScanForRepo(repoPath)) { + worktreeScanGenerations.delete(repoPath) + } +} + +export function _getWorktreeScanCacheSizesForTests(): { inFlight: number; generations: number } { + return { + inFlight: inFlightWorktreeScans.size, + generations: worktreeScanGenerations.size + } +} + +export function _resetWorktreeScanCacheForTests(): void { + inFlightWorktreeScans.clear() + worktreeScanGenerations.clear() +} + /** * List all worktrees for a git repo at the given path. Concurrent calls for * the same repo share one scan (unless the caller passes an AbortSignal, @@ -676,6 +714,7 @@ export function listWorktrees( if (inFlightWorktreeScans.get(key) === scan) { inFlightWorktreeScans.delete(key) } + pruneWorktreeScanGeneration(repoPath) }) inFlightWorktreeScans.set(key, scan) return scan diff --git a/src/main/github/client-create-pr.test.ts b/src/main/github/client-create-pr.test.ts index 6c64e4b8066..79d614eb0d0 100644 --- a/src/main/github/client-create-pr.test.ts +++ b/src/main/github/client-create-pr.test.ts @@ -4,6 +4,7 @@ import { readFile } from 'node:fs/promises' const { ghExecFileAsyncMock, getOwnerRepoMock, + getEnterpriseGitHubRepoSlugMock, extractExecErrorMock, acquireMock, releaseMock, @@ -11,6 +12,7 @@ const { } = vi.hoisted(() => ({ ghExecFileAsyncMock: vi.fn(), getOwnerRepoMock: vi.fn(), + getEnterpriseGitHubRepoSlugMock: vi.fn(), extractExecErrorMock: vi.fn((error: unknown) => { const value = error as { stderr?: string; stdout?: string; message?: string } return { @@ -52,12 +54,18 @@ vi.mock('../git/runner', () => ({ gitExecFileAsync: vi.fn() })) +vi.mock('./github-enterprise-repository', () => ({ + getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock +})) + import { createGitHubPullRequest } from './client' describe('createGitHubPullRequest', () => { beforeEach(() => { ghExecFileAsyncMock.mockReset() getOwnerRepoMock.mockReset() + getEnterpriseGitHubRepoSlugMock.mockReset() + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) extractExecErrorMock.mockClear() acquireMock.mockReset() releaseMock.mockReset() @@ -115,6 +123,78 @@ describe('createGitHubPullRequest', () => { expect(releaseMock).toHaveBeenCalledOnce() }) + it('host-qualifies --repo for a GHES remote so gh targets the Enterprise server (#8312)', async () => { + // github.com-only slug parsing misses GHES, so creation comes from the + // enterprise resolver, which carries the host. + getOwnerRepoMock.mockResolvedValueOnce(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValueOnce({ + owner: 'team', + repo: 'orca', + host: 'github.acme-corp.com' + }) + // gh prints the PR URL (not JSON); the GHES host must still parse directly. + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: 'https://github.acme-corp.com/team/orca/pull/7\n' + }) + + await expect( + createGitHubPullRequest('/repo-root', { + provider: 'github', + base: 'main', + head: 'feature/create-pr', + title: 'GHES PR' + }) + ).resolves.toEqual({ + ok: true, + number: 7, + url: 'https://github.acme-corp.com/team/orca/pull/7' + }) + + const [args] = ghExecFileAsyncMock.mock.calls[0] + // Bare "team/orca" would resolve against gh's default host (github.com); + // the host prefix pins the command to the Enterprise server. + expect(args[args.indexOf('--repo') + 1]).toBe('github.acme-corp.com/team/orca') + }) + + it('host-qualifies --repo for the GHES existing-PR fallback lookup (#8312)', async () => { + getOwnerRepoMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue({ + owner: 'team', + repo: 'orca', + host: 'github.acme-corp.com' + }) + // Create reports "already exists", forcing the pr-list fallback. + ghExecFileAsyncMock + .mockRejectedValueOnce( + Object.assign(new Error('exists'), { + stderr: 'a pull request for branch "feature/create-pr" already exists', + stdout: '' + }) + ) + .mockResolvedValueOnce({ + stdout: JSON.stringify([ + { number: 9, url: 'https://github.acme-corp.com/team/orca/pull/9' } + ]) + }) + + await expect( + createGitHubPullRequest('/repo-root', { + provider: 'github', + base: 'main', + head: 'feature/create-pr', + title: 'GHES PR' + }) + ).resolves.toMatchObject({ + ok: false, + code: 'already_exists', + existingReview: { number: 9, url: 'https://github.acme-corp.com/team/orca/pull/9' } + }) + + const [listArgs] = ghExecFileAsyncMock.mock.calls[1] + expect(listArgs).toEqual(expect.arrayContaining(['pr', 'list'])) + expect(listArgs[listArgs.indexOf('--repo') + 1]).toBe('github.acme-corp.com/team/orca') + }) + it('runs local WSL project pull request creation through the selected distro', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock.mockResolvedValueOnce({ diff --git a/src/main/github/client-work-items.test.ts b/src/main/github/client-work-items.test.ts index 66e728908df..d0e93eb306c 100644 --- a/src/main/github/client-work-items.test.ts +++ b/src/main/github/client-work-items.test.ts @@ -189,7 +189,9 @@ describe('listWorkItems', () => { '--repo', 'acme/widgets', '--assignee', - '@me' + '@me', + '--search', + 'sort:updated-desc' ], { cwd: '/repo-root' } ) @@ -205,7 +207,9 @@ describe('listWorkItems', () => { '--repo', 'acme/widgets', '--assignee', - '@me' + '@me', + '--search', + 'sort:updated-desc' ], { cwd: '/repo-root' } ) @@ -406,7 +410,9 @@ describe('listWorkItems', () => { 'acme/widgets', '--state', 'open', - '--draft' + '--draft', + '--search', + 'sort:updated-desc' ], { cwd: '/repo-root' } ) @@ -464,7 +470,9 @@ describe('listWorkItems', () => { '--repo', 'acme/widgets', '--state', - 'merged' + 'merged', + '--search', + 'sort:updated-desc' ], { cwd: '/repo-root' } ) @@ -528,7 +536,7 @@ describe('listWorkItems', () => { const { items } = await listWorkItems('/repo-root', 10, 'is:pr is:closed') expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - expect.arrayContaining(['--state', 'closed', '--search', '-is:merged']), + expect.arrayContaining(['--state', 'closed', '--search', '-is:merged sort:updated-desc']), { cwd: '/repo-root' } ) expect(items).toMatchObject([{ id: 'pr:9', type: 'pr', state: 'closed' }]) @@ -597,7 +605,7 @@ describe('listWorkItems', () => { expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - expect.arrayContaining(['--search', 'review-requested:@me']), + expect.arrayContaining(['--search', 'review-requested:@me sort:updated-desc']), { cwd: '/repo-root' } ) expect(ghExecFileAsyncMock).not.toHaveBeenCalledWith( @@ -606,6 +614,34 @@ describe('listWorkItems', () => { ) }) + it('pins list ordering to updated-desc so the updatedAt cursor pages consistently', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) + + await listWorkItems('/repo-root', 10, 'is:issue is:open') + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + expect.arrayContaining(['--search', 'sort:updated-desc']), + { cwd: '/repo-root' } + ) + }) + + it('combines the inclusive updatedAt cursor with updated-desc ordering on later pages', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) + + await listWorkItems('/repo-root', 10, 'is:issue is:open', '2026-07-01T00:00:00Z') + + // Why: the bound is inclusive (`<=`) so boundary items sharing the cursor's + // exact updatedAt aren't skipped; the renderer dedupes the re-fetched rows. + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + expect.arrayContaining(['--search', 'updated:<=2026-07-01T00:00:00Z sort:updated-desc']), + { cwd: '/repo-root' } + ) + }) + it('returns open issues and PRs for the all-open preset query', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) @@ -652,7 +688,9 @@ describe('listWorkItems', () => { '--repo', 'acme/widgets', '--state', - 'open' + 'open', + '--search', + 'sort:updated-desc' ], { cwd: '/repo-root' } ) @@ -667,7 +705,9 @@ describe('listWorkItems', () => { '--repo', 'acme/widgets', '--state', - 'open' + 'open', + '--search', + 'sort:updated-desc' ], { cwd: '/repo-root' } ) diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index 5363cb56c60..c0a3c58be37 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -118,6 +118,7 @@ import { updatePRState, updatePRTitle, _getMergeQueueCacheSizeForTests, + _getTrackedUpstreamBranchCacheSizesForTests, _resetOwnerRepoCache, _resetMergeQueueCacheForTests, __resetTrackedUpstreamBranchCacheForTests @@ -1786,6 +1787,119 @@ describe('getPRForBranch', () => { expect(trackedUpstreamCalls).toHaveLength(1) }) + it('releases tracked-upstream probe generations across runtime identities', async () => { + const sshGitProvider = { + exec: vi.fn().mockResolvedValue({ stdout: 'feature\0\n', stderr: '' }) + } + getSshGitProviderMock.mockReturnValue(sshGitProvider) + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'feature\0\n', stderr: '' }) + + await getPRForBranch('/repo-root', 'feature') + await getPRForBranch('/repo-root', 'feature', null, null, null, { + localGitExecOptions: { wslDistro: 'Ubuntu' } + }) + await getPRForBranch('/repo-root', 'feature', null, 'ssh-1') + + expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ + snapshots: 3, + inFlight: 0, + generations: 0 + }) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect(sshGitProvider.exec).toHaveBeenCalledTimes(1) + }) + + it('bounds unique tracked-upstream snapshots and sweeps expired identities', async () => { + vi.useFakeTimers() + try { + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'feature\0\n', stderr: '' }) + + for (let index = 0; index < 513; index += 1) { + await getPRForBranch(`/repo-root-${index}`, 'feature') + } + await getPRForBranch('/repo-root-512', 'feature') + + expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ + snapshots: 512, + inFlight: 0, + generations: 0 + }) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(513) + + await vi.advanceTimersByTimeAsync(30_001) + await getPRForBranch('/repo-root-fresh', 'feature') + + expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ + snapshots: 1, + inFlight: 0, + generations: 0 + }) + } finally { + vi.useRealTimers() + } + }) + + it('keeps stale probe cleanup from releasing a replacement generation', async () => { + let resolveOldProbe: (value: { stdout: string; stderr: string }) => void + let resolveCurrentProbe: (value: { stdout: string; stderr: string }) => void + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) + gitExecFileAsyncMock + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveOldProbe = resolve + }) + ) + .mockResolvedValueOnce({ stdout: 'replacement\0\n', stderr: '' }) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveCurrentProbe = resolve + }) + ) + + const oldLookup = getPRForBranch('/repo-root', 'old') + await vi.waitFor(() => expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)) + __resetTrackedUpstreamBranchCacheForTests() + await getPRForBranch('/repo-root', 'replacement') + const currentLookup = getPRForBranch('/repo-root', 'current') + await vi.waitFor(() => expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(3)) + + resolveOldProbe!({ stdout: 'old\0\n', stderr: '' }) + await oldLookup + expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ + snapshots: 0, + inFlight: 1, + generations: 1 + }) + + resolveCurrentProbe!({ stdout: 'current\0\n', stderr: '' }) + await currentLookup + expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ + snapshots: 1, + inFlight: 0, + generations: 0 + }) + }) + + it('releases tracked-upstream probe state when setup rejects', async () => { + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) + readLocalGitConfigSignatureMock.mockRejectedValue(new Error('git config unavailable')) + + await expect(getPRForBranch('/repo-root', 'feature')).resolves.toBeNull() + expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ + snapshots: 0, + inFlight: 0, + generations: 0 + }) + }) + it('does not fan out tracked-upstream probes after a transient for-each-ref failure', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValue({ candidates: [{ owner: 'acme', repo: 'widgets' }], diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 6e4005421a4..5330de5f701 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -79,6 +79,7 @@ import { rememberGhCwdResolutionFailure } from './gh-cwd-repo-negative-cache' import type { GitHubRepoContext } from './github-repository-identity' +import { getEnterpriseGitHubRepoSlug } from './github-enterprise-repository' export { _resetOwnerRepoCache } from './gh-utils' export { getIssue, @@ -454,6 +455,12 @@ type MainWorkItem = Omit const WORK_ITEM_ISSUE_LIST_JSON_FIELDS = 'number,title,state,url,labels,updatedAt,author,assignees' +// Why: the Tasks pager slices pages on updatedAt, so every list/search fetch +// must return rows newest-updated-first for the cursor to advance correctly. +// This is the search-qualifier spelling of the same contract the REST list +// paths express as `sort=updated&direction=desc`; keep the two in sync (#8649). +const WORK_ITEM_LIST_SORT_QUALIFIER = 'sort:updated-desc' + const WORK_ITEM_PR_LIST_JSON_FIELDS = 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests' @@ -995,11 +1002,13 @@ function buildWorkItemListArgs(args: { if (excludeMergedFromClosed) { searchParts.push('-is:merged') } - // Why: cursor-based pagination. GitHub search supports updated: 0) { - out.push('--search', searchParts.join(' ')) - } + // Why: pagination cursors slice on updatedAt, but `gh issue list` defaults + // to created-desc and `--search` defaults to best-match. `gh issue/pr list` + // has no --sort flag, so the only lever is the search query — which forces us + // to always emit --search. Without pinning the sort, recently-updated old + // items never appear on any page, so the pager advertises pages the fetch + // chain can never reach (#8649). + searchParts.push(WORK_ITEM_LIST_SORT_QUALIFIER) + out.push('--search', searchParts.join(' ')) return out } @@ -1750,16 +1764,29 @@ function parseCreatePRPayload(stdout: string): { number: number; url: string } | } catch { // Fall through to URL parsing for older gh versions without --json support. } - const urlMatch = trimmed.match(/https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)/) + // Why: gh prints the PR URL (not JSON) here; match any host, not just + // github.com, so a GitHub Enterprise Server URL still parses directly (#8312). + const urlMatch = trimmed.match(/https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/pull\/(\d+)/) if (!urlMatch) { return null } return { number: Number(urlMatch[1]), url: urlMatch[0] } } +// Why: `gh --repo OWNER/REPO` resolves the shorthand against gh's default host +// (usually github.com), not the repo's remote — so a GHES repo would target a +// same-named github.com repo, or fail. Qualify with the host for GHES so gh hits +// the Enterprise server; this is the only host signal for SSH repos, which run +// gh with no cwd context (#8312). github.com keeps the bare shorthand. +function ghRepoArg(slug: { owner: string; repo: string; host?: string }): string { + return slug.host && slug.host.toLowerCase() !== 'github.com' + ? `${slug.host}/${slug.owner}/${slug.repo}` + : `${slug.owner}/${slug.repo}` +} + async function findOpenPRByHeadBase(args: { repoPath: string - ownerRepo: OwnerRepo + repoArg: string head: string base: string connectionId?: string | null @@ -1771,7 +1798,7 @@ async function findOpenPRByHeadBase(args: { 'pr', 'list', '--repo', - `${args.ownerRepo.owner}/${args.ownerRepo.repo}`, + args.repoArg, '--head', args.head, '--base', @@ -1844,11 +1871,11 @@ export async function createGitHubPullRequest( } } - const ownerRepo = await getOwnerRepo( - repoPath, - connectionId, - ...hostedReviewLocalGitOptionArgs(options) - ) + // Why: github.com-only slug parsing returns null for GHES, so fall back to the + // enterprise resolver (gh-authenticated custom host) before giving up (#8312). + const ownerRepo = + (await getOwnerRepo(repoPath, connectionId, ...hostedReviewLocalGitOptionArgs(options))) ?? + (await getEnterpriseGitHubRepoSlug(repoPath, connectionId, options)) if (!ownerRepo) { return { ok: false, @@ -1856,6 +1883,8 @@ export async function createGitHubPullRequest( error: 'Creating pull requests requires a GitHub remote.' } } + // Host-qualified for GHES so gh targets the Enterprise server, not github.com. + const repoArg = ghRepoArg(ownerRepo) const base = normalizeHostedReviewBaseRef(input.base) const head = input.head ? normalizeHostedReviewHeadRef(input.head) || undefined : undefined @@ -1888,7 +1917,7 @@ export async function createGitHubPullRequest( 'pr', 'create', '--repo', - `${ownerRepo.owner}/${ownerRepo.repo}`, + repoArg, '--base', base, '--title', @@ -1917,7 +1946,7 @@ export async function createGitHubPullRequest( const found = head ? await findOpenPRByHeadBase({ repoPath, - ownerRepo, + repoArg, head, base, connectionId, @@ -1941,7 +1970,7 @@ export async function createGitHubPullRequest( ) { const existing = await findOpenPRByHeadBase({ repoPath, - ownerRepo, + repoArg, head, base, connectionId, @@ -2392,6 +2421,7 @@ type TrackedUpstreamBranch = { } const TRACKED_UPSTREAM_SNAPSHOT_CACHE_TTL_MS = 30_000 +const TRACKED_UPSTREAM_SNAPSHOT_CACHE_MAX_ENTRIES = 512 type TrackedUpstreamSnapshotCacheEntry = { expiresAt: number @@ -2411,12 +2441,49 @@ const trackedUpstreamSnapshotInFlight = new Map< string, Promise >() -const trackedUpstreamSnapshotGenerations = new Map() +const trackedUpstreamSnapshotGenerations = new Map() -function beginTrackedUpstreamSnapshotProbe(cacheKey: string): number { - const nextGeneration = (trackedUpstreamSnapshotGenerations.get(cacheKey) ?? 0) + 1 - trackedUpstreamSnapshotGenerations.set(cacheKey, nextGeneration) - return nextGeneration +function beginTrackedUpstreamSnapshotProbe(cacheKey: string): symbol { + const generation = Symbol() + trackedUpstreamSnapshotGenerations.set(cacheKey, generation) + return generation +} + +function finishTrackedUpstreamSnapshotProbe(cacheKey: string, generation: symbol): void { + // Why: generations only guard an active probe; retaining completed repo keys + // leaks worktree/runtime identities after the short-lived snapshot TTL expires. + if (trackedUpstreamSnapshotGenerations.get(cacheKey) === generation) { + trackedUpstreamSnapshotGenerations.delete(cacheKey) + } +} + +function pruneTrackedUpstreamSnapshotCache(now: number): void { + for (const [cacheKey, cached] of trackedUpstreamSnapshotCache) { + if (cached.expiresAt <= now) { + trackedUpstreamSnapshotCache.delete(cacheKey) + } + } + // Why: workspace/runtime churn can create unbounded unique keys within one + // TTL window, so expiry sweeping alone is not a memory bound. + while (trackedUpstreamSnapshotCache.size > TRACKED_UPSTREAM_SNAPSHOT_CACHE_MAX_ENTRIES) { + const oldestKey = trackedUpstreamSnapshotCache.keys().next().value + if (oldestKey === undefined) { + break + } + trackedUpstreamSnapshotCache.delete(oldestKey) + } +} + +export function _getTrackedUpstreamBranchCacheSizesForTests(): { + snapshots: number + inFlight: number + generations: number +} { + return { + snapshots: trackedUpstreamSnapshotCache.size, + inFlight: trackedUpstreamSnapshotInFlight.size, + generations: trackedUpstreamSnapshotGenerations.size + } } export function __resetTrackedUpstreamBranchCacheForTests(): void { @@ -2510,6 +2577,7 @@ async function getTrackedUpstreamBranch( upstreamsByBranchName: getCacheableTrackedUpstreamSnapshot(result.upstreamsByBranchName), expiresAt: Date.now() + TRACKED_UPSTREAM_SNAPSHOT_CACHE_TTL_MS }) + pruneTrackedUpstreamSnapshotCache(Date.now()) } if (trackedUpstreamSnapshotGenerations.get(cacheKey) !== probeGeneration) { const fresherCached = trackedUpstreamSnapshotCache.get(cacheKey) @@ -2522,6 +2590,7 @@ async function getTrackedUpstreamBranch( if (trackedUpstreamSnapshotInFlight.get(cacheKey) === probe) { trackedUpstreamSnapshotInFlight.delete(cacheKey) } + finishTrackedUpstreamSnapshotProbe(cacheKey, probeGeneration) } } diff --git a/src/main/github/github-enterprise-repository.test.ts b/src/main/github/github-enterprise-repository.test.ts new file mode 100644 index 00000000000..73de02ddae9 --- /dev/null +++ b/src/main/github/github-enterprise-repository.test.ts @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { ghExecFileAsyncMock, gitExecFileAsyncMock } = vi.hoisted(() => ({ + ghExecFileAsyncMock: vi.fn(), + gitExecFileAsyncMock: vi.fn() +})) + +// Mock only the exec boundary so the real remote-identity parsing, runtime +// option resolution, and `gh auth status` parsing run against controlled output. +vi.mock('../git/runner', () => ({ + ghExecFileAsync: ghExecFileAsyncMock, + gitExecFileAsync: gitExecFileAsyncMock +})) + +import { + _resetGitHubHostAuthCache, + getEnterpriseGitHubRepoSlug, + isGitHubHostAuthenticated +} from './github-enterprise-repository' + +function mockOriginRemote(url: string): void { + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + return { stdout: `${url}\n`, stderr: '' } + } + return { stdout: '', stderr: '' } + }) +} + +// gh exit 0 for `auth status --hostname ` means logged in to that host. +function mockHostAuthenticated(host = 'github.acme-corp.com'): void { + ghExecFileAsyncMock.mockResolvedValue({ + stdout: `${host}\n ✓ Logged in to ${host} account kelora (keyring)`, + stderr: '' + }) +} + +// gh exits non-zero and reports no matching host when not logged in. +function mockHostNotAuthenticated(): void { + ghExecFileAsyncMock.mockRejectedValue( + Object.assign(new Error('exit 1'), { + stdout: '', + stderr: 'You are not logged into any GitHub hosts. To log in, run: gh auth login' + }) + ) +} + +describe('getEnterpriseGitHubRepoSlug', () => { + beforeEach(() => { + ghExecFileAsyncMock.mockReset() + gitExecFileAsyncMock.mockReset() + _resetGitHubHostAuthCache() + }) + + it('resolves a GHES remote whose host the user is gh-authenticated to (#8312)', async () => { + mockOriginRemote('https://github.acme-corp.com/team/orca.git') + mockHostAuthenticated() + + await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toEqual({ + owner: 'team', + repo: 'orca', + host: 'github.acme-corp.com' + }) + // The auth probe targets the remote's host, not a hardcoded github.com. + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['auth', 'status', '--hostname', 'github.acme-corp.com'], + { cwd: '/repo' } + ) + }) + + it('resolves a GHES SCP-style SSH remote', async () => { + mockOriginRemote('git@github.acme-corp.com:team/orca.git') + mockHostAuthenticated() + + await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toEqual({ + owner: 'team', + repo: 'orca', + host: 'github.acme-corp.com' + }) + }) + + it('probes gh in the repository WSL runtime, not the host/default distro', async () => { + mockOriginRemote('https://github.acme-corp.com/team/orca.git') + mockHostAuthenticated() + + await getEnterpriseGitHubRepoSlug('/repo', null, { + localGitExecOptions: { wslDistro: 'Ubuntu' } + }) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['auth', 'status', '--hostname', 'github.acme-corp.com'], + { cwd: '/repo', wslDistro: 'Ubuntu' } + ) + }) + + it('leaves github.com to getOwnerRepo without probing gh auth', async () => { + mockOriginRemote('https://github.com/team/orca.git') + + await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeNull() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('declines a custom host the user is not gh-authenticated to (leaves it for Gitea)', async () => { + mockOriginRemote('https://gitea.example.com/team/orca.git') + mockHostNotAuthenticated() + + await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeNull() + }) + + it('returns null for an unparseable remote', async () => { + mockOriginRemote('not-a-remote-url') + mockHostAuthenticated() + + await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeNull() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('returns null when the origin remote lookup fails', async () => { + gitExecFileAsyncMock.mockRejectedValue(new Error('no such remote')) + + await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeNull() + }) +}) + +describe('isGitHubHostAuthenticated', () => { + beforeEach(() => { + ghExecFileAsyncMock.mockReset() + gitExecFileAsyncMock.mockReset() + _resetGitHubHostAuthCache() + }) + + it('runs gh in the SSH-local runtime (no cwd) for connection-backed repos', async () => { + mockHostAuthenticated() + + await expect( + isGitHubHostAuthenticated('github.acme-corp.com', '/remote/repo', 'ssh-1') + ).resolves.toBe(true) + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['auth', 'status', '--hostname', 'github.acme-corp.com'], + {} + ) + }) + + it('caches per runtime+host so detection polling does not re-spawn gh', async () => { + mockHostAuthenticated() + + await isGitHubHostAuthenticated('github.acme-corp.com', '/repo') + await isGitHubHostAuthenticated('github.acme-corp.com', '/repo') + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + it('does not share cache state across WSL distros', async () => { + mockHostAuthenticated() + + await isGitHubHostAuthenticated('github.acme-corp.com', '/repo', null, { wslDistro: 'Ubuntu' }) + await isGitHubHostAuthenticated('github.acme-corp.com', '/repo', null, { wslDistro: 'Debian' }) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('treats a listed host as authenticated even when gh exits non-zero', async () => { + ghExecFileAsyncMock.mockRejectedValue( + Object.assign(new Error('exit 1'), { + stdout: '', + stderr: + 'github.acme-corp.com\n ✓ Logged in to github.acme-corp.com account kelora (keyring)\n X github.com: token expired' + }) + ) + + await expect(isGitHubHostAuthenticated('github.acme-corp.com', '/repo')).resolves.toBe(true) + }) + + it('does not cache a hard gh failure so a later probe can recover', async () => { + ghExecFileAsyncMock.mockRejectedValueOnce( + Object.assign(new Error('not installed'), { stdout: '', stderr: '' }) + ) + expect(await isGitHubHostAuthenticated('github.acme-corp.com', '/repo')).toBe(false) + + mockHostAuthenticated() + expect(await isGitHubHostAuthenticated('github.acme-corp.com', '/repo')).toBe(true) + }) +}) diff --git a/src/main/github/github-enterprise-repository.ts b/src/main/github/github-enterprise-repository.ts new file mode 100644 index 00000000000..94cffe725fa --- /dev/null +++ b/src/main/github/github-enterprise-repository.ts @@ -0,0 +1,130 @@ +import { ghExecFileAsync } from '../git/runner' +import type { GitHubOwnerRepo } from '../../shared/types' +import { + getHostedReviewLocalGitOptions, + type HostedReviewExecutionOptions +} from '../source-control/hosted-review-git-options' +import { parseAuthStatus } from './auth-diagnose' +import { + ghRepoExecOptions, + getRemoteUrlForRepo, + githubRepoContext, + parseGitHubRemoteIdentity, + type LocalGitExecOptions +} from './github-repository-identity' + +export type GitHubEnterpriseRepoSlug = GitHubOwnerRepo & { host: string } + +// Why: `gh` only ever manages github.com / GitHub Enterprise credentials, so a +// host `gh auth status` reports as logged-in is definitively a GitHub host. This +// mirrors the `glab auth status` signal GitLab self-hosted detection uses, so a +// GHES remote is not left to fall through to Gitea (#8312). +const HOST_AUTH_TTL_MS = 60_000 + +type HostAuthCacheEntry = { + authenticated: boolean + expiresAt: number +} + +const hostAuthCache = new Map() + +// Why: gh's authenticated hosts live in per-runtime config — a WSL distro and an +// SSH host each carry their own `hosts.yml` — so cache state must be keyed by the +// runtime that executes gh, not shared under one "local" bucket. Mirrors the +// runtime scoping used by owner/repo resolution. +function runtimeCacheKey(connectionId?: string | null, wslDistro?: string): string { + return connectionId ?? `local:${wslDistro ?? 'host'}` +} + +/** @internal - exposed for tests only */ +export function _resetGitHubHostAuthCache(): void { + hostAuthCache.clear() +} + +// Only gh's own stdout/stderr — not the Error.message — counts as an +// authoritative answer. A spawn failure (gh missing, ENOENT) carries just a +// message and no command output, and must stay indeterminate rather than be +// read as "host not authenticated". +function ghCommandOutput(error: unknown): string { + const execErr = error as { stdout?: unknown; stderr?: unknown } + return [execErr?.stdout, execErr?.stderr] + .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) + .join('\n') +} + +/** + * Whether `gh` is authenticated to `host` from the repository's own runtime. + * + * The probe runs `gh auth status --hostname ` with the repo's execution + * options (cwd / WSL distro, or SSH-local like the create path), so a GHES login + * stored only in that runtime's gh config — or a `GH_ENTERPRISE_TOKEN` inferred + * from it — is honored instead of the host/default-distro gh. Cached briefly per + * runtime+host so provider-detection polling does not re-spawn gh each time. + */ +export async function isGitHubHostAuthenticated( + host: string, + repoPath: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const normalizedHost = host.toLowerCase() + const cacheKey = `${runtimeCacheKey(connectionId, localGitOptions.wslDistro)}\0${normalizedHost}` + const now = Date.now() + const cached = hostAuthCache.get(cacheKey) + if (cached && cached.expiresAt > now) { + return cached.authenticated + } + const execOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + let authenticated: boolean + try { + await ghExecFileAsync(['auth', 'status', '--hostname', normalizedHost], execOptions) + authenticated = true + } catch (error) { + const output = ghCommandOutput(error) + if (!output) { + // Indeterminate (gh missing / spawn failure) — do not cache so a later + // probe (gh installed, tunnel ready, token added) can recover. + return false + } + // gh exits non-zero when a host has a token problem but still prints the + // per-host status; treat the host as GitHub only when it is actually listed. + authenticated = parseAuthStatus(output).some( + (account) => account.host.toLowerCase() === normalizedHost + ) + } + hostAuthCache.set(cacheKey, { authenticated, expiresAt: now + HOST_AUTH_TTL_MS }) + return authenticated +} + +/** + * Resolve owner/repo for a GitHub Enterprise Server `origin` remote — a custom + * host the user is gh-authenticated to. Returns null for github.com (already + * handled by {@link getOwnerRepo}) and for hosts gh is not logged in to + * (Gitea/Forgejo/self-hosted GitLab/etc.), so GHES routes to the GitHub provider + * without a GitHub provider stealing another forge's remote. + */ +export async function getEnterpriseGitHubRepoSlug( + repoPath: string, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + const localGitOptions = getHostedReviewLocalGitOptions(options) + const context = githubRepoContext(repoPath, connectionId, localGitOptions) + let remoteUrl: string | null + try { + remoteUrl = await getRemoteUrlForRepo(context, 'origin') + } catch { + return null + } + const identity = remoteUrl ? parseGitHubRemoteIdentity(remoteUrl) : null + if (!identity || identity.host === 'github.com') { + return null + } + const authenticated = await isGitHubHostAuthenticated( + identity.host, + repoPath, + connectionId, + localGitOptions + ) + return authenticated ? { owner: identity.owner, repo: identity.repo, host: identity.host } : null +} diff --git a/src/main/github/work-item-details.test.ts b/src/main/github/work-item-details.test.ts index 69504b92f98..6d3d14c99b7 100644 --- a/src/main/github/work-item-details.test.ts +++ b/src/main/github/work-item-details.test.ts @@ -498,4 +498,74 @@ describe('getWorkItemDetails', () => { true ) }) + + // Why: a rate-limited/auth-failed file fetch must not render as an empty PR; + // the Files tab keys its retry state off details.filesUnavailable. + it('flags filesUnavailable when the PR file fetch fails but leaves the PR empty otherwise intact', async () => { + getWorkItemMock.mockResolvedValueOnce({ + id: 'pr:8305', + type: 'pr', + number: 8305, + title: 'Files fetch fails', + state: 'open', + url: 'https://github.com/acme/widgets/pull/8305', + labels: [], + updatedAt: '2026-07-11T00:00:00Z', + author: 'pr-author' + }) + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getPRCommentsMock.mockResolvedValue([]) + getPRChecksMock.mockResolvedValue([]) + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const target = args.at(-1) + if (target === 'repos/acme/widgets/pulls/8305') { + return { + stdout: JSON.stringify({ head: { sha: 'head-sha' }, base: { sha: 'base-sha' } }) + } + } + if (target === 'repos/acme/widgets/pulls/8305/files?per_page=100') { + throw new Error('gh: API rate limit exceeded (403)') + } + return { stdout: JSON.stringify({ data: {} }) } + }) + + const details = await getWorkItemDetails('/repo-root', 8305, 'pr') + + expect(details?.filesUnavailable).toBe(true) + expect(details?.files).toBeUndefined() + }) + + it('treats an empty file list as a genuinely empty PR, not an unavailable one', async () => { + getWorkItemMock.mockResolvedValueOnce({ + id: 'pr:8306', + type: 'pr', + number: 8306, + title: 'Empty PR', + state: 'open', + url: 'https://github.com/acme/widgets/pull/8306', + labels: [], + updatedAt: '2026-07-11T00:00:00Z', + author: 'pr-author' + }) + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getPRCommentsMock.mockResolvedValue([]) + getPRChecksMock.mockResolvedValue([]) + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const target = args.at(-1) + if (target === 'repos/acme/widgets/pulls/8306') { + return { + stdout: JSON.stringify({ head: { sha: 'head-sha' }, base: { sha: 'base-sha' } }) + } + } + if (target === 'repos/acme/widgets/pulls/8306/files?per_page=100') { + return { stdout: '[]' } + } + return { stdout: JSON.stringify({ data: {} }) } + }) + + const details = await getWorkItemDetails('/repo-root', 8306, 'pr') + + expect(details?.filesUnavailable).toBe(false) + expect(details?.files).toEqual([]) + }) }) diff --git a/src/main/github/work-item-details.ts b/src/main/github/work-item-details.ts index 6029613fb3d..9aa2de2b26b 100644 --- a/src/main/github/work-item-details.ts +++ b/src/main/github/work-item-details.ts @@ -525,12 +525,15 @@ async function getPRHeadBaseSha( } } +// Why: null signals a failed/blocked fetch (rate limit, auth, unresolved +// remote) so the Files tab can show a retryable error instead of the +// misleading "No files changed." empty state; [] means a genuinely empty PR. async function getPRFiles( repoPath: string, prNumber: number, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} -): Promise { +): Promise { const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) const ownerRepo = await getOwnerRepo( repoPath, @@ -538,7 +541,7 @@ async function getPRFiles( ...localGitOptionArgs(localGitOptions) ) if (!ownerRepo) { - return [] + return null } try { const { stdout } = await ghExecFileAsync( @@ -561,7 +564,7 @@ async function getPRFiles( reviewCommentLineNumbers: getPRReviewCommentLineNumbersFromPatch(file.patch) })) } catch { - return [] + return null } } @@ -1068,7 +1071,10 @@ export async function getWorkItemDetails( baseSha: shas?.baseSha, pullRequestId: viewedStates?.pullRequestId, checks, - files: mergePRFileViewedStates(files, viewedStates), + // Why: distinguish a failed file fetch (null) from an empty PR so the + // Files tab surfaces a retry instead of "No files changed." + files: files === null ? undefined : mergePRFileViewedStates(files, viewedStates), + filesUnavailable: files === null, participants: mentionParticipants } } finally { diff --git a/src/main/grok/hook-service.ts b/src/main/grok/hook-service.ts index c88e1254feb..564565d0d24 100644 --- a/src/main/grok/hook-service.ts +++ b/src/main/grok/hook-service.ts @@ -20,6 +20,11 @@ import { writeHooksJsonRemote, writeManagedScriptRemote } from '../agent-hooks/installer-utils-remote' +import { + buildPosixHookPayloadCapture, + buildWindowsHookEnvironmentGuardLines, + buildWindowsHookStdinDrainEpilogue +} from '../agent-hooks/hook-stdin-contract' // Why: Grok's tool-event matcher is a real regex (see Grok hooks docs). Bare // `*` is not a valid "match all" pattern and can fail to load/match, so tool @@ -115,9 +120,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { '@echo off', 'setlocal', 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', - 'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0', - 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0', - 'if "%ORCA_PANE_KEY%"=="" exit /b 0', + ...buildWindowsHookEnvironmentGuardLines(), 'set "ORCA_GROK_HOME=%GROK_HOME%"', `if not "%GROK_HOME:~${GROK_HOME_ENVELOPE_MAX_LENGTH},1%"=="" set "ORCA_GROK_HOME="`, // Why: a trailing backslash escapes curl's closing argv quote on Windows, @@ -125,22 +128,20 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { 'if "%ORCA_GROK_HOME:~-1%"=="\\" set "ORCA_GROK_HOME=%ORCA_GROK_HOME%."', WINDOWS_GROK_HOOK_POST_COMMAND, 'exit /b 0', + ...buildWindowsHookStdinDrainEpilogue(), '' ].join('\r\n') } return [ '#!/bin/sh', + ...buildPosixHookPayloadCapture(), 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', ' exit 0', 'fi', - 'payload=$(cat)', - 'if [ -z "$payload" ]; then', - ' exit 0', - 'fi', 'grok_home=', `if [ -n "\${GROK_HOME:-}" ] && [ "\${#GROK_HOME}" -le ${GROK_HOME_ENVELOPE_MAX_LENGTH} ]; then`, ' grok_home=$GROK_HOME', diff --git a/src/main/index.ts b/src/main/index.ts index 27e9aee2aa4..eb2ac4a7668 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -85,13 +85,15 @@ import { import { maybeRedirectAppImageCliLaunch } from './startup/appimage-cli-redirect' import { maybeRedirectPackagedCliEntryLaunch } from './startup/packaged-cli-entry-redirect' import { startFirstWindowStartupServices } from './startup/first-window-startup-services' +import { createWslCliReconciliationStartupBarrier } from './startup/wsl-cli-reconciliation-startup-barrier' import { getDevInstanceIdentity } from './startup/dev-instance-identity' import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path' import { acquireSingleInstanceLock, logSingleInstanceLockBypass, logSingleInstanceLockFailure, - shouldBypassSingleInstanceLock + shouldBypassSingleInstanceLock, + shouldSkipSingleInstanceLock } from './startup/single-instance-lock' import { startEventLoopStallProbe } from './startup/event-loop-stall-probe' import { startMainThreadChurnProbe } from './diagnostics/main-thread-churn-probe' @@ -102,6 +104,7 @@ import { } from './startup/startup-diagnostics' import { ensureWindowsUserDataAclGrant } from './startup/windows-user-data-acl' import { shouldQuitWhenAllWindowsClosed } from './startup/window-all-closed-quit-policy' +import { createServeDesktopActivationGate } from './startup/serve-desktop-activation' import { RateLimitService } from './rate-limits/service' import { readMiniMaxSessionCookie } from './minimax/minimax-cookie-store' import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target' @@ -133,6 +136,7 @@ import { StarNagService } from './star-nag/service' import { agentHookServer } from './agent-hooks/server' import { wslHookRelayManager } from './agent-hooks/wsl-hook-relay-manager' import { maybeAutoRenameBranchOnFirstWork } from './agent-hooks/first-work-branch-rename' +import { rememberBranchRenameFailureOutput } from './agent-hooks/branch-rename-failure-output' import { renameWorktreeFolderOnFirstWork } from './agent-hooks/first-work-folder-rename' import { moveWorktree } from './git/worktree' import { getRepoIdFromWorktreeId } from '../shared/worktree-id' @@ -181,11 +185,17 @@ import { import type { AgentStatusState } from '../shared/agent-status-types' import { resolveTuiAgentPermissionMode } from '../shared/tui-agent-permissions' import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts' +import { + HEADLESS_RUNTIME_WINDOW_ID, + type RuntimeDesktopWindowStatus +} from '../shared/runtime-types' +import { LocalPtyProvider } from './providers/local-pty-provider' import { KeybindingService } from './keybindings/keybinding-service' import { applyElectronProxySettings } from './network/proxy-settings' import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation' import { CliInstaller } from './cli/cli-installer' import { installLinuxBareOrcaDispatcher } from './cli/linux-bare-orca-dispatcher' +import { reconcileManagedWslCliRegistrations } from './cli/wsl-cli-registration-reconciliation' import { selfHealRuntimeEnvironmentFocus } from './runtime-environment-focus-self-heal' let mainWindow: BrowserWindow | null = null @@ -224,6 +234,12 @@ let keybindings: KeybindingService | null = null const expectedRendererReload = createWebContentsTimedFlag() const recoveryReloadInFlight = createWebContentsTimedFlag() let firstWindowStartupServicesReady: Promise = Promise.resolve() +let managedWslCliReconciliationReady: Promise = Promise.resolve() +let managedWslCliStartupBarrierReady: Promise = Promise.resolve() +// Why: the serve barrier fails open at its budget, so headless clients need the +// reconciliation state at ready time to know a WSL PTY launch may still race an +// un-migrated registration. 'settled' covers the off-Windows no-op fast path. +let managedWslCliReconciliationStatus: 'pending' | 'settled' | 'failed' = 'settled' // Why: GPU child crashes clustered right after launch indicate a broken driver; // track them so Orca can move this build onto software rendering. const gpuLaunchTimeMs = Date.now() @@ -235,6 +251,16 @@ let gpuFallbackActiveThisLaunch = false let localPtyStartupReady: Promise = Promise.resolve() const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000 const isServeMode = process.argv.includes('--serve') +const desktopActivationGate = createServeDesktopActivationGate({ + initialState: isServeMode ? 'initializing' : 'ready', + activateWindow: () => { + // Why: an updater replacement must not resurrect the old app bundle. + if (!isQuittingForUpdate()) { + focusExistingWindow() + } + }, + onBlocked: (reason) => console.error(`[serve] Desktop activation blocked: ${reason}`) +}) // Why: on Windows a CLI-shaped launch (Orca.exe ) that lost // ELECTRON_RUN_AS_NODE would otherwise boot the GUI, lose the single-instance // lock to a running window, and exit silently. Redirect it to node mode here, @@ -320,6 +346,7 @@ function maybeAutoRenameBranchOnFirstWorkFromHook(event: { return !!meta?.orcaCreationSource && meta.preserveBranchOnDelete !== true }, setDisplayName: (worktreeId, displayName) => { + rememberBranchRenameFailureOutput(worktreeId, null) const scope = parseWorkspaceKey(worktreeId) if (scope?.type === 'folder') { currentStore.updateFolderWorkspace(scope.folderWorkspaceId, { @@ -351,7 +378,10 @@ function maybeAutoRenameBranchOnFirstWorkFromHook(event: { moveWorktree }) : undefined, - setRenameError: (worktreeId, error) => { + setRenameError: (worktreeId, error, failureOutput) => { + // Refresh the local-only full-output capture before the dedupe below: + // a repeat of the same error string still comes from a fresh run. + rememberBranchRenameFailureOutput(worktreeId, error === null ? null : failureOutput) // Skip the write + renderer push when nothing changes — benign skips // clear the error on every settled worktree, most of which never had one. const scope = parseWorkspaceKey(worktreeId) @@ -419,7 +449,7 @@ if (app.isPackaged && process.platform !== 'win32') { configureDevUserDataPath(is.dev) configureOrcaUserDataPathEnv() -// Why: just past createMainWindow's win32 10s ready-to-show reveal fallback, +// Why: just past createMainWindow's 10s ready-to-show reveal fallback, // so a window revealed on that path still gets its tray icon. const TRAY_CREATE_FALLBACK_MS = 12_000 @@ -448,6 +478,23 @@ function focusExistingWindow(): void { }) } +function requestDesktopActivation(): void { + desktopActivationGate.requestActivation() +} + +function getDesktopWindowStatus(): RuntimeDesktopWindowStatus { + const state = desktopActivationGate.getState() + return state === 'ready' ? 'openable' : state +} + +function settleServeDesktopActivation(): void { + if (getLocalPtyProvider() instanceof LocalPtyProvider) { + desktopActivationGate.markBlocked('persistent PTY provider unavailable') + return + } + desktopActivationGate.markReady() +} + // Why: a webContents-scoped flag that auto-expires so an intent set for one renderer // can't leak to a later load. `consume` clears on a positive match for one-shot // signals (the recovery reload fires exactly one did-finish-load). @@ -538,22 +585,25 @@ const bypassSingleInstanceLock = shouldBypassSingleInstanceLock({ isDev: is.dev, isServeMode }) +const skipSingleInstanceLock = shouldSkipSingleInstanceLock({ + isDev: is.dev, + isServeMode +}) if (bypassSingleInstanceLock) { // Why: this is an explicit diagnostic escape hatch for macOS builds where // Electron reports a false lock loss before any normal app logs exist. logSingleInstanceLockBypass() } -const hasSingleInstanceLock = - is.dev && !isServeMode +const hasSingleInstanceLock = skipSingleInstanceLock + ? true + : bypassSingleInstanceLock ? true - : bypassSingleInstanceLock - ? true - : acquireSingleInstanceLock(app, focusExistingWindow) + : acquireSingleInstanceLock(app, requestDesktopActivation) if (startupDiagnosticsEnabled) { logStartupDiagnostic('single-instance-lock-result', { acquired: hasSingleInstanceLock, bypassed: bypassSingleInstanceLock, - skippedForDev: is.dev && !isServeMode + skippedForDev: skipSingleInstanceLock }) } if (!hasSingleInstanceLock) { @@ -605,7 +655,9 @@ if (hasSingleInstanceLock) { } ipcMain.handle('app:awaitFirstWindowStartupServices', async () => { - await firstWindowStartupServicesReady + // Why: window rendering and local RPC startup stay independent, but restored + // WSL terminals get a bounded chance to receive launcher repairs first. + await Promise.all([firstWindowStartupServicesReady, managedWslCliStartupBarrierReady]) }) ipcMain.handle( @@ -618,12 +670,11 @@ ipcMain.handle( } ) -function startDesktopFirstWindowStartupServices(): Promise { +function startTerminalRuntimeStartupServices(): Promise { logStartupMilestone('first-window-startup-services-start') const startupServices = startFirstWindowStartupServices({ - // Why: the persistent-terminal daemon is desktop-only. Headless `orca serve` - // registers its PTY runtime separately and must not spawn the desktop daemon - // or hook loopback listener. + // Why: desktop and headless serve must adopt the same persistent provider + // before either path is allowed to create terminals or a renderer. startDaemonPtyProvider: async (signal) => { logStartupMilestone('startup-service-start', { service: 'daemon-pty-provider' }) await initDaemonPtyProvider(signal) @@ -632,6 +683,9 @@ function startDesktopFirstWindowStartupServices(): Promise { // Why: PTY spawn env reads ORCA_AGENT_HOOK_* from the live server state, so // the renderer awaits this barrier before restored terminals reconnect. startAgentHookServer: async () => { + if (!isAgentStatusHooksEnabled(store?.getSettings())) { + return + } logStartupMilestone('startup-service-start', { service: 'agent-hook-server' }) await agentHookServer.start({ env: app.isPackaged ? 'production' : 'development', @@ -672,23 +726,6 @@ function startDesktopFirstWindowStartupServices(): Promise { return firstWindowStartupServicesReady } -async function startServeAgentHookServer(): Promise { - if (!isAgentStatusHooksEnabled(store?.getSettings())) { - return - } - try { - await agentHookServer.start({ - env: app.isPackaged ? 'production' : 'development', - userDataPath: app.getPath('userData'), - endpointNamespace: devAgentHookEndpointNamespace - }) - } catch (error) { - // Why: remote hook callbacks enrich agent status only. A headless runtime - // should still serve terminals if the loopback receiver cannot bind. - console.error('[agent-hooks] Failed to start serve hook server:', error) - } -} - function prepareCodexRuntimeHomeForLaunch(target?: CodexAccountSelectionTarget): string | null { const runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target) const hookTarget = @@ -860,7 +897,7 @@ function openMainWindow(): BrowserWindow { // seconds while explorer.exe's notification area is busy (part of issue // #7225's pre-paint stall), so create it after first paint. The timer // fallback covers windows revealed without ready-to-show ever firing - // (createMainWindow's win32 10s reveal fallback) — those can still be + // (createMainWindow's 10s reveal fallback) — those can still be // hidden to the tray on close, so the icon must exist by then. let trayCreated = false const createSystemTrayDeferred = (): void => { @@ -1017,6 +1054,7 @@ function openMainWindow(): BrowserWindow { stateStartedAt, launchToken, providerSession, + promptInteractionKey, isReplay }) => { if (mainWindow?.isDestroyed()) { @@ -1036,6 +1074,7 @@ function openMainWindow(): BrowserWindow { receivedAt, stateStartedAt, ...(providerSession ? { providerSession } : {}), + ...(promptInteractionKey ? { promptInteractionKey } : {}), ...(orchestration ? { orchestration } : {}) }) recordAgentStateCrashBreadcrumb(payload.agentType ?? 'unknown', payload.state) @@ -1371,6 +1410,9 @@ async function printServeReady(options: ServeOptions): Promise { type: 'orca_server_ready', runtimeId: runtime.getRuntimeId(), endpoint, + // Why: the WSL reconciliation barrier fails open, so 'pending' warns clients + // it outlived the startup budget and a WSL PTY launch may still race a repair. + managedWslCliReconciliation: managedWslCliReconciliationStatus, pairing: pairing.available ? { url: pairing.pairingUrl, @@ -1595,6 +1637,37 @@ app.whenReady().then(async () => { electronApp.setAppUserModelId(devInstanceIdentity.appUserModelId) app.setName(devInstanceIdentity.name) + // Why: managed WSL launchers live outside the Windows app bundle, so keep + // their launcher and bridge contract synchronized across app updates. + managedWslCliReconciliationStatus = 'pending' + managedWslCliReconciliationReady = reconcileManagedWslCliRegistrations({ + isPackaged: app.isPackaged, + userDataPath: getCanonicalUserDataPath(), + appVersion: app.getVersion() + }) + .then((results) => { + for (const result of results) { + if (result.outcome === 'failed') { + console.warn( + `[wsl-cli] ${result.distro} managed registration reconciliation failed: ${result.error}` + ) + } else if (result.outcome === 'repaired') { + console.log(`[wsl-cli] Repaired managed registration in ${result.distro}.`) + } + } + managedWslCliReconciliationStatus = 'settled' + }) + .catch((error) => { + managedWslCliReconciliationStatus = 'failed' + console.warn( + '[wsl-cli] Managed registration reconciliation discovery failed:', + error instanceof Error ? error.message : String(error) + ) + }) + managedWslCliStartupBarrierReady = createWslCliReconciliationStartupBarrier( + managedWslCliReconciliationReady + ) + const activeOrcaProfile = ensureActiveOrcaProfile() store = new Store({ dataFile: activeOrcaProfile.dataFile }) logStartupMilestone('store-loaded') @@ -1740,20 +1813,14 @@ app.whenReady().then(async () => { onTerminalAgentStatus: (event) => { agentHookServer.ingestTerminalStatus(event) }, - // Why: derived title/bell/agent facts ride one batched main→renderer - // channel (terminal-side-effect-authority.md). The renderer's authority - // kill switch decides whether to consume. Headless serve never creates a - // window, so the dep is omitted entirely — the runtime then skips fact - // batch construction and the per-chunk bell walk. - ...(isServeMode - ? {} - : { - onTerminalSideEffects: (batch: TerminalSideEffectBatch) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('pty:sideEffect', batch) - } - } - }), + // Why: serve can be promoted in place, so keep the listener wired from + // startup; runtime enables desktop-only scanners only for a ready renderer. + onTerminalSideEffects: (batch: TerminalSideEffectBatch) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('pty:sideEffect', batch) + } + }, + getDesktopWindowStatus: getDesktopWindowStatus, // Why: hook-reported agent status is the same source the desktop sidebar // reads. worktree.ps pulls it at query time so mobile shows the same agents. getAgentStatusSnapshot: () => agentHookServer.getStatusSnapshot(), @@ -2044,12 +2111,20 @@ app.whenReady().then(async () => { }) registerMobileHandlers(runtimeRpc) - if (!isServeMode) { - startDesktopFirstWindowStartupServices() - } + startTerminalRuntimeStartupServices() + app.on('activate', requestDesktopActivation) if (serveOptions) { - await startServeAgentHookServer() + // Why: give managed WSL launchers a brief chance to migrate before headless + // PTYs become reachable without letting slow repairs withhold all RPC readiness. + logStartupMilestone('wsl-cli-barrier-start') + await managedWslCliStartupBarrierReady + logStartupMilestone('wsl-cli-barrier-resolved', { + reconciliation: managedWslCliReconciliationStatus + }) + // Why: headless PTYs must never start on the fallback provider and then be + // swept when an activated renderer registers desktop lifecycle handlers. + await localPtyStartupReady registerHeadlessPtyRuntime( runtime, prepareCodexRuntimeHomeForLaunch, @@ -2067,11 +2142,12 @@ app.whenReady().then(async () => { // Why: headless servers have no renderer graph publisher. Publish an // explicit empty graph so status clients see a ready server while // renderer-only operations still fail at their own window boundary. - runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) await runtimeRpc.start().catch((error) => { console.error('[runtime] Failed to start headless RPC transport:', error) throw error }) + settleServeDesktopActivation() installServeSignalHandlers() // Why: the orca CLI command is normally installed by the renderer onboarding / // Settings "Install CLI" flow via the cli:install IPC. Headless serve has no @@ -2154,15 +2230,6 @@ app.whenReady().then(async () => { triggerStartupNotificationRegistration(store) } }) - - app.on('activate', () => { - // Don't re-open a window while Squirrel's ShipIt is replacing the .app - // bundle. Without this guard the old version gets resurrected and the - // update never applies. - if (BrowserWindow.getAllWindows().length === 0 && !isQuittingForUpdate()) { - openMainWindow() - } - }) }) app.on('before-quit', () => { diff --git a/src/main/ipc/agent-hooks.test.ts b/src/main/ipc/agent-hooks.test.ts index b4ff9701854..48440713cd0 100644 --- a/src/main/ipc/agent-hooks.test.ts +++ b/src/main/ipc/agent-hooks.test.ts @@ -9,6 +9,9 @@ import { makePaneKey } from '../../shared/stable-pane-id' const dropStatusEntry = vi.fn() const dropStatusEntriesByTabPrefix = vi.fn() +const retirePaneAuthority = vi.fn() +const transferPaneAuthority = vi.fn() +const canTransferPaneAuthority = vi.fn(() => true) const getStatusSnapshot = vi.fn() const inferInterrupt = vi.fn() const clearMigrationUnsupportedPtysByTabPrefix = vi.fn() @@ -42,6 +45,9 @@ vi.mock('../agent-hooks/server', async () => { agentHookServer: { dropStatusEntry, dropStatusEntriesByTabPrefix, + retirePaneAuthority, + transferPaneAuthority, + canTransferPaneAuthority, getStatusSnapshot, inferInterrupt } @@ -100,6 +106,10 @@ vi.mock('../kimi/hook-service', () => ({ beforeEach(() => { dropStatusEntry.mockReset() dropStatusEntriesByTabPrefix.mockReset() + retirePaneAuthority.mockReset() + transferPaneAuthority.mockReset() + canTransferPaneAuthority.mockReset() + canTransferPaneAuthority.mockReturnValue(true) getStatusSnapshot.mockReset() inferInterrupt.mockReset() clearMigrationUnsupportedPtysByTabPrefix.mockReset() @@ -378,3 +388,73 @@ describe('agentStatus:dropByTabPrefix IPC', () => { expect(removeAllListeners).toHaveBeenCalledWith('agentStatus:dropByTabPrefix') }) }) + +describe('agent pane authority IPC', () => { + it('retires one validated pane authority', async () => { + const { registerAgentHookHandlers } = await import('./agent-hooks') + registerAgentHookHandlers() + + onHandlers.get('agentStatus:retirePaneAuthority')!({}, PANE_KEY) + + expect(retirePaneAuthority).toHaveBeenCalledWith(PANE_KEY) + expect(clearMigrationUnsupportedPtysForPaneKey).toHaveBeenCalledWith(PANE_KEY) + }) + + it('transfers validated pane authority with its provider PTY', async () => { + const { registerAgentHookHandlers } = await import('./agent-hooks') + registerAgentHookHandlers() + + onHandlers.get('agentStatus:transferPaneAuthority')!( + {}, + { + fromPaneKey: PANE_KEY, + toPaneKey: CHILD_PANE_KEY, + ptyId: 'pty-1' + } + ) + + expect(transferPaneAuthority).toHaveBeenCalledWith(PANE_KEY, CHILD_PANE_KEY, 'pty-1') + }) + + it('rejects malformed pane authority messages and replaces prior listeners', async () => { + const { registerAgentHookHandlers } = await import('./agent-hooks') + registerAgentHookHandlers() + + onHandlers.get('agentStatus:retirePaneAuthority')!({}, 'tab-1:0') + onHandlers.get('agentStatus:transferPaneAuthority')!( + {}, + { + fromPaneKey: PANE_KEY, + toPaneKey: 'invalid', + ptyId: '' + } + ) + + expect(retirePaneAuthority).not.toHaveBeenCalled() + expect(transferPaneAuthority).not.toHaveBeenCalled() + expect(removeAllListeners).toHaveBeenCalledWith('agentStatus:retirePaneAuthority') + expect(removeAllListeners).toHaveBeenCalledWith('agentStatus:transferPaneAuthority') + }) + + it('rejects unowned and oversized pane authority transfers', async () => { + const { registerAgentHookHandlers } = await import('./agent-hooks') + registerAgentHookHandlers() + const transfer = onHandlers.get('agentStatus:transferPaneAuthority')! + + canTransferPaneAuthority.mockReturnValue(false) + transfer({}, { fromPaneKey: PANE_KEY, toPaneKey: CHILD_PANE_KEY, ptyId: 'forged-pty' }) + transfer({}, { fromPaneKey: PANE_KEY, toPaneKey: CHILD_PANE_KEY }) + canTransferPaneAuthority.mockReturnValue(true) + transfer({}, { fromPaneKey: PANE_KEY, toPaneKey: CHILD_PANE_KEY, ptyId: 'x'.repeat(513) }) + transfer( + {}, + { + fromPaneKey: `${'x'.repeat(180)}:11111111-1111-4111-8111-111111111111`, + toPaneKey: CHILD_PANE_KEY, + ptyId: 'pty-1' + } + ) + + expect(transferPaneAuthority).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts index b1e8cb4b2cb..76b98ccb821 100644 --- a/src/main/ipc/agent-hooks.ts +++ b/src/main/ipc/agent-hooks.ts @@ -5,9 +5,7 @@ import type { MigrationUnsupportedPtyEntry } from '../../shared/agent-status-types' import type { AgentInterruptInferenceRequest } from '../../shared/agent-interrupt-intent' -import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { agentHookServer, isValidPaneKey } from '../agent-hooks/server' -import { isValidTerminalTabId } from '../../shared/terminal-tab-id' import { ampHookService } from '../amp/hook-service' import { clearMigrationUnsupportedPtysByTabPrefix, @@ -27,44 +25,26 @@ import { hermesHookService } from '../hermes/hook-service' import { devinHookService } from '../devin/hook-service' import { kimiHookService } from '../kimi/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' +import { registerAgentPaneAuthorityIpcHandlers } from './agent-pane-authority-ipc' +import { createAgentPaneAuthorityOwnership } from './agent-pane-authority-ownership' +import { + enrichAgentStatusIpcPayload, + isValidAgentStatusDropTabId, + type AgentStatusRuntimeEnrichment +} from './agent-status-ipc-boundary' -type AgentStatusRuntimeEnrichment = Pick< - OrcaRuntimeService, - 'getAgentStatusTerminalHandleForPaneKey' | 'getAgentStatusOrchestrationContextForPaneKey' -> - -const MAX_AGENT_STATUS_DROP_TAB_ID_LENGTH = 160 - -function enrichAgentStatusIpcPayload( - data: AgentStatusIpcPayload, - runtime: AgentStatusRuntimeEnrichment | undefined -): AgentStatusIpcPayload { - if (!runtime) { - return data - } - const terminalHandle = runtime.getAgentStatusTerminalHandleForPaneKey(data.paneKey) - const orchestration = runtime.getAgentStatusOrchestrationContextForPaneKey(data.paneKey) - return { - ...data, - ...(terminalHandle ? { terminalHandle } : {}), - ...(orchestration ? { orchestration } : {}) - } -} - -function isValidAgentStatusDropTabId(value: unknown): value is string { - return ( - typeof value === 'string' && - value.length <= MAX_AGENT_STATUS_DROP_TAB_ID_LENGTH && - value.trim() === value && - isValidTerminalTabId(value) - ) +type AgentHookHandlerDependencies = { + getPtyIdForPaneKey?: (paneKey: string) => string | undefined } // Why: install/remove are intentionally not exposed to the renderer. Orca // auto-installs managed hooks at app startup (see src/main/index.ts), so a // renderer-triggered remove would be silently reverted on the next launch // and mislead the user. -export function registerAgentHookHandlers(runtime?: AgentStatusRuntimeEnrichment): void { +export function registerAgentHookHandlers( + runtime?: AgentStatusRuntimeEnrichment, + dependencies: AgentHookHandlerDependencies = {} +): void { // Why: matches the defensive pattern in src/main/ipc/pty.ts so re-registration // never throws "Attempted to register a second handler..." if this function is // ever invoked more than once (e.g. the macOS app re-activation path that @@ -120,6 +100,13 @@ export function registerAgentHookHandlers(runtime?: AgentStatusRuntimeEnrichment console.warn('[agent-hooks] dropStatusEntriesByTabPrefix failed:', err) } }) + registerAgentPaneAuthorityIpcHandlers({ + ownsPty: createAgentPaneAuthorityOwnership({ + getPtyIdForPaneKey: dependencies.getPtyIdForPaneKey, + getRuntimeTerminalHandleForPaneKey: (paneKey) => + runtime?.getAgentStatusTerminalHandleForPaneKey(paneKey) + }) + }) ipcMain.handle('agentStatus:getSnapshot', (): AgentStatusIpcPayload[] => { // Why: the renderer pulls this after workspace hydration, so startup cannot // lose replayed statuses while its local store is still empty. Match the diff --git a/src/main/ipc/agent-pane-authority-ipc.ts b/src/main/ipc/agent-pane-authority-ipc.ts new file mode 100644 index 00000000000..a8956ecdbd3 --- /dev/null +++ b/src/main/ipc/agent-pane-authority-ipc.ts @@ -0,0 +1,54 @@ +import { ipcMain } from 'electron' +import { agentHookServer, isValidPaneKey } from '../agent-hooks/server' +import { clearMigrationUnsupportedPtysForPaneKey } from '../agent-hooks/migration-unsupported-pty-state' + +const MAX_PTY_ID_LENGTH = 512 + +export type AgentPaneAuthorityOwnership = { + ownsPty: (paneKey: string, ptyId: string) => boolean +} + +export function registerAgentPaneAuthorityIpcHandlers( + ownership: AgentPaneAuthorityOwnership +): void { + ipcMain.removeAllListeners('agentStatus:retirePaneAuthority') + ipcMain.removeAllListeners('agentStatus:transferPaneAuthority') + ipcMain.on('agentStatus:retirePaneAuthority', (_event, paneKey: unknown) => { + if (typeof paneKey !== 'string' || !isValidPaneKey(paneKey)) { + return + } + try { + agentHookServer.retirePaneAuthority(paneKey) + clearMigrationUnsupportedPtysForPaneKey(paneKey) + } catch (err) { + console.warn('[agent-hooks] retirePaneAuthority failed:', err) + } + }) + ipcMain.on('agentStatus:transferPaneAuthority', (_event, value: unknown) => { + if (!value || typeof value !== 'object') { + return + } + const args = value as Record + const ptyId = typeof args.ptyId === 'string' ? args.ptyId : undefined + if ( + typeof args.fromPaneKey !== 'string' || + typeof args.toPaneKey !== 'string' || + !isValidPaneKey(args.fromPaneKey) || + !isValidPaneKey(args.toPaneKey) || + args.fromPaneKey === args.toPaneKey || + (args.ptyId !== undefined && + (typeof args.ptyId !== 'string' || + args.ptyId.length > MAX_PTY_ID_LENGTH || + args.ptyId.trim() !== args.ptyId || + args.ptyId.length === 0)) || + !agentHookServer.canTransferPaneAuthority(args.fromPaneKey, ptyId, ownership.ownsPty) + ) { + return + } + try { + agentHookServer.transferPaneAuthority(args.fromPaneKey, args.toPaneKey, ptyId) + } catch (err) { + console.warn('[agent-hooks] transferPaneAuthority failed:', err) + } + }) +} diff --git a/src/main/ipc/agent-pane-authority-ownership.test.ts b/src/main/ipc/agent-pane-authority-ownership.test.ts new file mode 100644 index 00000000000..2b5c9c016c8 --- /dev/null +++ b/src/main/ipc/agent-pane-authority-ownership.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from 'vitest' +import { createAgentPaneAuthorityOwnership } from './agent-pane-authority-ownership' + +describe('agent pane authority ownership', () => { + it('accepts only the PTY bound to the physical local pane', () => { + const ownsPty = createAgentPaneAuthorityOwnership({ + getPtyIdForPaneKey: (paneKey) => (paneKey === 'source-pane' ? 'pty-1' : undefined) + }) + + expect(ownsPty('source-pane', 'pty-1')).toBe(true) + expect(ownsPty('source-pane', 'pty-forged')).toBe(false) + expect(ownsPty('other-pane', 'pty-1')).toBe(false) + }) + + it('matches scoped and legacy runtime IDs to the authoritative terminal handle', () => { + const getRuntimeTerminalHandleForPaneKey = vi.fn(() => 'terminal:one') + const ownsPty = createAgentPaneAuthorityOwnership({ + getRuntimeTerminalHandleForPaneKey + }) + + expect(ownsPty('source-pane', 'remote:terminal:one')).toBe(true) + expect(ownsPty('source-pane', 'remote:env-1@@terminal%3Aone')).toBe(true) + expect(ownsPty('source-pane', 'remote:env-1@@other')).toBe(false) + expect(ownsPty('source-pane', 'remote:env-1@@%E0%A4%A')).toBe(false) + expect(ownsPty('source-pane', 'remote:@@terminal%3Aone')).toBe(false) + expect(ownsPty('source-pane', 'remote:%20@@terminal%3Aone')).toBe(false) + expect(ownsPty('source-pane', 'remote:env-1@@')).toBe(false) + expect(ownsPty('source-pane', 'remote:env-1@@terminal:one')).toBe(false) + expect(ownsPty('source-pane', 'remote:terminal%3Aone')).toBe(false) + }) +}) diff --git a/src/main/ipc/agent-pane-authority-ownership.ts b/src/main/ipc/agent-pane-authority-ownership.ts new file mode 100644 index 00000000000..266bb19663a --- /dev/null +++ b/src/main/ipc/agent-pane-authority-ownership.ts @@ -0,0 +1,44 @@ +export type AgentPaneAuthorityOwnershipSources = { + getPtyIdForPaneKey?: (paneKey: string) => string | undefined + getRuntimeTerminalHandleForPaneKey?: (paneKey: string) => string | undefined +} + +function getRemoteRuntimeHandle(ptyId: string): string | null { + if (!ptyId.startsWith('remote:')) { + return null + } + const rest = ptyId.slice(7) + const separatorIndex = rest.indexOf('@@') + if (separatorIndex === -1) { + return rest.length > 0 && rest.trim() === rest ? rest : null + } + const encodedOwner = rest.slice(0, separatorIndex) + const encodedHandle = rest.slice(separatorIndex + 2) + if (!encodedOwner || !encodedHandle) { + return null + } + try { + const owner = decodeURIComponent(encodedOwner) + const handle = decodeURIComponent(encodedHandle) + if (!owner || owner.trim() !== owner || !handle || handle.trim() !== handle) { + return null + } + return `remote:${encodeURIComponent(owner)}@@${encodeURIComponent(handle)}` === ptyId + ? handle + : null + } catch { + return null + } +} + +export function createAgentPaneAuthorityOwnership( + sources: AgentPaneAuthorityOwnershipSources +): (paneKey: string, ptyId: string) => boolean { + return (paneKey, ptyId) => { + if (sources.getPtyIdForPaneKey?.(paneKey) === ptyId) { + return true + } + const runtimeHandle = sources.getRuntimeTerminalHandleForPaneKey?.(paneKey) + return Boolean(runtimeHandle && getRemoteRuntimeHandle(ptyId) === runtimeHandle) + } +} diff --git a/src/main/ipc/agent-status-ipc-boundary.ts b/src/main/ipc/agent-status-ipc-boundary.ts new file mode 100644 index 00000000000..cec23d681f6 --- /dev/null +++ b/src/main/ipc/agent-status-ipc-boundary.ts @@ -0,0 +1,35 @@ +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' +import { isValidTerminalTabId } from '../../shared/terminal-tab-id' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' + +export type AgentStatusRuntimeEnrichment = Pick< + OrcaRuntimeService, + 'getAgentStatusTerminalHandleForPaneKey' | 'getAgentStatusOrchestrationContextForPaneKey' +> + +const MAX_AGENT_STATUS_DROP_TAB_ID_LENGTH = 160 + +export function enrichAgentStatusIpcPayload( + data: AgentStatusIpcPayload, + runtime: AgentStatusRuntimeEnrichment | undefined +): AgentStatusIpcPayload { + if (!runtime) { + return data + } + const terminalHandle = runtime.getAgentStatusTerminalHandleForPaneKey(data.paneKey) + const orchestration = runtime.getAgentStatusOrchestrationContextForPaneKey(data.paneKey) + return { + ...data, + ...(terminalHandle ? { terminalHandle } : {}), + ...(orchestration ? { orchestration } : {}) + } +} + +export function isValidAgentStatusDropTabId(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length <= MAX_AGENT_STATUS_DROP_TAB_ID_LENGTH && + value.trim() === value && + isValidTerminalTabId(value) + ) +} diff --git a/src/main/ipc/bounded-warning-dedupe.test.ts b/src/main/ipc/bounded-warning-dedupe.test.ts index 787d8a96539..aa8cd623ad8 100644 --- a/src/main/ipc/bounded-warning-dedupe.test.ts +++ b/src/main/ipc/bounded-warning-dedupe.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - DEFAULT_WARNING_DEDUPE_MAX_KEYS, - shouldEmitBoundedWarning -} from './bounded-warning-dedupe' +import { DEFAULT_WARNING_DEDUPE_MAX_KEYS, shouldEmitBoundedWarning } from './bounded-warning-dedupe' describe('shouldEmitBoundedWarning', () => { it('keeps retained warning keys quiet without cascade eviction after saturation', () => { @@ -29,8 +26,6 @@ describe('shouldEmitBoundedWarning', () => { ) expect(keys.filter((key) => shouldEmitBoundedWarning(warningKeys, key))).toEqual(keys) - expect(keys.filter((key) => shouldEmitBoundedWarning(warningKeys, key))).toEqual([ - keys.at(-1) - ]) + expect(keys.filter((key) => shouldEmitBoundedWarning(warningKeys, key))).toEqual([keys.at(-1)]) }) }) diff --git a/src/main/ipc/cli.test.ts b/src/main/ipc/cli.test.ts new file mode 100644 index 00000000000..eeb98539751 --- /dev/null +++ b/src/main/ipc/cli.test.ts @@ -0,0 +1,148 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + handlers, + ipcHandleMock, + wslInstallerMock, + recordInstalledMock, + recordRemovedMock, + getDefaultWslDistroMock +} = vi.hoisted(() => ({ + handlers: new Map unknown>(), + ipcHandleMock: vi.fn(), + wslInstallerMock: vi.fn(), + recordInstalledMock: vi.fn(), + recordRemovedMock: vi.fn(), + getDefaultWslDistroMock: vi.fn() +})) + +vi.mock('electron', () => ({ ipcMain: { handle: ipcHandleMock } })) +vi.mock('../cli/cli-installer', () => ({ CliInstaller: vi.fn() })) +vi.mock('../cli/wsl-cli-installer', () => ({ WslCliInstaller: wslInstallerMock })) +vi.mock('../cli/wsl-cli-registration-registry', () => ({ + recordWslCliRegistrationInstalled: recordInstalledMock, + recordWslCliRegistrationRemoved: recordRemovedMock +})) +vi.mock('../persistence', () => ({ getCanonicalUserDataPath: () => '/canonical-user-data' })) +vi.mock('../startup/hydrate-shell-path', () => ({ + hydrateShellPath: vi.fn(async () => ({ ok: false })), + mergePathSegments: vi.fn() +})) +vi.mock('../wsl', () => ({ getDefaultWslDistro: getDefaultWslDistroMock })) + +import { registerCliHandlers } from './cli' + +type WslHandler = (event: unknown, args?: { distro?: string | null }) => Promise<{ state: string }> + +function getWslHandler(channel: string): WslHandler { + const handler = handlers.get(channel) + if (!handler) { + throw new Error(`Missing IPC handler: ${channel}`) + } + return handler as WslHandler +} + +describe('WSL CLI registration IPC', () => { + beforeEach(() => { + handlers.clear() + ipcHandleMock.mockReset() + ipcHandleMock.mockImplementation( + (channel: string, handler: (...args: unknown[]) => unknown) => { + handlers.set(channel, handler) + } + ) + wslInstallerMock.mockReset() + recordInstalledMock.mockReset().mockResolvedValue(undefined) + recordRemovedMock.mockReset().mockResolvedValue(undefined) + getDefaultWslDistroMock.mockReset().mockReturnValue('Ubuntu') + registerCliHandlers() + }) + + it('records a successful explicit-distro installation', async () => { + const install = vi.fn(async () => ({ state: 'installed' })) + wslInstallerMock.mockImplementation(function MockWslCliInstaller() { + return { install } + }) + + await expect(getWslHandler('cli:installWsl')({}, { distro: ' Debian ' })).resolves.toEqual({ + state: 'installed' + }) + expect(wslInstallerMock).toHaveBeenCalledWith({ distro: 'Debian' }) + expect(recordInstalledMock).toHaveBeenCalledWith('/canonical-user-data', 'Debian') + }) + + it('records removal from the resolved default distro', async () => { + const remove = vi.fn(async () => ({ state: 'not_installed' })) + wslInstallerMock.mockImplementation(function MockWslCliInstaller() { + return { remove } + }) + + await expect(getWslHandler('cli:removeWsl')({})).resolves.toEqual({ state: 'not_installed' }) + expect(wslInstallerMock).toHaveBeenCalledWith({ distro: 'Ubuntu' }) + expect(recordRemovedMock).toHaveBeenCalledWith('/canonical-user-data', 'Ubuntu') + }) + + it('does not claim ownership when installation is not confirmed', async () => { + const install = vi.fn(async () => ({ state: 'unsupported' })) + wslInstallerMock.mockImplementation(function MockWslCliInstaller() { + return { install } + }) + + await expect(getWslHandler('cli:installWsl')({})).resolves.toEqual({ state: 'unsupported' }) + expect(recordInstalledMock).not.toHaveBeenCalled() + }) + + it('keeps successful installation successful when advisory registry persistence fails', async () => { + const install = vi.fn(async () => ({ state: 'installed' })) + wslInstallerMock.mockImplementation(function MockWslCliInstaller() { + return { install } + }) + recordInstalledMock.mockRejectedValueOnce(new Error('ENOSPC')) + + await expect(getWslHandler('cli:installWsl')({})).resolves.toEqual({ state: 'installed' }) + }) + + it('keeps successful removal successful when advisory registry persistence fails', async () => { + const remove = vi.fn(async () => ({ state: 'not_installed' })) + wslInstallerMock.mockImplementation(function MockWslCliInstaller() { + return { remove } + }) + recordRemovedMock.mockRejectedValueOnce(new Error('EACCES')) + + await expect(getWslHandler('cli:removeWsl')({})).resolves.toEqual({ state: 'not_installed' }) + }) + + it('serializes a concurrent removal after installation and ownership persistence', async () => { + let finishInstall!: () => void + const install = vi.fn( + () => + new Promise<{ state: 'installed' }>((resolve) => { + finishInstall = () => resolve({ state: 'installed' }) + }) + ) + const remove = vi.fn(async () => ({ state: 'not_installed' as const })) + wslInstallerMock + .mockImplementationOnce(function MockInstallWslCliInstaller() { + return { install } + }) + .mockImplementationOnce(function MockRemoveWslCliInstaller() { + return { remove } + }) + + const installation = getWslHandler('cli:installWsl')({}, { distro: 'Ubuntu' }) + await vi.waitFor(() => expect(install).toHaveBeenCalledOnce()) + const removal = getWslHandler('cli:removeWsl')({}, { distro: 'ubuntu' }) + await Promise.resolve() + expect(remove).not.toHaveBeenCalled() + + finishInstall() + await expect(installation).resolves.toEqual({ state: 'installed' }) + await expect(removal).resolves.toEqual({ state: 'not_installed' }) + expect(recordInstalledMock.mock.invocationCallOrder[0]).toBeLessThan( + remove.mock.invocationCallOrder[0] + ) + expect(remove.mock.invocationCallOrder[0]).toBeLessThan( + recordRemovedMock.mock.invocationCallOrder[0] + ) + }) +}) diff --git a/src/main/ipc/cli.ts b/src/main/ipc/cli.ts index b0b4f4c6311..659c69d9c02 100644 --- a/src/main/ipc/cli.ts +++ b/src/main/ipc/cli.ts @@ -1,13 +1,49 @@ import { ipcMain } from 'electron' import type { CliInstallStatus } from '../../shared/cli-install-types' import { CliInstaller } from '../cli/cli-installer' +import { + recordWslCliRegistrationInstalled, + recordWslCliRegistrationRemoved +} from '../cli/wsl-cli-registration-registry' import { WslCliInstaller } from '../cli/wsl-cli-installer' +import { runSerializedWslCliRegistrationOperation } from '../cli/wsl-cli-registration-operation' +import { getCanonicalUserDataPath } from '../persistence' import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path' +import { getDefaultWslDistro } from '../wsl' function normalizeWslCliDistro(args?: { distro?: string | null }): string | undefined { return args?.distro?.trim() || undefined } +function resolveWslCliDistro(args?: { distro?: string | null }): string | null { + return normalizeWslCliDistro(args) ?? getDefaultWslDistro() +} + +function runWslCliRegistrationOperation( + distro: string | null, + operation: () => Promise +): Promise { + return distro ? runSerializedWslCliRegistrationOperation(distro, operation) : operation() +} + +async function persistWslCliRegistration( + operation: () => Promise, + action: 'install' | 'remove' +): Promise { + try { + await operation() + } catch (error) { + // Why: the WSL file operation already succeeded; advisory metadata must + // not turn that success into a false Settings failure. The atomic write + // left the prior registry intact, and repair is disk-authoritative, so a + // stale entry self-corrects on the next startup probe. + console.warn( + `[wsl-cli] Failed to persist ${action} registration metadata:`, + error instanceof Error ? error.message : String(error) + ) + } +} + async function hydrateLocalShellPathForCli(force = false): Promise { if (process.platform === 'win32') { return @@ -39,21 +75,44 @@ export function registerCliHandlers(): void { ipcMain.handle( 'cli:getWslInstallStatus', async (_event, args?: { distro?: string | null }): Promise => { - return new WslCliInstaller({ distro: normalizeWslCliDistro(args) }).getStatus() + // Why: status is a read-only probe; queuing it behind a long-running + // repair/install would hang the Settings spinner for its duration, and + // Settings re-polls, so a rare transient read self-corrects. + return new WslCliInstaller({ distro: resolveWslCliDistro(args) }).getStatus() } ) ipcMain.handle( 'cli:installWsl', async (_event, args?: { distro?: string | null }): Promise => { - return new WslCliInstaller({ distro: normalizeWslCliDistro(args) }).install() + const distro = resolveWslCliDistro(args) + return runWslCliRegistrationOperation(distro, async () => { + const status = await new WslCliInstaller({ distro }).install() + if (distro && status.state === 'installed') { + await persistWslCliRegistration( + () => recordWslCliRegistrationInstalled(getCanonicalUserDataPath(), distro), + 'install' + ) + } + return status + }) } ) ipcMain.handle( 'cli:removeWsl', async (_event, args?: { distro?: string | null }): Promise => { - return new WslCliInstaller({ distro: normalizeWslCliDistro(args) }).remove() + const distro = resolveWslCliDistro(args) + return runWslCliRegistrationOperation(distro, async () => { + const status = await new WslCliInstaller({ distro }).remove() + if (distro && status.state === 'not_installed') { + await persistWslCliRegistration( + () => recordWslCliRegistrationRemoved(getCanonicalUserDataPath(), distro), + 'remove' + ) + } + return status + }) } ) } diff --git a/src/main/ipc/filesystem-import-ssh-directory.ts b/src/main/ipc/filesystem-import-ssh-directory.ts new file mode 100644 index 00000000000..bb8abd027f4 --- /dev/null +++ b/src/main/ipc/filesystem-import-ssh-directory.ts @@ -0,0 +1,102 @@ +import { lstat, readdir, realpath } from 'node:fs/promises' +import { isAbsolute, join, relative, sep } from 'node:path' +import type { FileUploadSession, IFilesystemProvider } from '../providers/types' +import { assertSafeRemotePathSegment, type RemotePathFlavor } from '../ssh/ssh-remote-platform' + +export async function captureLocalUploadRoot( + sourcePath: string, + sourceStat: Awaited> +): Promise { + const rootRealPath = await realpath(sourcePath) + const rootRealStat = await lstat(rootRealPath) + if ( + statIdentityPartChanged(sourceStat.ino, rootRealStat.ino) || + statIdentityPartChanged(sourceStat.dev, rootRealStat.dev) || + !rootRealStat.isDirectory() + ) { + throw new Error(`Upload source changed while being inspected: ${sourcePath}`) + } + return rootRealPath +} + +export async function preScanSshImportDirectory( + dirPath: string, + remotePathFlavor: RemotePathFlavor +): Promise { + const entries = await readdir(dirPath, { withFileTypes: true }) + for (const entry of entries) { + assertSafeRemotePathSegment(entry.name, remotePathFlavor) + if (entry.isSymbolicLink()) { + return true + } + if (entry.isDirectory()) { + const childPath = join(dirPath, entry.name) + if (await preScanSshImportDirectory(childPath, remotePathFlavor)) { + return true + } + } + } + return false +} + +export async function uploadSshImportDirectory( + provider: IFilesystemProvider, + uploadSession: FileUploadSession, + localDir: string, + remoteDir: string, + rootRealPath: string, + remotePathFlavor: RemotePathFlavor +): Promise { + await assertLocalUploadPathInsideRoot(rootRealPath, localDir) + const entries = await readdir(localDir, { withFileTypes: true }) + for (const entry of entries) { + assertSafeRemotePathSegment(entry.name, remotePathFlavor) + const localPath = join(localDir, entry.name) + const remotePath = `${remoteDir}/${entry.name}` + await assertLocalUploadPathInsideRoot(rootRealPath, localPath) + const statResult = await lstat(localPath) + + // Why: skip symlinks and special files even after the up-front pre-scan; + // this closes the TOCTOU gap if one is created during upload. + if (statResult.isSymbolicLink() || (!statResult.isFile() && !statResult.isDirectory())) { + continue + } + + if (statResult.isDirectory()) { + await provider.createDirNoClobber(remotePath) + await uploadSshImportDirectory( + provider, + uploadSession, + localPath, + remotePath, + rootRealPath, + remotePathFlavor + ) + continue + } + await uploadSession.uploadFile(localPath, remotePath, { exclusive: true }) + } +} + +function statIdentityPartChanged( + left: number | bigint | undefined, + right: number | bigint | undefined +): boolean { + const leftKnown = left !== undefined && left !== 0 && left !== 0n + const rightKnown = right !== undefined && right !== 0 && right !== 0n + return leftKnown && rightKnown && left !== right +} + +async function assertLocalUploadPathInsideRoot( + rootRealPath: string, + candidatePath: string +): Promise { + const candidateRealPath = await realpath(candidatePath) + const relativeToRoot = relative(rootRealPath, candidateRealPath) + if ( + relativeToRoot !== '' && + (relativeToRoot === '..' || relativeToRoot.startsWith(`..${sep}`) || isAbsolute(relativeToRoot)) + ) { + throw new Error(`Upload source escapes selected directory: ${candidatePath}`) + } +} diff --git a/src/main/ipc/filesystem-import-ssh-ops.test.ts b/src/main/ipc/filesystem-import-ssh-ops.test.ts index 1efc9dff2d1..3046cbb620e 100644 --- a/src/main/ipc/filesystem-import-ssh-ops.test.ts +++ b/src/main/ipc/filesystem-import-ssh-ops.test.ts @@ -1,5 +1,6 @@ import path from 'node:path' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { FileUploadSession, IFilesystemProvider } from '../providers/types' const handlers = new Map Promise>() const { @@ -9,11 +10,6 @@ const { realpathMock, copyFileMock, readdirMock, - sftpExistsMock, - uploadFileMock, - uploadDirMock, - removeDirectorySftpMock, - mkdirSftpMock, getConnMgrMock } = vi.hoisted(() => ({ handleMock: vi.fn(), @@ -22,11 +18,6 @@ const { realpathMock: vi.fn(), copyFileMock: vi.fn(), readdirMock: vi.fn(), - sftpExistsMock: vi.fn(), - uploadFileMock: vi.fn(), - uploadDirMock: vi.fn(), - removeDirectorySftpMock: vi.fn(), - mkdirSftpMock: vi.fn(), getConnMgrMock: vi.fn() })) @@ -40,16 +31,13 @@ vi.mock('fs/promises', () => ({ copyFile: copyFileMock, readdir: readdirMock })) -vi.mock('../ssh/sftp-upload', () => ({ - sftpPathExists: sftpExistsMock, - uploadFile: uploadFileMock, - uploadDirectory: uploadDirMock, - removeDirectorySftp: removeDirectorySftpMock, - mkdirSftp: mkdirSftpMock -})) vi.mock('./ssh', () => ({ getSshConnectionManager: getConnMgrMock })) import { registerFilesystemMutationHandlers } from './filesystem-mutations' +import { + registerSshFilesystemProvider, + unregisterSshFilesystemProvider +} from '../providers/ssh-filesystem-dispatch' const store = { getRepos: () => [ @@ -65,13 +53,38 @@ const store = { } const enoent = (): Error => Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) +function createProvider(uploadSession: FileUploadSession): IFilesystemProvider { + return { + readDir: vi.fn(), + readFile: vi.fn(), + downloadFile: vi.fn(), + openFileUploadSession: vi.fn().mockResolvedValue(uploadSession), + writeFile: vi.fn().mockResolvedValue(undefined), + writeFileBase64: vi.fn(), + writeFileBase64Chunk: vi.fn().mockResolvedValue(undefined), + stat: vi.fn().mockRejectedValue(enoent()), + deletePath: vi.fn().mockResolvedValue(undefined), + createFile: vi.fn(), + createDir: vi.fn().mockResolvedValue(undefined), + createDirNoClobber: vi.fn().mockResolvedValue(undefined), + rename: vi.fn(), + renameNoClobber: vi.fn(), + copy: vi.fn(), + realpath: vi.fn(), + search: vi.fn(), + listFiles: vi.fn(), + watch: vi.fn() + } as unknown as IFilesystemProvider +} + describe('fs:importExternalPaths — SSH operations', () => { const destDir = '/home/user/project/src' const connId = 'ssh-conn-1' - const mockSftp = { end: vi.fn() } + let provider: IFilesystemProvider + let uploadSession: FileUploadSession const makeConn = () => ({ getState: () => ({ status: 'connected' }), - sftp: vi.fn().mockResolvedValue(mockSftp) + sftp: vi.fn() }) const mockDir = (p: string): void => { const rp = path.resolve(p) @@ -82,6 +95,22 @@ describe('fs:importExternalPaths — SSH operations', () => { throw enoent() }) } + const mockFile = (p: string): void => { + const rp = path.resolve(p) + lstatMock.mockImplementation(async (x: string) => { + if (x === rp) { + return { + size: 12, + ino: 1, + dev: 1, + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false + } + } + throw enoent() + }) + } const invoke = (args: Record) => handlers.get('fs:importExternalPaths')!(null, args) as Promise<{ results: Record[] @@ -96,37 +125,36 @@ describe('fs:importExternalPaths — SSH operations', () => { realpathMock, copyFileMock, readdirMock, - sftpExistsMock, - uploadFileMock, - uploadDirMock, - removeDirectorySftpMock, - mkdirSftpMock, getConnMgrMock ].forEach((m) => m.mockReset()) - mockSftp.end.mockReset() handleMock.mockImplementation((ch: string, h: never) => { handlers.set(ch, h) }) realpathMock.mockImplementation(async (p: string) => p) lstatMock.mockRejectedValue(enoent()) - sftpExistsMock.mockResolvedValue(false) - uploadFileMock.mockResolvedValue(undefined) - uploadDirMock.mockResolvedValue(undefined) - removeDirectorySftpMock.mockResolvedValue(undefined) - mkdirSftpMock.mockResolvedValue(undefined) + readdirMock.mockResolvedValue([]) getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() }) + uploadSession = { + uploadFile: vi.fn().mockResolvedValue(undefined), + close: vi.fn() + } + provider = createProvider(uploadSession) + registerSshFilesystemProvider(connId, provider) registerFilesystemMutationHandlers(store as never) }) - it('deconflicts file names via SFTP lstat', async () => { - const rp = path.resolve('/tmp/dropped/logo.png') - lstatMock.mockImplementation(async (x: string) => { - if (x === rp) { - return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false } + afterEach(() => { + unregisterSshFilesystemProvider(connId) + }) + + it('deconflicts file names via provider stat', async () => { + mockFile('/tmp/dropped/logo.png') + vi.mocked(provider.stat).mockImplementation(async (p: string) => { + if (p === `${destDir}/logo.png`) { + return { type: 'file', size: 1, mtime: 1 } } throw enoent() }) - sftpExistsMock.mockImplementation(async (_s: unknown, p: string) => p === `${destDir}/logo.png`) const { results } = await invoke({ sourcePaths: ['/tmp/dropped/logo.png'], destDir, @@ -157,66 +185,112 @@ describe('fs:importExternalPaths — SSH operations', () => { it('handles partial failure with correct per-item results', async () => { const sources = ['/tmp/dropped/good.txt', '/tmp/dropped/bad.txt', '/tmp/dropped/ok.txt'] + const resolved = new Set(sources.map((s) => path.resolve(s))) lstatMock.mockImplementation(async (p: string) => { - if (sources.map((s) => path.resolve(s)).includes(p)) { - return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false } + if (resolved.has(p)) { + return { + size: 12, + ino: 1, + dev: 1, + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false + } } throw enoent() }) - uploadFileMock.mockImplementation(async (_s: unknown, lp: string) => { - if (lp === path.resolve('/tmp/dropped/bad.txt')) { + vi.mocked(uploadSession.uploadFile).mockImplementation(async (_localPath, remotePath) => { + if (remotePath.endsWith('/bad.txt')) { throw new Error('permission denied') } }) + const { results } = await invoke({ sourcePaths: sources, destDir, connectionId: connId }) expect(results).toHaveLength(3) expect(results[0]).toMatchObject({ status: 'imported' }) expect(results[1]).toMatchObject({ status: 'failed', reason: 'permission denied' }) expect(results[2]).toMatchObject({ status: 'imported' }) + expect(provider.deletePath).not.toHaveBeenCalled() }) - it('uploads directories via mkdirSftp + uploadDirectory', async () => { - mockDir('/tmp/dropped/assets') - readdirMock.mockResolvedValue([]) + it('does not delete a file another client created during an exclusive-upload race', async () => { + mockFile('/tmp/dropped/report.txt') + vi.mocked(uploadSession.uploadFile).mockRejectedValue( + Object.assign(new Error('EEXIST: destination already exists'), { code: 'EEXIST' }) + ) + + const { results } = await invoke({ + sourcePaths: ['/tmp/dropped/report.txt'], + destDir, + connectionId: connId + }) + + expect(results[0]).toMatchObject({ + status: 'failed', + reason: 'EEXIST: destination already exists' + }) + // Why: a failed exclusive create does not prove Orca owns the destination. + expect(provider.deletePath).not.toHaveBeenCalled() + }) + + it('uploads directories via provider createDirNoClobber and binary writes', async () => { + const root = path.resolve('/tmp/dropped/assets') + const child = path.join(root, 'logo.png') + lstatMock.mockImplementation(async (p: string) => { + if (p === root) { + return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false } + } + if (p === child) { + return { + size: 3, + ino: 1, + dev: 1, + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false + } + } + throw enoent() + }) + readdirMock.mockImplementation(async (p: string) => + p === root + ? [ + { + name: 'logo.png', + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false + } + ] + : [] + ) const { results } = await invoke({ sourcePaths: ['/tmp/dropped/assets'], destDir, connectionId: connId }) + expect(results[0]).toMatchObject({ status: 'imported', kind: 'directory' }) - expect(mkdirSftpMock).toHaveBeenCalledWith(mockSftp, `${destDir}/assets`, { - allowExisting: false + expect(provider.createDirNoClobber).toHaveBeenCalledWith(`${destDir}/assets`) + expect(uploadSession.uploadFile).toHaveBeenCalledWith(child, `${destDir}/assets/logo.png`, { + exclusive: true }) - expect(uploadDirMock).toHaveBeenCalledWith( - mockSftp, - path.resolve('/tmp/dropped/assets'), - `${destDir}/assets`, - path.resolve('/tmp/dropped/assets'), - { exclusive: true } - ) }) it('reports per-item failure when deconfliction throws', async () => { - const rp = path.resolve('/tmp/dropped/file.txt') - lstatMock.mockImplementation(async (x: string) => { - if (x === rp) { - return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false } - } - throw enoent() - }) - sftpExistsMock.mockRejectedValue(new Error('SFTP channel closed')) + mockFile('/tmp/dropped/file.txt') + vi.mocked(provider.stat).mockRejectedValue(new Error('Remote connection not found')) const { results } = await invoke({ sourcePaths: ['/tmp/dropped/file.txt'], destDir, connectionId: connId }) - expect(results[0]).toMatchObject({ status: 'failed', reason: 'SFTP channel closed' }) + expect(results[0]).toMatchObject({ status: 'failed', reason: 'Remote connection not found' }) }) - it('reports failure when mkdirSftp rejects', async () => { + it('reports failure when creating a directory rejects', async () => { mockDir('/tmp/dropped/mydir') - readdirMock.mockResolvedValue([]) - mkdirSftpMock.mockRejectedValue(new Error('permission denied')) + vi.mocked(provider.createDirNoClobber).mockRejectedValue(new Error('permission denied')) const { results } = await invoke({ sourcePaths: ['/tmp/dropped/mydir'], destDir, @@ -225,10 +299,38 @@ describe('fs:importExternalPaths — SSH operations', () => { expect(results[0]).toMatchObject({ status: 'failed', reason: 'permission denied' }) }) - it('removes a created SSH directory import root when uploadDirectory fails', async () => { - mockDir('/tmp/dropped/assets') - readdirMock.mockResolvedValue([]) - uploadDirMock.mockRejectedValue(new Error('disk full')) + it('removes a created SSH directory import root when nested upload fails', async () => { + const root = path.resolve('/tmp/dropped/assets') + const child = path.join(root, 'logo.png') + lstatMock.mockImplementation(async (p: string) => { + if (p === root) { + return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false } + } + if (p === child) { + return { + size: 3, + ino: 1, + dev: 1, + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false + } + } + throw enoent() + }) + readdirMock.mockImplementation(async (p: string) => + p === root + ? [ + { + name: 'logo.png', + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false + } + ] + : [] + ) + vi.mocked(uploadSession.uploadFile).mockRejectedValue(new Error('disk full')) const { results } = await invoke({ sourcePaths: ['/tmp/dropped/assets'], @@ -237,16 +339,18 @@ describe('fs:importExternalPaths — SSH operations', () => { }) expect(results[0]).toMatchObject({ status: 'failed', reason: 'disk full' }) - expect(mkdirSftpMock).toHaveBeenCalledWith(mockSftp, `${destDir}/assets`, { - allowExisting: false - }) - expect(removeDirectorySftpMock).toHaveBeenCalledWith(mockSftp, `${destDir}/assets`) + expect(provider.createDirNoClobber).toHaveBeenCalledWith(`${destDir}/assets`) + expect(provider.deletePath).toHaveBeenCalledWith(`${destDir}/assets`, true) }) - it('deconflicts directory names via SFTP lstat', async () => { + it('deconflicts directory names via provider stat', async () => { mockDir('/tmp/dropped/assets') - readdirMock.mockResolvedValue([]) - sftpExistsMock.mockImplementation(async (_s: unknown, p: string) => p === `${destDir}/assets`) + vi.mocked(provider.stat).mockImplementation(async (p: string) => { + if (p === `${destDir}/assets`) { + return { type: 'directory', size: 1, mtime: 1 } + } + throw enoent() + }) const { results } = await invoke({ sourcePaths: ['/tmp/dropped/assets'], destDir, @@ -281,7 +385,7 @@ describe('fs:importExternalPaths — SSH operations', () => { connectionId: connId }) expect(results[0]).toMatchObject({ status: 'skipped', reason: 'symlink' }) - expect(uploadDirMock).not.toHaveBeenCalled() + expect(uploadSession.uploadFile).not.toHaveBeenCalled() }) it('reports skipped when source lstat returns EACCES', async () => { diff --git a/src/main/ipc/filesystem-import-ssh-path-safety.test.ts b/src/main/ipc/filesystem-import-ssh-path-safety.test.ts new file mode 100644 index 00000000000..ec01175ad35 --- /dev/null +++ b/src/main/ipc/filesystem-import-ssh-path-safety.test.ts @@ -0,0 +1,274 @@ +import path from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { FileUploadSession, IFilesystemProvider } from '../providers/types' + +const { getConnMgrMock, lstatMock, providerMock, readdirMock, realpathMock } = vi.hoisted(() => ({ + getConnMgrMock: vi.fn(), + lstatMock: vi.fn(), + providerMock: vi.fn(), + readdirMock: vi.fn(), + realpathMock: vi.fn() +})) + +vi.mock('node:fs/promises', () => ({ + lstat: lstatMock, + readdir: readdirMock, + realpath: realpathMock +})) +vi.mock('./filesystem-auth', () => ({ + authorizeExternalPath: vi.fn(), + isENOENT: (error: NodeJS.ErrnoException) => error.code === 'ENOENT' +})) +vi.mock('./ssh', () => ({ getSshConnectionManager: getConnMgrMock })) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + requireSshFilesystemProvider: providerMock +})) + +import { importExternalPathsSsh } from './filesystem-import-ssh' + +function createProvider(uploadSession: FileUploadSession): IFilesystemProvider { + const missing = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + return { + stat: vi.fn().mockRejectedValue(missing), + createDirNoClobber: vi.fn().mockResolvedValue(undefined), + deletePath: vi.fn().mockResolvedValue(undefined), + openFileUploadSession: vi.fn().mockResolvedValue(uploadSession) + } as unknown as IFilesystemProvider +} + +describe('SSH import remote path safety', () => { + const connectionId = 'ssh-windows' + const destDir = 'C:/Users/me/project/.orca/drops' + let provider: IFilesystemProvider + let uploadSession: FileUploadSession + + beforeEach(() => { + vi.clearAllMocks() + getConnMgrMock.mockReturnValue({ + getConnection: () => ({ getState: () => ({ status: 'connected' }) }) + }) + uploadSession = { + uploadFile: vi.fn().mockResolvedValue(undefined), + close: vi.fn() + } + provider = createProvider(uploadSession) + providerMock.mockReturnValue(provider) + readdirMock.mockResolvedValue([]) + realpathMock.mockImplementation(async (value: string) => value) + }) + + it.runIf(process.platform !== 'win32')( + 'rejects a POSIX filename containing Windows traversal before remote stat', + async () => { + const sourcePath = '/tmp/..\\..\\.ssh\\orca_drop' + + const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId) + + expect(results[0]).toMatchObject({ + status: 'failed', + reason: expect.stringContaining('Unsafe') + }) + expect(lstatMock).not.toHaveBeenCalled() + expect(provider.stat).not.toHaveBeenCalled() + expect(provider.createDirNoClobber).not.toHaveBeenCalled() + expect(uploadSession.uploadFile).not.toHaveBeenCalled() + expect(provider.deletePath).not.toHaveBeenCalled() + } + ) + + it.each(['report.txt:orca', 'NUL', 'trailing.', 'question?.txt'])( + 'rejects Windows-special top-level name %j before remote stat', + async (name) => { + const { results } = await importExternalPathsSsh([`/tmp/${name}`], destDir, connectionId) + + expect(results[0]).toMatchObject({ + status: 'failed', + reason: expect.stringContaining('Unsafe') + }) + expect(lstatMock).not.toHaveBeenCalled() + expect(provider.stat).not.toHaveBeenCalled() + expect(uploadSession.uploadFile).not.toHaveBeenCalled() + } + ) + + it('rejects a nested Windows traversal name before creating the import root', async () => { + const sourcePath = path.resolve('/tmp/assets') + lstatMock.mockResolvedValue({ + isFile: () => false, + isDirectory: () => true, + isSymbolicLink: () => false + }) + readdirMock.mockResolvedValue([ + { + name: '..\\..\\.ssh\\orca_drop', + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false + } + ]) + + const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId) + + expect(results[0]).toMatchObject({ + status: 'failed', + reason: expect.stringContaining('Unsafe') + }) + expect(provider.stat).not.toHaveBeenCalled() + expect(provider.createDirNoClobber).not.toHaveBeenCalled() + expect(uploadSession.uploadFile).not.toHaveBeenCalled() + expect(provider.deletePath).not.toHaveBeenCalled() + }) + + it('keeps exclusive creation for safe file names', async () => { + const sourcePath = path.resolve('/tmp/report.txt') + lstatMock.mockResolvedValue({ + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false + }) + + const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId) + + expect(results[0]).toMatchObject({ status: 'imported', destPath: `${destDir}/report.txt` }) + expect(uploadSession.uploadFile).toHaveBeenCalledWith(sourcePath, `${destDir}/report.txt`, { + exclusive: true + }) + }) + + it.runIf(process.platform !== 'win32')( + 'preserves backslashes in valid POSIX names for POSIX remotes', + async () => { + const sourcePath = path.resolve('/tmp/notes\\2026.txt') + const posixDestDir = '/home/me/project' + lstatMock.mockResolvedValue({ + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false + }) + + const { results } = await importExternalPathsSsh([sourcePath], posixDestDir, connectionId) + + expect(results[0]).toMatchObject({ + status: 'imported', + destPath: `${posixDestDir}/notes\\2026.txt` + }) + expect(uploadSession.uploadFile).toHaveBeenCalledWith( + sourcePath, + `${posixDestDir}/notes\\2026.txt`, + { exclusive: true } + ) + } + ) + + it('streams an N-file directory through guarded per-file uploads', async () => { + const sourcePath = path.resolve('/tmp/assets') + const names = ['one.txt', 'two.txt', 'three.txt'] + const entries = names.map((name) => ({ + name, + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false + })) + lstatMock.mockImplementation(async (value: string) => + value === sourcePath + ? { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false } + : { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false } + ) + readdirMock.mockResolvedValue(entries) + + const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId) + + expect(results[0]).toMatchObject({ status: 'imported', kind: 'directory' }) + expect(uploadSession.uploadFile).toHaveBeenCalledTimes(3) + for (const name of names) { + expect(uploadSession.uploadFile).toHaveBeenCalledWith( + path.join(sourcePath, name), + `${destDir}/assets/${name}`, + { exclusive: true } + ) + } + }) + + it('skips special entries without failing the directory import', async () => { + const sourcePath = path.resolve('/tmp/assets') + const regularPath = path.join(sourcePath, 'report.txt') + const socketPath = path.join(sourcePath, 'server.sock') + const entries = [ + { + name: 'report.txt', + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false + }, + { + name: 'server.sock', + isFile: () => false, + isDirectory: () => false, + isSymbolicLink: () => false + } + ] + lstatMock.mockImplementation(async (value: string) => { + if (value === sourcePath) { + return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false } + } + if (value === regularPath) { + return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false } + } + expect(value).toBe(socketPath) + return { isFile: () => false, isDirectory: () => false, isSymbolicLink: () => false } + }) + readdirMock.mockResolvedValue(entries) + + const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId) + + expect(results[0]).toMatchObject({ status: 'imported', kind: 'directory' }) + expect(uploadSession.uploadFile).toHaveBeenCalledOnce() + expect(uploadSession.uploadFile).toHaveBeenCalledWith( + regularPath, + `${destDir}/assets/report.txt`, + { exclusive: true } + ) + }) + + it('rejects a selected root replaced after its realpath is captured', async () => { + const sourcePath = path.resolve('/tmp/assets') + lstatMock.mockResolvedValue({ + isFile: () => false, + isDirectory: () => true, + isSymbolicLink: () => false + }) + realpathMock.mockResolvedValueOnce(sourcePath).mockResolvedValueOnce('/private/replacement') + + const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId) + + expect(results[0]).toMatchObject({ + status: 'failed', + reason: expect.stringContaining('escapes selected directory') + }) + expect(uploadSession.uploadFile).not.toHaveBeenCalled() + expect(provider.deletePath).toHaveBeenCalledWith(`${destDir}/assets`, true) + }) + + it('rejects a selected root replaced while its realpath is captured', async () => { + const sourcePath = path.resolve('/tmp/assets') + const directoryStat = (ino: number) => ({ + dev: 1, + ino, + isFile: () => false, + isDirectory: () => true, + isSymbolicLink: () => false + }) + lstatMock.mockResolvedValueOnce(directoryStat(1)).mockResolvedValueOnce(directoryStat(2)) + realpathMock.mockResolvedValue('/private/replacement') + + const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId) + + expect(results[0]).toMatchObject({ + status: 'failed', + reason: expect.stringContaining('changed while being inspected') + }) + expect(provider.createDirNoClobber).not.toHaveBeenCalled() + expect(uploadSession.uploadFile).not.toHaveBeenCalled() + expect(provider.deletePath).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/filesystem-import-ssh.test.ts b/src/main/ipc/filesystem-import-ssh.test.ts index ce07b9122a6..f834dd904ad 100644 --- a/src/main/ipc/filesystem-import-ssh.test.ts +++ b/src/main/ipc/filesystem-import-ssh.test.ts @@ -1,8 +1,8 @@ import path from 'node:path' import { constants } from 'node:fs' -import { EventEmitter } from 'node:events' import { Readable, Writable } from 'node:stream' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { FileUploadSession, IFilesystemProvider } from '../providers/types' const handlers = new Map Promise>() const { @@ -14,11 +14,6 @@ const { openMock, readdirMock, unlinkMock, - sftpExistsMock, - uploadFileMock, - uploadDirMock, - removeDirectorySftpMock, - mkdirSftpMock, getConnMgrMock } = vi.hoisted(() => ({ handleMock: vi.fn(), @@ -29,11 +24,6 @@ const { openMock: vi.fn(), readdirMock: vi.fn(), unlinkMock: vi.fn(), - sftpExistsMock: vi.fn(), - uploadFileMock: vi.fn(), - uploadDirMock: vi.fn(), - removeDirectorySftpMock: vi.fn(), - mkdirSftpMock: vi.fn(), getConnMgrMock: vi.fn() })) @@ -50,16 +40,13 @@ vi.mock('fs/promises', () => ({ unlink: unlinkMock, rm: vi.fn() })) -vi.mock('../ssh/sftp-upload', () => ({ - sftpPathExists: sftpExistsMock, - uploadFile: uploadFileMock, - uploadDirectory: uploadDirMock, - removeDirectorySftp: removeDirectorySftpMock, - mkdirSftp: mkdirSftpMock -})) vi.mock('./ssh', () => ({ getSshConnectionManager: getConnMgrMock })) import { registerFilesystemMutationHandlers } from './filesystem-mutations' +import { + registerSshFilesystemProvider, + unregisterSshFilesystemProvider +} from '../providers/ssh-filesystem-dispatch' const store = { getRepos: () => [ @@ -75,39 +62,52 @@ const store = { } const enoent = (): Error => Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) -type MockSftpWriteStream = EventEmitter & { - destroy: ReturnType - end: ReturnType -} - -function createMockSftpWriteStream(): MockSftpWriteStream { - const stream = new EventEmitter() as MockSftpWriteStream - stream.destroy = vi.fn() - stream.end = vi.fn(() => stream.emit('close')) - return stream +function createProvider(uploadSession: FileUploadSession): IFilesystemProvider { + return { + readDir: vi.fn(), + readFile: vi.fn(), + downloadFile: vi.fn(), + openFileUploadSession: vi.fn().mockResolvedValue(uploadSession), + writeFile: vi.fn().mockResolvedValue(undefined), + writeFileBase64: vi.fn(), + writeFileBase64Chunk: vi.fn().mockResolvedValue(undefined), + stat: vi.fn().mockRejectedValue(enoent()), + deletePath: vi.fn().mockResolvedValue(undefined), + createFile: vi.fn(), + createDir: vi.fn().mockResolvedValue(undefined), + createDirNoClobber: vi.fn().mockResolvedValue(undefined), + rename: vi.fn(), + renameNoClobber: vi.fn(), + copy: vi.fn(), + realpath: vi.fn(), + search: vi.fn(), + listFiles: vi.fn(), + watch: vi.fn() + } as unknown as IFilesystemProvider } describe('fs:importExternalPaths — SSH routing & connection', () => { const destDir = '/home/user/project/src' const connId = 'ssh-conn-1' - const sftpWriteStreams: MockSftpWriteStream[] = [] - const mockSftp = { - end: vi.fn(), - createWriteStream: vi.fn(() => { - const stream = createMockSftpWriteStream() - sftpWriteStreams.push(stream) - return stream - }) - } + let provider: IFilesystemProvider + let uploadSession: FileUploadSession + const makeConn = (status = 'connected') => ({ getState: () => ({ status }), - sftp: vi.fn().mockResolvedValue(mockSftp) + sftp: vi.fn() }) const mockFile = (p: string): void => { const rp = path.resolve(p) lstatMock.mockImplementation(async (x: string) => { if (x === rp) { - return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false } + return { + size: 12, + ino: 1, + dev: 1, + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false + } } throw enoent() }) @@ -128,16 +128,8 @@ describe('fs:importExternalPaths — SSH routing & connection', () => { openMock, readdirMock, unlinkMock, - sftpExistsMock, - uploadFileMock, - uploadDirMock, - removeDirectorySftpMock, - mkdirSftpMock, getConnMgrMock ].forEach((m) => m.mockReset()) - mockSftp.end.mockReset() - mockSftp.createWriteStream.mockClear() - sftpWriteStreams.length = 0 handleMock.mockImplementation((ch: string, h: never) => { handlers.set(ch, h) }) @@ -167,29 +159,36 @@ describe('fs:importExternalPaths — SSH routing & connection', () => { } }) unlinkMock.mockResolvedValue(undefined) - sftpExistsMock.mockResolvedValue(false) - uploadFileMock.mockResolvedValue(undefined) - uploadDirMock.mockResolvedValue(undefined) - removeDirectorySftpMock.mockResolvedValue(undefined) - mkdirSftpMock.mockResolvedValue(undefined) + uploadSession = { + uploadFile: vi.fn().mockResolvedValue(undefined), + close: vi.fn() + } + provider = createProvider(uploadSession) + registerSshFilesystemProvider(connId, provider) registerFilesystemMutationHandlers(store as never) }) - it('routes to SFTP when connectionId is present', async () => { + afterEach(() => { + unregisterSshFilesystemProvider(connId) + }) + + it('routes SSH imports through the filesystem provider when connectionId is present', async () => { getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() }) mockFile('/tmp/dropped/file.txt') + const { results } = await invoke({ sourcePaths: ['/tmp/dropped/file.txt'], destDir, connectionId: connId }) + expect(results[0]).toMatchObject({ status: 'imported', kind: 'file' }) - expect(uploadFileMock).toHaveBeenCalledWith( - mockSftp, + expect(uploadSession.uploadFile).toHaveBeenCalledWith( path.resolve('/tmp/dropped/file.txt'), `${destDir}/file.txt`, { exclusive: true } ) + expect(uploadSession.close).toHaveBeenCalledOnce() expect(copyFileMock).not.toHaveBeenCalled() }) @@ -206,7 +205,8 @@ describe('fs:importExternalPaths — SSH routing & connection', () => { ) }) - it('returns empty results without opening SFTP', async () => { + it('returns empty results without opening SFTP or requiring a provider', async () => { + unregisterSshFilesystemProvider(connId) const conn = makeConn() getConnMgrMock.mockReturnValue({ getConnection: () => conn }) const { results } = await invoke({ sourcePaths: [], destDir, connectionId: connId }) @@ -235,36 +235,15 @@ describe('fs:importExternalPaths — SSH routing & connection', () => { ).rejects.toThrow('not active') }) - it('throws when conn.sftp() rejects', async () => { - const conn = makeConn() - conn.sftp.mockRejectedValue(new Error('SFTP subsystem not available')) - getConnMgrMock.mockReturnValue({ getConnection: () => conn }) + it('throws when the SSH filesystem provider is unavailable', async () => { + unregisterSshFilesystemProvider(connId) + getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() }) await expect( invoke({ sourcePaths: ['/tmp/x'], destDir, connectionId: connId }) - ).rejects.toThrow('SFTP subsystem') + ).rejects.toThrow('Remote connection dropped') }) - it('closes SFTP channel after success', async () => { - getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() }) - mockFile('/tmp/dropped/file.txt') - await invoke({ sourcePaths: ['/tmp/dropped/file.txt'], destDir, connectionId: connId }) - expect(mockSftp.end).toHaveBeenCalledOnce() - }) - - it('closes SFTP channel after upload error', async () => { - getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() }) - mockFile('/tmp/dropped/file.txt') - uploadFileMock.mockRejectedValue(new Error('disk full')) - const { results } = await invoke({ - sourcePaths: ['/tmp/dropped/file.txt'], - destDir, - connectionId: connId - }) - expect(results[0]).toMatchObject({ status: 'failed', reason: 'disk full' }) - expect(mockSftp.end).toHaveBeenCalledOnce() - }) - - it('removes staging marker write listeners after remote stream close', async () => { + it('uploads terminal-drop staging marker through provider writes', async () => { getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() }) mockFile('/tmp/dropped/file.txt') @@ -275,9 +254,11 @@ describe('fs:importExternalPaths — SSH routing & connection', () => { ensureDir: true }) - expect(mockSftp.createWriteStream).toHaveBeenCalledWith('/home/user/project/.orca/.gitignore') - expect(sftpWriteStreams[0]!.listenerCount('close')).toBe(0) - expect(sftpWriteStreams[0]!.listenerCount('error')).toBe(0) - expect(sftpWriteStreams[0]!.destroy).toHaveBeenCalledOnce() + expect(provider.createDir).toHaveBeenCalledWith('/home/user/project/.orca') + expect(provider.writeFile).toHaveBeenCalledWith( + '/home/user/project/.orca/.gitignore', + '*\n!.gitignore\n' + ) + expect(provider.createDir).toHaveBeenCalledWith('/home/user/project/.orca/drops') }) }) diff --git a/src/main/ipc/filesystem-import-ssh.ts b/src/main/ipc/filesystem-import-ssh.ts index 51c8aaae54f..afaabd179ba 100644 --- a/src/main/ipc/filesystem-import-ssh.ts +++ b/src/main/ipc/filesystem-import-ssh.ts @@ -1,20 +1,20 @@ -import { lstat, readdir, realpath } from 'node:fs/promises' -import { basename, join, posix, resolve } from 'node:path' -import type { SFTPWrapper } from 'ssh2' +import { lstat } from 'node:fs/promises' +import { basename, posix, resolve } from 'node:path' import { authorizeExternalPath, isENOENT } from './filesystem-auth' import { getSshConnectionManager } from './ssh' -import { - uploadFile, - uploadDirectory, - mkdirSftp, - sftpPathExists, - removeDirectorySftp -} from '../ssh/sftp-upload' +import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import type { FileUploadSession, IFilesystemProvider } from '../providers/types' import type { ImportItemResult } from './filesystem-mutations' +import { assertSafeRemotePathSegment, type RemotePathFlavor } from '../ssh/ssh-remote-platform' +import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' +import { + captureLocalUploadRoot, + preScanSshImportDirectory, + uploadSshImportDirectory +} from './filesystem-import-ssh-directory' -// Why: the SSH import path bypasses SshFilesystemProvider and uses -// SshConnection.sftp() directly because the relay's JSON-RPC fs.writeFile -// is text-only and cannot carry binary data without base64 overhead. +// Why: the SSH import path uses SshFilesystemProvider instead of direct SFTP so +// system-SSH transports (ProxyCommand/ProxyJump/FIDO2) get the same workflows. export async function importExternalPathsSsh( sourcePaths: string[], destDir: string, @@ -39,25 +39,35 @@ export async function importExternalPathsSsh( throw new Error('SSH connection is not active — please reconnect and try again') } - const sftp = await conn.sftp() + const provider = requireSshFilesystemProvider(connectionId) + if (options?.ensureDir) { + // Why: terminal-drop staging needs `${worktree}/.orca/drops` to exist + // before the first upload. .orca/ is reserved as Orca-owned remote state; + // see docs/terminal-drop-ssh.md. + await ensureDropStagingDir(provider, destDir) + } + + const results: ImportItemResult[] = [] + const reservedNames = new Set() + if (!provider.openFileUploadSession) { + throw new Error('Remote file upload is unavailable. Reconnect the SSH target and retry.') + } + const uploadSession = await provider.openFileUploadSession() + // Why: filename legality follows the remote filesystem, not the client's OS. + const remotePathFlavor: RemotePathFlavor = isWindowsAbsolutePathLike(destDir) + ? 'windows' + : 'posix' try { - if (options?.ensureDir) { - // Why: terminal-drop staging needs `${worktree}/.orca/drops` to exist - // before the first upload. Upload primitives do not create parent dirs, - // and mkdirSftp is not recursive — so walk the parent chain here on the - // same SFTP session to avoid doubling the handshake cost. Writing the - // .orca/.gitignore marker only when absent prevents clobbering user- - // authored patterns. .orca/ is reserved as Orca-owned remote state; - // see docs/terminal-drop-ssh.md. - await ensureDropStagingDir(sftp, destDir) - } - - const results: ImportItemResult[] = [] - const reservedNames = new Set() - for (const sourcePath of sourcePaths) { - const result = await importOneSourceSsh(sftp, sourcePath, destDir, reservedNames) + const result = await importOneSourceSsh( + provider, + uploadSession, + sourcePath, + destDir, + reservedNames, + remotePathFlavor + ) results.push(result) if (result.status === 'imported') { // Why: destPath is a remote POSIX path (e.g. /home/user/foo/bar.txt). @@ -66,23 +76,36 @@ export async function importExternalPathsSsh( reservedNames.add(posix.basename(result.destPath)) } } - - return { results } } finally { - sftp.end() + uploadSession.close() } + + return { results } } async function importOneSourceSsh( - sftp: SFTPWrapper, + provider: IFilesystemProvider, + uploadSession: FileUploadSession, sourcePath: string, destDir: string, - reservedNames: Set + reservedNames: Set, + remotePathFlavor: RemotePathFlavor ): Promise { const resolvedSource = resolve(sourcePath) authorizeExternalPath(resolvedSource) + const originalName = basename(resolvedSource) + try { + assertSafeRemotePathSegment(originalName, remotePathFlavor) + } catch (error) { + return { + sourcePath, + status: 'failed', + reason: error instanceof Error ? error.message : String(error) + } + } + let sourceStat: Awaited> try { sourceStat = await lstat(resolvedSource) @@ -115,29 +138,30 @@ async function importOneSourceSsh( const isDir = sourceStat.isDirectory() - if (isDir) { - const hasSymlink = await preScanForSymlinks(resolvedSource) - if (hasSymlink) { - return { sourcePath, status: 'skipped', reason: 'symlink' } - } - } - - const originalName = basename(resolvedSource) - let createdDestDir: string | null = null try { - const finalName = await deconflictNameSftp(sftp, destDir, originalName, reservedNames) + const rootRealPath = isDir ? await captureLocalUploadRoot(resolvedSource, sourceStat) : null + if (isDir && (await preScanSshImportDirectory(resolvedSource, remotePathFlavor))) { + return { sourcePath, status: 'skipped', reason: 'symlink' } + } + + const finalName = await deconflictName(provider, destDir, originalName, reservedNames) const destPath = `${destDir}/${finalName}` const renamed = finalName !== originalName if (isDir) { - await mkdirSftp(sftp, destPath, { allowExisting: false }) + await provider.createDirNoClobber(destPath) createdDestDir = destPath - await uploadDirectory(sftp, resolvedSource, destPath, await realpath(resolvedSource), { - exclusive: true - }) + await uploadSshImportDirectory( + provider, + uploadSession, + resolvedSource, + destPath, + rootRealPath!, + remotePathFlavor + ) } else { - await uploadFile(sftp, resolvedSource, destPath, { exclusive: true }) + await uploadSession.uploadFile(resolvedSource, destPath, { exclusive: true }) } return { @@ -151,7 +175,7 @@ async function importOneSourceSsh( if (createdDestDir) { // Why: local directory imports roll back partial output; SSH imports // should not leave the no-clobber root after a nested upload failure. - await removeDirectorySftp(sftp, createdDestDir).catch(() => {}) + await provider.deletePath(createdDestDir, true).catch(() => {}) } return { sourcePath, @@ -161,14 +185,14 @@ async function importOneSourceSsh( } } -async function deconflictNameSftp( - sftp: SFTPWrapper, +async function deconflictName( + provider: IFilesystemProvider, destDir: string, originalName: string, reservedNames: Set ): Promise { if ( - !(await sftpPathExists(sftp, `${destDir}/${originalName}`)) && + !(await remotePathExists(provider, `${destDir}/${originalName}`)) && !reservedNames.has(originalName) ) { return originalName @@ -180,14 +204,20 @@ async function deconflictNameSftp( const ext = hasMeaningfulExt ? originalName.slice(dotIndex) : '' let candidate = `${stem} copy${ext}` - if (!(await sftpPathExists(sftp, `${destDir}/${candidate}`)) && !reservedNames.has(candidate)) { + if ( + !(await remotePathExists(provider, `${destDir}/${candidate}`)) && + !reservedNames.has(candidate) + ) { return candidate } let counter = 2 while (counter < 10000) { candidate = `${stem} copy ${counter}${ext}` - if (!(await sftpPathExists(sftp, `${destDir}/${candidate}`)) && !reservedNames.has(candidate)) { + if ( + !(await remotePathExists(provider, `${destDir}/${candidate}`)) && + !reservedNames.has(candidate) + ) { return candidate } counter += 1 @@ -198,58 +228,40 @@ async function deconflictNameSftp( ) } -async function ensureDropStagingDir(sftp: SFTPWrapper, destDir: string): Promise { - // destDir is a posix remote path, expected to be `${worktreePath}/.orca/drops`. +async function ensureDropStagingDir(provider: IFilesystemProvider, destDir: string): Promise { const parent = posix.dirname(destDir) - await mkdirSftp(sftp, parent) + await provider.createDir(parent) const gitignorePath = `${parent}/.gitignore` - if (!(await sftpPathExists(sftp, gitignorePath))) { - // Why: negate the marker so .orca/.gitignore itself is trackable if we - // ever want to, without dirtying `git status` today. Only write when - // absent to avoid clobbering user-authored patterns. - await writeSftpFile(sftp, gitignorePath, '*\n!.gitignore\n') + if (!(await remotePathExists(provider, gitignorePath))) { + await provider.writeFile(gitignorePath, '*\n!.gitignore\n') } - await mkdirSftp(sftp, destDir) + await provider.createDir(destDir) } -function writeSftpFile(sftp: SFTPWrapper, remotePath: string, contents: string): Promise { - return new Promise((resolve, reject) => { - let settled = false - const writeStream = sftp.createWriteStream(remotePath) - const cleanup = (): void => { - writeStream.off('close', onClose) - writeStream.off('error', onError) - } - const settle = (fn: typeof resolve | typeof reject, val?: unknown): void => { - if (settled) { - return - } - settled = true - cleanup() - writeStream.destroy() - fn(val as never) - } - const onClose = (): void => settle(resolve) - const onError = (err: Error): void => settle(reject, err) - - writeStream.on('close', onClose) - writeStream.on('error', onError) - writeStream.end(contents) - }) -} - -async function preScanForSymlinks(dirPath: string): Promise { - const entries = await readdir(dirPath, { withFileTypes: true }) - for (const entry of entries) { - if (entry.isSymbolicLink()) { - return true - } - if (entry.isDirectory()) { - const childPath = join(dirPath, entry.name) - if (await preScanForSymlinks(childPath)) { - return true - } +async function remotePathExists( + provider: IFilesystemProvider, + remotePath: string +): Promise { + try { + await provider.stat(remotePath) + return true + } catch (error) { + if (isRemoteMissingError(error)) { + return false } + throw error } - return false +} + +function isRemoteMissingError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + const code = (error as NodeJS.ErrnoException).code + return ( + code === 'ENOENT' || + /\b(ENOENT|ENOTDIR)\b|no such file or directory|cannot find (?:the )?(?:file|path)|(?:file|path) not found/i.test( + error.message + ) + ) } diff --git a/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts b/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts index d0891ec35a2..a566bf178c6 100644 --- a/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts +++ b/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts @@ -300,6 +300,48 @@ describe('local filesystem watcher unsubscribe cleanup', () => { } }) + it('aborts an opening local watcher when the last listener unwatches', async () => { + vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) + let resolveSubscribe: (subscription: { unsubscribe: () => void }) => void = () => {} + const unsubscribeMock = vi.fn() + vi.mocked(subscribeParcelWatcher).mockImplementation( + () => + new Promise((resolve) => { + resolveSubscribe = resolve as typeof resolveSubscribe + }) + ) + const sender = { + isDestroyed: () => false, + send: vi.fn(), + once: vi.fn(), + id: 1 + } + + const watchPromise = handlers['fs:watchWorktree']( + { sender }, + { worktreePath: '/tmp/repo' } + ) as Promise + await vi.waitFor(() => { + expect(subscribeParcelWatcher).toHaveBeenCalled() + }) + + const unwatchPromise = handlers['fs:unwatchWorktree']( + { sender }, + { worktreePath: '/tmp/repo' } + ) as Promise + // Why: unwatch must abort the in-flight install so watchWorktree settles + // without waiting for the native subscribe crawl to finish. + const watchSettledEarly = await Promise.race([ + watchPromise.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 20)) + ]) + resolveSubscribe({ unsubscribe: unsubscribeMock }) + await Promise.all([watchPromise, unwatchPromise]) + + expect(watchSettledEarly).toBe(true) + expect(unsubscribeMock).toHaveBeenCalledTimes(1) + }) + it('cancels an opening local watcher for worktree deletion', async () => { vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) let resolveSubscribe: (subscription: { unsubscribe: () => void }) => void = () => {} @@ -325,9 +367,50 @@ describe('local filesystem watcher unsubscribe cleanup', () => { expect(subscribeParcelWatcher).toHaveBeenCalled() }) const closePromise = closeLocalWatcherForWorktreePath('/tmp/repo') + const closedBeforeNativeSubscribe = await Promise.race([ + closePromise.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 0)) + ]) resolveSubscribe({ unsubscribe: unsubscribeMock }) await Promise.all([watchPromise, closePromise]) + expect(closedBeforeNativeSubscribe).toBe(true) + expect(unsubscribeMock).toHaveBeenCalledTimes(1) + }) + + it('cancels an opening local watcher during app shutdown', async () => { + vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) + let resolveSubscribe: (subscription: { unsubscribe: () => void }) => void = () => {} + const unsubscribeMock = vi.fn() + vi.mocked(subscribeParcelWatcher).mockImplementation( + () => + new Promise((resolve) => { + resolveSubscribe = resolve as typeof resolveSubscribe + }) + ) + const sender = { + isDestroyed: () => false, + send: vi.fn(), + once: vi.fn(), + id: 1 + } + + const watchPromise = handlers['fs:watchWorktree']( + { sender }, + { worktreePath: '/tmp/repo' } + ) as Promise + await vi.waitFor(() => { + expect(subscribeParcelWatcher).toHaveBeenCalled() + }) + const shutdownPromise = closeAllWatchers() + const closedBeforeNativeSubscribe = await Promise.race([ + shutdownPromise.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 0)) + ]) + resolveSubscribe({ unsubscribe: unsubscribeMock }) + await Promise.all([watchPromise, shutdownPromise]) + + expect(closedBeforeNativeSubscribe).toBe(true) expect(unsubscribeMock).toHaveBeenCalledTimes(1) }) }) diff --git a/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts b/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts new file mode 100644 index 00000000000..3e22cb6b835 --- /dev/null +++ b/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts @@ -0,0 +1,299 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { handleMock, getSshFilesystemProviderMock } = vi.hoisted(() => ({ + handleMock: vi.fn(), + getSshFilesystemProviderMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { handle: handleMock } +})) + +vi.mock('fs/promises', () => ({ stat: vi.fn() })) +vi.mock('@parcel/watcher', () => ({ subscribe: vi.fn() })) +vi.mock('./filesystem-watcher-wsl', () => ({ createWslWatcher: vi.fn() })) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: getSshFilesystemProviderMock +})) + +import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' + +type HandlerMap = Record Promise | unknown> + +describe('remote filesystem watcher cancellation', () => { + const handlers: HandlerMap = {} + + beforeEach(async () => { + handleMock.mockReset() + getSshFilesystemProviderMock.mockReset() + for (const key of Object.keys(handlers)) { + delete handlers[key] + } + handleMock.mockImplementation((channel, handler) => { + handlers[channel] = handler + }) + registerFilesystemWatcherHandlers() + await closeAllWatchers() + }) + + it('aborts pending SSH setup after the last same-root listener leaves and cleans late success', async () => { + let installSignal: AbortSignal | undefined + let resolveInstall: ((unwatch: () => void) => void) | undefined + const lateUnwatch = vi.fn() + const watchMock = vi.fn( + (_rootPath, _callback, options?: { signal?: AbortSignal }) => + new Promise<() => void>((resolve) => { + installSignal = options?.signal + resolveInstall = resolve + }) + ) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + const senderOne = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const senderTwo = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const first = handlers['fs:watchWorktree']({ sender: senderOne }, args) as Promise + const second = handlers['fs:watchWorktree']({ sender: senderTwo }, args) as Promise + + await Promise.resolve() + try { + expect(watchMock).toHaveBeenCalledTimes(1) + expect(installSignal?.aborted).toBe(false) + + handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args) + await Promise.resolve() + expect(installSignal?.aborted).toBe(false) + + handlers['fs:unwatchWorktree']({ sender: { id: 2 } }, args) + await Promise.resolve() + expect(installSignal?.aborted).toBe(true) + } finally { + resolveInstall?.(lateUnwatch) + await Promise.all([first, second]) + } + expect(lateUnwatch).toHaveBeenCalledTimes(1) + }) + + it('starts a fresh same-root generation when a listener arrives after physical abort', async () => { + let firstSignal: AbortSignal | undefined + let secondCallback: ((events: unknown[]) => void) | undefined + const secondUnwatch = vi.fn() + const watchMock = vi + .fn() + .mockImplementationOnce( + (_rootPath, _callback, options?: { signal?: AbortSignal }) => + new Promise<() => void>((_resolve, reject) => { + firstSignal = options?.signal + options?.signal?.addEventListener( + 'abort', + () => { + const error = new Error('cancelled') + error.name = 'AbortError' + reject(error) + }, + { once: true } + ) + }) + ) + .mockImplementationOnce((_rootPath, callback) => { + secondCallback = callback + return Promise.resolve(secondUnwatch) + }) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + const firstSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const secondSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const first = handlers['fs:watchWorktree']({ sender: firstSender }, args) as Promise + + await Promise.resolve() + handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args) + await vi.waitFor(() => expect(firstSignal?.aborted).toBe(true)) + const second = handlers['fs:watchWorktree']({ sender: secondSender }, args) as Promise + + await Promise.all([first, second]) + expect(watchMock).toHaveBeenCalledTimes(2) + secondCallback?.([{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }]) + expect(secondSender.send).toHaveBeenCalledTimes(1) + + handlers['fs:unwatchWorktree']({ sender: { id: 2 } }, args) + expect(secondUnwatch).toHaveBeenCalledTimes(1) + }) + + it('aborts pending SSH setup on sender destruction and watcher shutdown', async () => { + const installs = new Map< + string, + { signal: AbortSignal | undefined; resolve: (unwatch: () => void) => void } + >() + const watchMock = vi.fn( + (rootPath: string, _callback, options?: { signal?: AbortSignal }) => + new Promise<() => void>((resolve) => { + installs.set(rootPath, { signal: options?.signal, resolve }) + }) + ) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + const destroyedCallbacks: (() => void)[] = [] + const destroyedSender = { + isDestroyed: () => false, + send: vi.fn(), + once: vi.fn((event: string, callback: () => void) => { + if (event === 'destroyed') { + destroyedCallbacks.push(callback) + } + }), + id: 1 + } + const destroyedArgs = { worktreePath: '/destroyed', connectionId: 'conn-1' } + const destroyedWatch = handlers['fs:watchWorktree']( + { sender: destroyedSender }, + destroyedArgs + ) as Promise + + await Promise.resolve() + destroyedCallbacks[0]() + await Promise.resolve() + expect(installs.get('/destroyed')?.signal?.aborted).toBe(true) + installs.get('/destroyed')?.resolve(vi.fn()) + await destroyedWatch + + const shutdownSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const shutdownArgs = { worktreePath: '/shutdown', connectionId: 'conn-1' } + const shutdownWatch = handlers['fs:watchWorktree']( + { sender: shutdownSender }, + shutdownArgs + ) as Promise + + await Promise.resolve() + await closeAllWatchers() + expect(installs.get('/shutdown')?.signal?.aborted).toBe(true) + installs.get('/shutdown')?.resolve(vi.fn()) + await shutdownWatch + }) + + it('keeps the shared install alive when a replacement sender joins before the deferred abort fires', async () => { + let installSignal: AbortSignal | undefined + let resolveInstall: ((unwatch: () => void) => void) | undefined + const watchMock = vi.fn( + (_rootPath, _callback, options?: { signal?: AbortSignal }) => + new Promise<() => void>((resolve) => { + installSignal = options?.signal + resolveInstall = resolve + }) + ) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + const senderOne = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const senderTwo = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const first = handlers['fs:watchWorktree']({ sender: senderOne }, args) as Promise + + await Promise.resolve() + expect(watchMock).toHaveBeenCalledTimes(1) + + // The last listener leaves and a replacement joins in the SAME tick — before + // the queued abort microtask runs. The shared install must survive. + handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args) + const second = handlers['fs:watchWorktree']({ sender: senderTwo }, args) as Promise + + await Promise.resolve() + await Promise.resolve() + expect(installSignal?.aborted).toBe(false) + expect(watchMock).toHaveBeenCalledTimes(1) + + resolveInstall?.(vi.fn()) + await Promise.all([first, second]) + }) + + it('refuses a post-shutdown joiner recursion instead of resurrecting the install', async () => { + let firstSignal: AbortSignal | undefined + let resolveFirst: ((unwatch: () => void) => void) | undefined + const lateUnwatch = vi.fn() + const watchMock = vi.fn( + (_rootPath, _callback, options?: { signal?: AbortSignal }) => + new Promise<() => void>((resolve) => { + firstSignal = options?.signal + resolveFirst = resolve + }) + ) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + const firstSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const secondSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const first = handlers['fs:watchWorktree']({ sender: firstSender }, args) as Promise + + await Promise.resolve() + expect(watchMock).toHaveBeenCalledTimes(1) + + // Last listener leaves -> deferred abort fires while the install is still pending. + handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args) + await vi.waitFor(() => expect(firstSignal?.aborted).toBe(true)) + + // Joiner arrives after physical abort (canJoinInstall === false); it awaits the + // 'cancelled' resolution and would recurse into a fresh install. + const second = handlers['fs:watchWorktree']({ sender: secondSender }, args) as Promise + await Promise.resolve() + + // Shutdown latches the subsystem before the joiner's recursion runs. + await closeAllWatchers() + + // Late success of the aborted generation must be unwatched, not registered. + resolveFirst?.(lateUnwatch) + await Promise.all([first, second]) + + // The recursion is refused post-shutdown: provider.watch() is never called again. + expect(watchMock).toHaveBeenCalledTimes(1) + expect(lateUnwatch).toHaveBeenCalledTimes(1) + }) + + it('refuses a pre-shutdown joiner recursion even after a new watch reopens the subsystem', async () => { + const installs = new Map< + string, + { signal: AbortSignal | undefined; resolve: (unwatch: () => void) => void } + >() + const watchMock = vi.fn( + (rootPath: string, _callback, options?: { signal?: AbortSignal }) => + new Promise<() => void>((resolve) => { + installs.set(rootPath, { signal: options?.signal, resolve }) + }) + ) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + const reopenArgs = { worktreePath: '/home/me/other', connectionId: 'conn-1' } + const lateUnwatch = vi.fn() + const firstSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const joinerSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const reopenSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 3 } + const first = handlers['fs:watchWorktree']({ sender: firstSender }, args) as Promise + + await Promise.resolve() + expect(watchMock).toHaveBeenCalledTimes(1) + + // Last listener leaves -> deferred abort fires while the install is still pending. + handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args) + await vi.waitFor(() => expect(installs.get('/home/me/repo')?.signal?.aborted).toBe(true)) + + // Joiner arrives after physical abort (canJoinInstall === false); it captures the + // current lifecycle generation and awaits the 'cancelled' resolution. + const joiner = handlers['fs:watchWorktree']({ sender: joinerSender }, args) as Promise + await Promise.resolve() + + // Shutdown bumps the generation, then a genuine new watch reopens the subsystem + // (clearing the boolean latch) before the joiner resumes. + await closeAllWatchers() + const reopen = handlers['fs:watchWorktree']( + { sender: reopenSender }, + reopenArgs + ) as Promise + await Promise.resolve() + expect(watchMock).toHaveBeenCalledTimes(2) + + // Now let the aborted install resolve; the joiner recurses on the stale generation. + installs.get('/home/me/repo')?.resolve(lateUnwatch) + installs.get('/home/me/other')?.resolve(vi.fn()) + await Promise.all([first, joiner, reopen]) + + // The joiner's recursion is refused despite the reopen: no third provider.watch(). + expect(watchMock).toHaveBeenCalledTimes(2) + expect(watchMock.mock.calls.filter(([rootPath]) => rootPath === '/home/me/repo')).toHaveLength( + 1 + ) + expect(lateUnwatch).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/ipc/filesystem-watcher-wsl.test.ts b/src/main/ipc/filesystem-watcher-wsl.test.ts index 203ca2fbfa1..4258c0e675e 100644 --- a/src/main/ipc/filesystem-watcher-wsl.test.ts +++ b/src/main/ipc/filesystem-watcher-wsl.test.ts @@ -172,4 +172,28 @@ describe('createWslWatcher', () => { await expect(promise).rejects.toThrow('write EPIPE') }) + + it('kills the snapshot process when the install abort signal fires during startup', async () => { + const child = new FakeChildProcess() + spawnMock.mockReturnValueOnce(child) + const controller = new AbortController() + const promise = createWslWatcher(ROOT_KEY, ROOT_KEY, makeDeps(), controller.signal) + + controller.abort() + + await expect(promise).rejects.toMatchObject({ name: 'AbortError' }) + expect(child.kill).toHaveBeenCalledOnce() + }) + + it('rejects immediately when the install abort signal is already aborted', async () => { + const controller = new AbortController() + controller.abort() + + await expect( + createWslWatcher(ROOT_KEY, ROOT_KEY, makeDeps(), controller.signal) + ).rejects.toMatchObject({ + name: 'AbortError' + }) + expect(spawnMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ipc/filesystem-watcher-wsl.ts b/src/main/ipc/filesystem-watcher-wsl.ts index b0d2c8e9d04..54e58415894 100644 --- a/src/main/ipc/filesystem-watcher-wsl.ts +++ b/src/main/ipc/filesystem-watcher-wsl.ts @@ -150,8 +150,15 @@ function markOverflowWithoutUncStat(root: WatchedRoot): void { export async function createWslWatcher( rootKey: string, worktreePath: string, - deps: WslWatcherDeps + deps: WslWatcherDeps, + signal?: AbortSignal ): Promise { + // Why: cancelled local installs pass the same abort controller used for + // native Parcel subscribe; honor it before spawning a WSL snapshot process. + if (signal?.aborted) { + throw new DOMException('WSL watcher subscription aborted', 'AbortError') + } + const wsl = parseWslUncPath(worktreePath) if (!wsl) { throw new Error(`Not a WSL path: ${worktreePath}`) @@ -257,6 +264,12 @@ export async function createWslWatcher( throw error instanceof Error ? error : new Error(String(error)) } + const onAbort = (): void => { + settleInitial(new DOMException('WSL watcher subscription aborted', 'AbortError')) + child.kill() + } + signal?.addEventListener('abort', onAbort, { once: true }) + const startupTimer = setTimeout(() => { settleInitial(new Error(`Timed out starting WSL watcher for ${worktreePath}`)) child.kill() @@ -321,7 +334,12 @@ export async function createWslWatcher( child.stdin.end(buildSnapshotScript(deps.ignoreDirs)) - await initialSnapshotReady.finally(() => clearTimeout(startupTimer)) + try { + await initialSnapshotReady + } finally { + clearTimeout(startupTimer) + signal?.removeEventListener('abort', onAbort) + } root.subscription = { unsubscribe: async () => { diff --git a/src/main/ipc/filesystem-watcher.test.ts b/src/main/ipc/filesystem-watcher.test.ts index 0b3b07ca071..f776e0d494d 100644 --- a/src/main/ipc/filesystem-watcher.test.ts +++ b/src/main/ipc/filesystem-watcher.test.ts @@ -124,7 +124,9 @@ describe('registerFilesystemWatcherHandlers', () => { getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) await vi.advanceTimersByTimeAsync(1_000) - expect(watchMock).toHaveBeenCalledWith('/home/me/repo', expect.any(Function)) + expect(watchMock).toHaveBeenCalledWith('/home/me/repo', expect.any(Function), { + signal: expect.any(AbortSignal) + }) const onEvents = watchMock.mock.calls[0][1] onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }]) expect(sendMock).toHaveBeenCalledWith('fs:changed', { @@ -161,7 +163,9 @@ describe('registerFilesystemWatcherHandlers', () => { await vi.advanceTimersByTimeAsync(1_000) - expect(retryWatchMock).toHaveBeenCalledWith('/home/me/repo', expect.any(Function)) + expect(retryWatchMock).toHaveBeenCalledWith('/home/me/repo', expect.any(Function), { + signal: expect.any(AbortSignal) + }) handlers['fs:unwatchWorktree']( { sender: { id: 1 } }, { worktreePath: '/home/me/repo', connectionId: 'conn-1' } diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index 224c348b871..665b543779f 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -72,6 +72,7 @@ const pendingLocalUnsubscribes = new Set>() type LocalWatcherInstallToken = { cancelled: boolean listeners: Map + abortController: AbortController } type LocalWatcherInstallResult = 'installed' | 'unavailable' | 'cancelled' // Why: native watcher creation is async. Concurrent local watch requests for @@ -96,6 +97,9 @@ function cleanupInFlightLocalInstallsForSender(senderId: number): void { token.listeners.delete(senderId) if (token.listeners.size === 0) { token.cancelled = true + // Why: match closeLocalWatcherForWorktreePath / closeAllWatchers — abort + // so a pending native/forked subscription stops early, not at completion. + token.abortController.abort() } } } @@ -278,7 +282,11 @@ function scheduleBatchFlush(rootKey: string, root: WatchedRoot): void { // ── Watcher creation ───────────────────────────────────────────────── -async function createWatcher(rootKey: string, rootPath: string): Promise { +async function createWatcher( + rootKey: string, + rootPath: string, + signal?: AbortSignal +): Promise { const root: WatchedRoot = { subscription: null!, listeners: new Map(), @@ -300,6 +308,11 @@ async function createWatcher(rootKey: string, rootPath: string): Promise { + root.batch.overflowed = true + scheduleBatchFlush(rootKey, root) + } + // Why: subscriptions run in a forked watcher process (issue #7547 — // watcher.node teardown races fail-fast the hosting process). A watcher // crash there is recovered by resubscribing; onInterruption marks the @@ -336,11 +349,13 @@ async function createWatcher(rootKey: string, rootPath: string): Promise { - // The watcher process crashed and this root was resubscribed; events - // during the gap are lost, so hand the renderer an overflow refresh. - root.batch.overflowed = true - scheduleBatchFlush(rootKey, root) + { + delivery: { maxEventsPerBatch: MAX_BATCHED_WATCHER_EVENTS }, + // A child restart or bounded-queue overflow loses path precision; both + // require the same conservative renderer refresh. + onInterruption: markWatcherInterrupted, + onOverflow: markWatcherInterrupted, + signal } ) @@ -466,7 +481,11 @@ async function subscribe(worktreePath: string, sender: WebContents): Promise undefined) @@ -619,22 +652,31 @@ type RemoteWatcherState = { type RemoteWatcherInstallToken = { cancelled: boolean listeners: Map + abortController: AbortController + abortScheduled: boolean } // Key: `${connectionId}:${worktreePath}`, Value: shared remote watch state. const remoteWatchers = new Map() const loggedUnavailableRemoteWatchers = new Set() const pendingRemoteWatcherRetries = new Map>() -// Why: track in-flight `provider.watch()` calls so an unwatch/shutdown that -// arrives while a watch is still resolving can mark the install cancelled. -// Without this, the awaited unwatch handle would be installed after the -// renderer thinks the watch is gone, leaking a native watcher. +// Why: track in-flight `provider.watch()` calls so last-listener cleanup can +// abort relay setup, while late success is still unwatched instead of leaked. const inFlightRemoteInstalls = new Map() // Why: dedupe concurrent installRemoteWatcher calls for the same key so // overlapping fs:watchWorktree IPCs share one native watcher and one listener // map, instead of each call independently invoking provider.watch() and // overwriting the per-key state on resolution. const pendingRemoteInstallPromises = new Map>() +// Why: block installs that begin AFTER closeAllWatchers — an in-flight joiner +// recursion or a fired retry tick calls installRemoteWatcher directly, bypassing +// the token-abort loop. A genuine new fs:watchWorktree clears the latch. +let remoteWatchersClosed = false +// Why: the boolean latch alone can't tell a pre-shutdown waiter apart from a +// fresh call once a genuine new watch reopens the subsystem. Each call captures +// the generation at entry; closeAllWatchers bumps it, so a joiner that awaited +// across a shutdown+reopen recurses on a stale generation and is refused. +let remoteWatcherLifecycleGeneration = 0 const REMOTE_WATCH_RETRY_MS = 1_000 const REMOTE_WATCH_RETRY_TIMEOUT_MS = 60_000 @@ -642,7 +684,7 @@ function addInFlightRemoteInstallListener( token: RemoteWatcherInstallToken, sender: WebContents ): void { - if (sender.isDestroyed()) { + if (sender.isDestroyed() || token.abortController.signal.aborted) { return } token.listeners.set(sender.id, sender) @@ -650,12 +692,26 @@ function addInFlightRemoteInstallListener( registerSenderCleanup(sender) } +function cancelInFlightRemoteInstallIfUnowned(token: RemoteWatcherInstallToken): void { + token.cancelled = token.listeners.size === 0 + if (!token.cancelled || token.abortScheduled || token.abortController.signal.aborted) { + return + } + token.abortScheduled = true + // Why: a replacement sender can synchronously revive the shared install + // during a renderer handoff; otherwise stop the relay crawl next microtask. + queueMicrotask(() => { + token.abortScheduled = false + if (token.cancelled && token.listeners.size === 0) { + token.abortController.abort() + } + }) +} + function cleanupInFlightRemoteInstallsForSender(senderId: number): void { for (const token of inFlightRemoteInstalls.values()) { token.listeners.delete(senderId) - if (token.listeners.size === 0) { - token.cancelled = true - } + cancelInFlightRemoteInstallIfUnowned(token) } } @@ -693,8 +749,16 @@ type RemoteWatcherInstallResult = 'installed' | 'unavailable' | 'cancelled' async function installRemoteWatcher( sender: WebContents, connectionId: string, - worktreePath: string + worktreePath: string, + generation = remoteWatcherLifecycleGeneration ): Promise { + // Why: refuse installs racing in after teardown (joiner recursion, fired retry + // tick) so provider.watch() is never called and registered post-shutdown. The + // generation guard also refuses a waiter that captured an earlier lifecycle, + // even after a new watch reopened the subsystem. + if (remoteWatchersClosed || generation !== remoteWatcherLifecycleGeneration) { + return 'cancelled' + } const provider = getSshFilesystemProvider(connectionId) if (!provider || sender.isDestroyed()) { return 'unavailable' @@ -714,7 +778,8 @@ async function installRemoteWatcher( const pendingInstall = pendingRemoteInstallPromises.get(key) if (pendingInstall) { const inFlight = inFlightRemoteInstalls.get(key) - if (inFlight) { + const canJoinInstall = inFlight && !inFlight.abortController.signal.aborted + if (canJoinInstall) { // Why: a new watcher can join after all previous pending listeners // unwatched but before provider.watch() resolves; revive that install // instead of inheriting the stale cancellation. @@ -729,9 +794,27 @@ async function installRemoteWatcher( ) { addRemoteWatchListener(key, sender) } + if ( + result === 'cancelled' && + !canJoinInstall && + !sender.isDestroyed() && + generation === remoteWatcherLifecycleGeneration + ) { + // Why: AbortSignal cannot be revived. A listener arriving after physical + // cancellation waits out that generation, then owns a fresh install. + if (pendingRemoteInstallPromises.get(key) === pendingInstall) { + pendingRemoteInstallPromises.delete(key) + } + return installRemoteWatcher(sender, connectionId, worktreePath, generation) + } return result } - const cancelToken: RemoteWatcherInstallToken = { cancelled: false, listeners: new Map() } + const cancelToken: RemoteWatcherInstallToken = { + cancelled: false, + listeners: new Map(), + abortController: new AbortController(), + abortScheduled: false + } inFlightRemoteInstalls.set(key, cancelToken) addInFlightRemoteInstallListener(cancelToken, sender) const installPromise = doInstallRemoteWatcher(provider, key, worktreePath, cancelToken) @@ -753,22 +836,29 @@ async function doInstallRemoteWatcher( ): Promise { let unwatch: () => void try { - unwatch = await provider.watch(worktreePath, (events) => { - const state = remoteWatchers.get(key) - if (!state) { - return - } - for (const listener of state.listeners.values()) { - if (listener.isDestroyed()) { - continue + unwatch = await provider.watch( + worktreePath, + (events) => { + const state = remoteWatchers.get(key) + if (!state) { + return } - listener.send('fs:changed', { - worktreePath, - events - } satisfies FsChangedPayload) - } - }) + for (const listener of state.listeners.values()) { + if (listener.isDestroyed()) { + continue + } + listener.send('fs:changed', { + worktreePath, + events + } satisfies FsChangedPayload) + } + }, + { signal: cancelToken.abortController.signal } + ) } catch (err) { + if (cancelToken.cancelled || cancelToken.abortController.signal.aborted) { + return 'cancelled' + } console.warn(`[filesystem-watcher] SSH watcher unavailable for ${key}:`, err) return 'unavailable' } finally { @@ -851,6 +941,9 @@ export function registerFilesystemWatcherHandlers(): void { 'fs:watchWorktree', async (event, args: { worktreePath: string; connectionId?: string }): Promise => { if (args.connectionId) { + // Why: a real new watch reopens the subsystem after closeAllWatchers + // latched it shut (also how tests reset between cases). + remoteWatchersClosed = false const key = `${args.connectionId}:${args.worktreePath}` const result = await installRemoteWatcher( event.sender, @@ -890,7 +983,7 @@ export function registerFilesystemWatcherHandlers(): void { const inFlight = inFlightRemoteInstalls.get(key) if (inFlight) { inFlight.listeners.delete(_event.sender.id) - inFlight.cancelled = inFlight.listeners.size === 0 + cancelInFlightRemoteInstallIfUnowned(inFlight) } loggedUnavailableRemoteWatchers.delete(key) releaseRemoteWatchListener(key, _event?.sender?.id ?? 0) @@ -918,14 +1011,24 @@ export async function closeAllWatchers(): Promise { } pendingRemoteWatcherRetries.clear() loggedUnavailableRemoteWatchers.clear() + // Why: latch the subsystem shut and drop the dedup map so a late install that + // begins after teardown is refused instead of registering post-shutdown. Bump + // the generation so a waiter that resumes after a later reopen still recurses + // on a stale lifecycle and is refused. + remoteWatchersClosed = true + remoteWatcherLifecycleGeneration += 1 + pendingRemoteInstallPromises.clear() // Why: cancel any in-flight provider.watch() calls so their resolved // unwatch handles are discarded instead of being installed after shutdown. for (const token of inFlightRemoteInstalls.values()) { + token.listeners.clear() token.cancelled = true + token.abortController.abort() } for (const token of inFlightLocalInstalls.values()) { token.listeners.clear() token.cancelled = true + token.abortController.abort() } for (const [rootKey, root] of watchedRoots) { diff --git a/src/main/ipc/mobile.test.ts b/src/main/ipc/mobile.test.ts index ea4b0602282..4091a298446 100644 --- a/src/main/ipc/mobile.test.ts +++ b/src/main/ipc/mobile.test.ts @@ -6,7 +6,9 @@ const { handleMock, networkInterfacesMock } = vi.hoisted(() => ({ })) vi.mock('electron', () => ({ - ipcMain: { handle: handleMock } + app: { isPackaged: false }, + ipcMain: { handle: handleMock }, + shell: { openExternal: vi.fn() } })) vi.mock('qrcode', () => ({ @@ -226,4 +228,55 @@ describe('registerMobileHandlers', () => { }) expect(revokeRuntimeAccess).toHaveBeenCalledWith('runtime-1') }) + + it('inspects and repairs the current packaged Windows websocket port', async () => { + const runPowerShell = vi + .fn() + .mockResolvedValueOnce( + JSON.stringify({ + ruleAllowed: false, + privateFirewallEnabled: true, + networkCategory: 'Private' + }) + ) + .mockResolvedValueOnce('{"launched":true,"exitCode":0}') + const rpcServer = { getWebSocketEndpoint: () => 'ws://0.0.0.0:6768' } + registerMobileHandlers(rpcServer as never, { + firewallEnvironment: { + platform: 'win32', + isPackaged: true, + executablePath: 'C:\\Program Files\\Orca\\Orca.exe', + runPowerShell + } + }) + + await expect( + handlers.get('mobile:getWindowsFirewallStatus')?.(null, { address: '192.168.0.108' }) + ).resolves.toMatchObject({ supported: true, port: 6768, ruleAllowed: false }) + await expect( + handlers.get('mobile:repairWindowsFirewall')?.({ + sender: { isDestroyed: () => false, getType: () => 'window' } + }) + ).resolves.toEqual({ ok: true }) + }) + + it('rejects firewall mutation from a non-window renderer', async () => { + const runPowerShell = vi.fn() + const rpcServer = { getWebSocketEndpoint: () => 'ws://0.0.0.0:6768' } + registerMobileHandlers(rpcServer as never, { + firewallEnvironment: { + platform: 'win32', + isPackaged: true, + executablePath: 'C:\\Program Files\\Orca\\Orca.exe', + runPowerShell + } + }) + + expect( + handlers.get('mobile:repairWindowsFirewall')?.({ + sender: { isDestroyed: () => false, getType: () => 'webview' } + }) + ).toEqual({ ok: false, reason: 'unsupported' }) + expect(runPowerShell).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts index 11da72988d4..548835801ce 100644 --- a/src/main/ipc/mobile.ts +++ b/src/main/ipc/mobile.ts @@ -1,10 +1,16 @@ -import { ipcMain } from 'electron' +import { app, ipcMain, shell, type IpcMainInvokeEvent } from 'electron' import { networkInterfaces } from 'node:os' import QRCode from 'qrcode' import type { RuntimeAccessGrant } from '../../shared/runtime-access-grants' import { isTailnetIPv4Address } from '../../shared/tailnet-address' import type { DeviceEntry } from '../runtime/device-registry' import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' +import { + getWebSocketPort, + inspectWindowsMobileFirewall, + repairWindowsMobileFirewall, + type WindowsMobileFirewallEnvironment +} from '../runtime/windows-mobile-firewall' export type NetworkInterface = { name: string @@ -51,7 +57,21 @@ function toRuntimeAccessGrant(device: DeviceEntry): RuntimeAccessGrant { // device management, and WebSocket readiness status. They depend on the // OrcaRuntimeRpcServer because it owns the device registry and TLS state. -export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void { +export type MobileHandlerDependencies = { + firewallEnvironment?: WindowsMobileFirewallEnvironment + openWindowsNetworkSettings?: () => Promise +} + +export function registerMobileHandlers( + rpcServer: OrcaRuntimeRpcServer, + dependencies: MobileHandlerDependencies = {} +): void { + const firewallEnvironment = dependencies.firewallEnvironment ?? { + platform: process.platform, + isPackaged: app.isPackaged, + executablePath: process.execPath, + systemRoot: process.env.SystemRoot + } ipcMain.handle('mobile:listNetworkInterfaces', (): { interfaces: NetworkInterface[] } => ({ interfaces: getNetworkInterfaces() })) @@ -189,4 +209,33 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void { endpoint: rpcServer.getWebSocketEndpoint() } }) + + ipcMain.handle('mobile:getWindowsFirewallStatus', (_event, args?: { address?: string }) => { + const port = getWebSocketPort(rpcServer.getWebSocketEndpoint()) + return inspectWindowsMobileFirewall(port, args?.address, firewallEnvironment) + }) + + ipcMain.handle('mobile:repairWindowsFirewall', (event: IpcMainInvokeEvent) => { + if (!isWindowRenderer(event)) { + return { ok: false as const, reason: 'unsupported' as const } + } + // Why: elevated inputs come from the running runtime, never the renderer. + const port = getWebSocketPort(rpcServer.getWebSocketEndpoint()) + return repairWindowsMobileFirewall(port, firewallEnvironment) + }) + + ipcMain.handle('mobile:openWindowsNetworkSettings', async (event: IpcMainInvokeEvent) => { + if (!isWindowRenderer(event) || firewallEnvironment.platform !== 'win32') { + return false + } + const openSettings = + dependencies.openWindowsNetworkSettings ?? + (() => shell.openExternal('ms-settings:network-status')) + await openSettings() + return true + }) +} + +function isWindowRenderer(event: IpcMainInvokeEvent): boolean { + return !event.sender.isDestroyed() && event.sender.getType() === 'window' } diff --git a/src/main/ipc/native-chat-subscribe-lifecycle.test.ts b/src/main/ipc/native-chat-subscribe-lifecycle.test.ts new file mode 100644 index 00000000000..53588814848 --- /dev/null +++ b/src/main/ipc/native-chat-subscribe-lifecycle.test.ts @@ -0,0 +1,228 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { handlers, listeners, subscribeTranscript } = vi.hoisted(() => ({ + handlers: new Map unknown>(), + listeners: new Map unknown>(), + subscribeTranscript: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => { + handlers.set(channel, handler) + }), + on: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => { + listeners.set(channel, handler) + }) + } +})) + +vi.mock('../native-chat/transcript-watch', () => ({ + subscribeNativeChatTranscript: subscribeTranscript +})) + +import { + _getNativeChatPendingSubscriptionCountForTest, + _getNativeChatSenderCleanupCountForTest, + clearNativeChatSubscriptions, + registerNativeChatHandlers +} from './native-chat' + +type TestSubscription = { + unsubscribe: ReturnType +} + +type DeferredSubscription = { + promise: Promise + reject: (error: Error) => void + resolve: () => void + unsubscribe: ReturnType +} + +type SenderHarness = { + destroy: () => void + registeredCleanupCount: () => number + sender: { + id: number + isDestroyed: () => boolean + once: (event: string, callback: () => void) => void + send: ReturnType + } +} + +beforeEach(() => { + clearNativeChatSubscriptions() + handlers.clear() + listeners.clear() + subscribeTranscript.mockReset() + registerNativeChatHandlers() +}) + +function deferredSubscription(): DeferredSubscription { + const unsubscribe = vi.fn() + let resolvePromise: (subscription: TestSubscription) => void = () => {} + let rejectPromise: (error: Error) => void = () => {} + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve + rejectPromise = reject + }) + return { + promise, + reject: rejectPromise, + resolve: () => resolvePromise({ unsubscribe }), + unsubscribe + } +} + +function createSender(id: number): SenderHarness { + let destroyed = false + const destroyedCallbacks: (() => void)[] = [] + return { + destroy: () => { + destroyed = true + for (const callback of destroyedCallbacks) { + callback() + } + }, + registeredCleanupCount: () => destroyedCallbacks.length, + sender: { + id, + isDestroyed: () => destroyed, + once: (event, callback) => { + if (event === 'destroyed') { + destroyedCallbacks.push(callback) + } + }, + send: vi.fn() + } + } +} + +function subscribe(sender: SenderHarness['sender'], subscriptionId: string): void { + const listener = listeners.get('nativeChat:subscribe') + if (!listener) { + throw new Error('subscribe listener not registered') + } + listener({ sender }, { subscriptionId, agent: 'claude', sessionId: `session-${subscriptionId}` }) +} + +function unsubscribe(sender: SenderHarness['sender'], subscriptionId: string): void { + const listener = listeners.get('nativeChat:unsubscribe') + if (!listener) { + throw new Error('unsubscribe listener not registered') + } + listener({ sender }, { subscriptionId }) +} + +async function waitFor(predicate: () => boolean, timeoutMs = 1000): Promise { + const startedAt = Date.now() + while (!predicate()) { + if (Date.now() - startedAt > timeoutMs) { + throw new Error('timed out waiting for lifecycle state') + } + await new Promise((resolve) => setTimeout(resolve, 0)) + } +} + +describe('nativeChat subscribe lifecycle', () => { + it('closes a watcher that resolves after renderer unsubscribe', async () => { + const pending = deferredSubscription() + subscribeTranscript.mockReturnValueOnce(pending.promise) + const renderer = createSender(1) + + subscribe(renderer.sender, 'unmount') + expect(_getNativeChatPendingSubscriptionCountForTest()).toBe(1) + unsubscribe(renderer.sender, 'unmount') + expect(_getNativeChatPendingSubscriptionCountForTest()).toBe(0) + unsubscribe(renderer.sender, 'unmount') + expect(_getNativeChatPendingSubscriptionCountForTest()).toBe(0) + + pending.resolve() + await waitFor(() => pending.unsubscribe.mock.calls.length === 1) + unsubscribe(renderer.sender, 'unmount') + expect(pending.unsubscribe).toHaveBeenCalledOnce() + renderer.destroy() + expect(_getNativeChatSenderCleanupCountForTest()).toBe(0) + }) + + it('closes a watcher that resolves after renderer destruction', async () => { + const pending = deferredSubscription() + subscribeTranscript.mockReturnValueOnce(pending.promise) + const renderer = createSender(2) + + subscribe(renderer.sender, 'destroy') + renderer.destroy() + expect(_getNativeChatPendingSubscriptionCountForTest()).toBe(0) + expect(_getNativeChatSenderCleanupCountForTest()).toBe(0) + + pending.resolve() + await waitFor(() => pending.unsubscribe.mock.calls.length === 1) + }) + + it('keeps the latest same-id subscribe when setup resolves in reverse order', async () => { + const older = deferredSubscription() + const newer = deferredSubscription() + subscribeTranscript.mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise) + const renderer = createSender(3) + + subscribe(renderer.sender, 'same-id') + subscribe(renderer.sender, 'same-id') + expect(_getNativeChatPendingSubscriptionCountForTest()).toBe(1) + + newer.resolve() + await waitFor(() => _getNativeChatPendingSubscriptionCountForTest() === 0) + expect(newer.unsubscribe).not.toHaveBeenCalled() + older.resolve() + await waitFor(() => older.unsubscribe.mock.calls.length === 1) + + unsubscribe(renderer.sender, 'same-id') + expect(newer.unsubscribe).toHaveBeenCalledOnce() + renderer.destroy() + }) + + it('clears rejected setup without duplicating sender cleanup registration', async () => { + const failed = deferredSubscription() + const retry = deferredSubscription() + subscribeTranscript.mockReturnValueOnce(failed.promise).mockReturnValueOnce(retry.promise) + const renderer = createSender(4) + + subscribe(renderer.sender, 'retry') + failed.reject(new Error('watch setup failed')) + await waitFor(() => _getNativeChatPendingSubscriptionCountForTest() === 0) + expect(_getNativeChatSenderCleanupCountForTest()).toBe(1) + + subscribe(renderer.sender, 'retry') + expect(renderer.registeredCleanupCount()).toBe(1) + retry.resolve() + await waitFor(() => _getNativeChatPendingSubscriptionCountForTest() === 0) + unsubscribe(renderer.sender, 'retry') + expect(retry.unsubscribe).toHaveBeenCalledOnce() + renderer.destroy() + expect(_getNativeChatSenderCleanupCountForTest()).toBe(0) + }) + + it('isolates late setup from a replacement renderer reusing the sender id', async () => { + const oldPending = deferredSubscription() + const replacementPending = deferredSubscription() + subscribeTranscript + .mockReturnValueOnce(oldPending.promise) + .mockReturnValueOnce(replacementPending.promise) + const oldRenderer = createSender(41) + const replacementRenderer = createSender(41) + + subscribe(oldRenderer.sender, 'remount') + oldRenderer.destroy() + subscribe(replacementRenderer.sender, 'remount') + expect(replacementRenderer.registeredCleanupCount()).toBe(1) + + replacementPending.resolve() + await waitFor(() => _getNativeChatPendingSubscriptionCountForTest() === 0) + oldPending.resolve() + await waitFor(() => oldPending.unsubscribe.mock.calls.length === 1) + expect(replacementPending.unsubscribe).not.toHaveBeenCalled() + + replacementRenderer.destroy() + expect(replacementPending.unsubscribe).toHaveBeenCalledOnce() + expect(_getNativeChatSenderCleanupCountForTest()).toBe(0) + }) +}) diff --git a/src/main/ipc/native-chat.test.ts b/src/main/ipc/native-chat.test.ts index fd09a14ff3f..d16a39b1f13 100644 --- a/src/main/ipc/native-chat.test.ts +++ b/src/main/ipc/native-chat.test.ts @@ -20,6 +20,7 @@ vi.mock('electron', () => ({ })) import { + _getNativeChatSenderCleanupCountForTest, clearNativeChatSubscriptions, clearNativeChatTranscriptCache, registerNativeChatHandlers @@ -238,6 +239,67 @@ describe('nativeChat:readSession handler', () => { } }) + it('drops cleanup registration when sender is destroyed before subscribe completes', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-destroy-race-')) + tempRoots.push(root) + const projectDir = join(root, '.claude', 'projects', '-repo') + await mkdir(projectDir, { recursive: true }) + await writeFile( + join(projectDir, 'sess-race.jsonl'), + `${jsonLines([ + { + type: 'user', + uuid: 'u-race', + timestamp: '2026-06-01T10:00:00.000Z', + message: { role: 'user', content: 'Race' } + } + ])}\n` + ) + + registerNativeChatHandlers() + const subscribe = listeners.get('nativeChat:subscribe') + expect(subscribe).toBeDefined() + + let destroyed = false + let destroyedCb: (() => void) | undefined + const sender = { + id: 41, + isDestroyed: () => destroyed, + once: (event: string, cb: () => void) => { + if (event === 'destroyed') { + destroyedCb = cb + } + }, + send: vi.fn() + } + + const previousHome = process.env.HOME + process.env.HOME = root + try { + subscribe!( + { sender }, + { + subscriptionId: 'sub-race', + agent: 'claude', + sessionId: 'sess-race' + } + ) + + expect(destroyedCb).toBeDefined() + destroyed = true + destroyedCb!() + + await waitFor(() => _getNativeChatSenderCleanupCountForTest() === 0) + expect(sender.send).not.toHaveBeenCalled() + } finally { + if (previousHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = previousHome + } + } + }) + it('returns an error for an unknown session without throwing', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-missing-')) tempRoots.push(root) diff --git a/src/main/ipc/native-chat.ts b/src/main/ipc/native-chat.ts index ad6117db0c1..3ed4e9b74a6 100644 --- a/src/main/ipc/native-chat.ts +++ b/src/main/ipc/native-chat.ts @@ -71,9 +71,17 @@ type LiveSubscription = { // same renderer can watch several panes, and a destroyed window tears down all // of its watchers — strict teardown to avoid fd leaks (plan U4 risk). const liveSubscriptions = new Map>() +// Why: unsubscribe and renderer destruction must invalidate async watcher setup +// before it can publish a late subscription into the live map. +const pendingSubscriptions = new Map>() const senderCleanupRegistered = new Set() function teardownSubscription(senderId: number, subscriptionId: string): void { + const pendingBySubId = pendingSubscriptions.get(senderId) + pendingBySubId?.delete(subscriptionId) + if (pendingBySubId?.size === 0) { + pendingSubscriptions.delete(senderId) + } const bySubId = liveSubscriptions.get(senderId) const live = bySubId?.get(subscriptionId) if (!live || !bySubId) { @@ -87,6 +95,9 @@ function teardownSubscription(senderId: number, subscriptionId: string): void { } function teardownAllForSender(senderId: number): void { + // The destroyed event can arrive before async subscription setup stores a watcher. + senderCleanupRegistered.delete(senderId) + pendingSubscriptions.delete(senderId) const bySubId = liveSubscriptions.get(senderId) if (!bySubId) { return @@ -95,7 +106,6 @@ function teardownAllForSender(senderId: number): void { live.subscription.unsubscribe() } liveSubscriptions.delete(senderId) - senderCleanupRegistered.delete(senderId) } function registerSenderCleanup(sender: WebContents): void { @@ -107,6 +117,27 @@ function registerSenderCleanup(sender: WebContents): void { sender.once('destroyed', () => teardownAllForSender(sender.id)) } +function beginPendingSubscription(senderId: number, subscriptionId: string): symbol { + teardownSubscription(senderId, subscriptionId) + const token = Symbol(subscriptionId) + const bySubId = pendingSubscriptions.get(senderId) ?? new Map() + bySubId.set(subscriptionId, token) + pendingSubscriptions.set(senderId, bySubId) + return token +} + +function takePendingSubscription(senderId: number, subscriptionId: string, token: symbol): boolean { + const bySubId = pendingSubscriptions.get(senderId) + if (bySubId?.get(subscriptionId) !== token) { + return false + } + bySubId.delete(subscriptionId) + if (bySubId.size === 0) { + pendingSubscriptions.delete(senderId) + } + return true +} + async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArgs): Promise { const sender = event.sender if (sender.isDestroyed()) { @@ -114,26 +145,32 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg } const { subscriptionId, agent, sessionId, transcriptPath } = args // Replace any prior subscription under the same id (session change/resubscribe). - teardownSubscription(sender.id, subscriptionId) + const pendingToken = beginPendingSubscription(sender.id, subscriptionId) registerSenderCleanup(sender) - const subscription = await subscribeNativeChatTranscript({ - agent, - sessionId, - transcriptPath, - onAppend: (messages) => { - if (sender.isDestroyed()) { - return + let subscription: NativeChatTranscriptSubscription + try { + subscription = await subscribeNativeChatTranscript({ + agent, + sessionId, + transcriptPath, + onAppend: (messages) => { + if (sender.isDestroyed()) { + return + } + const payload: NativeChatAppendedPayload = { subscriptionId, messages } + sender.send('nativeChat:appended', payload) } - const payload: NativeChatAppendedPayload = { subscriptionId, messages } - sender.send('nativeChat:appended', payload) - } - }) + }) + } catch { + takePendingSubscription(sender.id, subscriptionId, pendingToken) + return + } - // The window may have gone away (or the subscription been replaced) while we - // resolved the file path — don't register a now-orphaned watcher. - const stillCurrent = !sender.isDestroyed() - if (!stillCurrent) { + // Why: unmount, destruction, or a newer same-id subscribe can invalidate setup + // while path resolution is pending; only the owning token may publish its watcher. + const stillCurrent = takePendingSubscription(sender.id, subscriptionId, pendingToken) + if (sender.isDestroyed() || !stillCurrent) { subscription.unsubscribe() return } @@ -147,12 +184,26 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg liveSubscriptions.set(sender.id, bySubId) } -/** Test-only: drop all live transcript subscriptions between runs. */ +/** Test-only: drop all live and pending transcript subscriptions between runs. */ export function clearNativeChatSubscriptions(): void { - const senderIds = Array.from(liveSubscriptions.keys()) + const senderIds = new Set([...liveSubscriptions.keys(), ...pendingSubscriptions.keys()]) for (const senderId of senderIds) { teardownAllForSender(senderId) } + pendingSubscriptions.clear() + senderCleanupRegistered.clear() +} + +export function _getNativeChatSenderCleanupCountForTest(): number { + return senderCleanupRegistered.size +} + +export function _getNativeChatPendingSubscriptionCountForTest(): number { + let count = 0 + for (const bySubId of pendingSubscriptions.values()) { + count += bySubId.size + } + return count } export function registerNativeChatHandlers(): void { diff --git a/src/main/ipc/parcel-watcher-canary-directory.ts b/src/main/ipc/parcel-watcher-canary-directory.ts new file mode 100644 index 00000000000..28e7ec6518c --- /dev/null +++ b/src/main/ipc/parcel-watcher-canary-directory.ts @@ -0,0 +1,26 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +export function createWatcherCanaryDirectory(): string | null { + try { + return mkdtempSync(join(tmpdir(), 'orca-watcher-canary-')) + } catch (error) { + // Why: the canary is diagnostic only; read-only temp storage must not + // disable the crash-isolated watcher itself. + console.warn('[parcel-watcher-process] canary directory unavailable:', error) + return null + } +} + +export function removeWatcherCanaryDirectory(canaryDir: string | null): null { + if (!canaryDir) { + return null + } + try { + rmSync(canaryDir, { recursive: true, force: true }) + } catch { + // Canary cleanup is best-effort; watcher lifecycle must still complete. + } + return null +} diff --git a/src/main/ipc/parcel-watcher-child-launch.ts b/src/main/ipc/parcel-watcher-child-launch.ts new file mode 100644 index 00000000000..6a72bb98145 --- /dev/null +++ b/src/main/ipc/parcel-watcher-child-launch.ts @@ -0,0 +1,57 @@ +import { fork, type ChildProcess } from 'node:child_process' +import { writeFileSync } from 'node:fs' +import { + createWatcherCanaryDirectory, + removeWatcherCanaryDirectory +} from './parcel-watcher-canary-directory' +import type { WatcherToHostMessage } from './parcel-watcher-process-protocol' + +export type LaunchedWatcherChild = { + child: ChildProcess + canaryDir: string | null +} + +export function launchWatcherChild( + entryPath: string, + currentCanaryDir: string | null, + onMessage: (child: ChildProcess, message: WatcherToHostMessage) => void, + onGone: (child: ChildProcess, code?: number | null, signal?: NodeJS.Signals | null) => void +): LaunchedWatcherChild | null { + const canaryDir = currentCanaryDir ?? createWatcherCanaryDirectory() + let child: ChildProcess + try { + child = fork(entryPath, [], { + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + ...(canaryDir ? { ORCA_WATCHER_CANARY_DIR: canaryDir } : {}) + }, + ...(process.platform === 'win32' ? { windowsHide: true } : {}) + }) + } catch (error) { + removeWatcherCanaryDirectory(canaryDir) + console.error('[parcel-watcher-process] failed to fork watcher process:', error) + return null + } + const faultHarnessPidFile = process.env.ORCA_WATCHER_CHILD_PID_FILE + if (faultHarnessPidFile && child.pid) { + try { + // Why: exclusive creation lets the harness identify the child without a + // leaked test-only environment variable clobbering an existing file. + writeFileSync(faultHarnessPidFile, String(child.pid), { flag: 'wx' }) + } catch { + // Fault-injection observability must never affect watcher availability. + } + } + child.stderr?.on('data', (chunk: Buffer) => { + console.error('[parcel-watcher-process]', String(chunk).trimEnd()) + }) + child.on('message', (message) => onMessage(child, message as WatcherToHostMessage)) + // Why: Node can close IPC before emitting exit. Recover at disconnect so a + // concurrent subscribe cannot replace the child without restoring old roots. + child.on('disconnect', () => onGone(child)) + child.on('error', () => onGone(child)) + child.on('exit', (code, signal) => onGone(child, code, signal)) + return { child, canaryDir } +} diff --git a/src/main/ipc/parcel-watcher-crash-fuse.ts b/src/main/ipc/parcel-watcher-crash-fuse.ts new file mode 100644 index 00000000000..69717311ba5 --- /dev/null +++ b/src/main/ipc/parcel-watcher-crash-fuse.ts @@ -0,0 +1,24 @@ +const CRASH_WINDOW_MS = 30_000 +const MAX_CRASHES_PER_WINDOW = 3 + +export class WatcherProcessCrashFuse { + private crashTimes: number[] = [] + + recordCrash(now = Date.now()): void { + this.removeExpired(now) + this.crashTimes.push(now) + } + + isOpen(now = Date.now()): boolean { + this.removeExpired(now) + return this.crashTimes.length >= MAX_CRASHES_PER_WINDOW + } + + reset(): void { + this.crashTimes = [] + } + + private removeExpired(now: number): void { + this.crashTimes = this.crashTimes.filter((time) => now - time < CRASH_WINDOW_MS) + } +} diff --git a/src/main/ipc/parcel-watcher-event-delivery.test.ts b/src/main/ipc/parcel-watcher-event-delivery.test.ts new file mode 100644 index 00000000000..66c6232a63f --- /dev/null +++ b/src/main/ipc/parcel-watcher-event-delivery.test.ts @@ -0,0 +1,44 @@ +import { setImmediate } from 'node:timers/promises' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { statMock } = vi.hoisted(() => ({ statMock: vi.fn() })) + +vi.mock('node:fs/promises', () => ({ stat: statMock })) + +import { prepareWatcherProcessEvents } from './parcel-watcher-event-delivery' + +describe('prepareWatcherProcessEvents', () => { + beforeEach(() => { + statMock.mockReset() + }) + + it('caps directory metadata stat work across concurrent roots in one child', async () => { + let activeStats = 0 + let maxActiveStats = 0 + statMock.mockImplementation(async () => { + activeStats++ + maxActiveStats = Math.max(maxActiveStats, activeStats) + await setImmediate() + activeStats-- + return { isDirectory: () => false } + }) + const batches = Array.from({ length: 8 }, (_, rootIndex) => + Array.from({ length: 16 }, (_, eventIndex) => ({ + type: 'update' as const, + path: `/repo-${rootIndex}/file-${eventIndex}.ts` + })) + ) + + await Promise.all( + batches.map((events) => + prepareWatcherProcessEvents(events, { + includeDirectoryMetadata: true, + maxEventsPerBatch: 200 + }) + ) + ) + + expect(statMock).toHaveBeenCalledTimes(128) + expect(maxActiveStats).toBe(8) + }) +}) diff --git a/src/main/ipc/parcel-watcher-event-delivery.ts b/src/main/ipc/parcel-watcher-event-delivery.ts new file mode 100644 index 00000000000..54e8448801c --- /dev/null +++ b/src/main/ipc/parcel-watcher-event-delivery.ts @@ -0,0 +1,160 @@ +import { stat } from 'node:fs/promises' +import type { Event as ParcelWatcherEvent } from '@parcel/watcher' +import { MAX_BATCHED_WATCHER_EVENTS } from './filesystem-watcher-event-batch' +import type { + WatcherProcessDeliveryOptions, + WatcherProcessEvent +} from './parcel-watcher-process-protocol' + +const DIRECTORY_STAT_CONCURRENCY = 8 +let activeDirectoryStats = 0 +const directoryStatWaiters: (() => void)[] = [] + +export type WatcherProcessEventDeliveryQueue = { + enqueue(events: readonly ParcelWatcherEvent[]): void + close(): void +} + +async function acquireDirectoryStatSlot(): Promise { + if (activeDirectoryStats < DIRECTORY_STAT_CONCURRENCY) { + activeDirectoryStats++ + return + } + await new Promise((resolve) => directoryStatWaiters.push(resolve)) +} + +function releaseDirectoryStatSlot(): void { + const next = directoryStatWaiters.shift() + if (next) { + // Transfer the existing slot directly so a newly arriving task cannot + // overtake this waiter and temporarily exceed the global budget. + next() + return + } + activeDirectoryStats-- +} + +async function statWatcherEventPath(eventPath: string): Promise { + await acquireDirectoryStatSlot() + try { + return (await stat(eventPath)).isDirectory() + } finally { + releaseDirectoryStatSlot() + } +} + +async function mapWithConcurrency( + items: readonly T[], + limit: number, + mapper: (item: T) => Promise +): Promise { + const results = Array.from({ length: items.length }) + let cursor = 0 + const lane = async (): Promise => { + while (cursor < items.length) { + const index = cursor++ + results[index] = await mapper(items[index]) + } + } + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, lane)) + return results +} + +async function mapWatcherEvent( + event: ParcelWatcherEvent, + includeDirectoryMetadata: boolean +): Promise { + if (!includeDirectoryMetadata || event.type === 'delete') { + return { type: event.type, path: event.path } + } + let isDirectory = false + try { + isDirectory = await statWatcherEventPath(event.path) + } catch { + // Why: a path can vanish between the native event and metadata lookup. + // Treat unknown metadata as a file-like event so parent invalidation still runs. + } + return { type: event.type, path: event.path, isDirectory } +} + +export async function prepareWatcherProcessEvents( + events: readonly ParcelWatcherEvent[], + delivery: WatcherProcessDeliveryOptions | undefined +): Promise { + if (delivery?.maxEventsPerBatch !== undefined && events.length > delivery.maxEventsPerBatch) { + return null + } + if (delivery?.includeDirectoryMetadata !== true) { + return events.map((event) => ({ type: event.type, path: event.path })) + } + return mapWithConcurrency(events, DIRECTORY_STAT_CONCURRENCY, (event) => + mapWatcherEvent(event, true) + ) +} + +/** Keep at most one active and one bounded pending batch per subscription. */ +export function createWatcherProcessEventDeliveryQueue( + delivery: WatcherProcessDeliveryOptions | undefined, + deliver: (events: WatcherProcessEvent[] | null) => Promise, + onError: (error: unknown) => void +): WatcherProcessEventDeliveryQueue { + const eventLimit = delivery?.maxEventsPerBatch ?? MAX_BATCHED_WATCHER_EVENTS + let active = true + let draining = false + let pendingOverflow = false + let pendingEvents: ParcelWatcherEvent[] = [] + + const drain = async (): Promise => { + if (!active || draining) { + return + } + draining = true + try { + while (active && (pendingOverflow || pendingEvents.length > 0)) { + const overflowed = pendingOverflow + const events = pendingEvents + pendingOverflow = false + pendingEvents = [] + if (overflowed) { + await deliver(null) + continue + } + const prepared = await prepareWatcherProcessEvents(events, delivery) + if (active) { + await deliver(prepared) + } + } + } catch (error) { + if (active) { + onError(error) + } + } finally { + draining = false + if (active && (pendingOverflow || pendingEvents.length > 0)) { + void drain() + } + } + } + + return { + enqueue(events): void { + if (!active || events.length === 0 || pendingOverflow) { + return + } + if (events.length > eventLimit || pendingEvents.length + events.length > eventLimit) { + pendingEvents = [] + pendingOverflow = true + } else { + for (const event of events) { + pendingEvents.push(event) + } + } + void drain() + }, + close(): void { + active = false + pendingEvents = [] + pendingOverflow = false + } + } +} diff --git a/src/main/ipc/parcel-watcher-host-subscriptions.ts b/src/main/ipc/parcel-watcher-host-subscriptions.ts new file mode 100644 index 00000000000..71a3d82868f --- /dev/null +++ b/src/main/ipc/parcel-watcher-host-subscriptions.ts @@ -0,0 +1,155 @@ +import type { ChildProcess } from 'node:child_process' +import { + resetPendingSubscribeAttempt, + takePendingSubscribe +} from './parcel-watcher-pending-subscribe' +import { WatcherProcessFailure } from './parcel-watcher-process-failure' +import type { HostToWatcherMessage, WatcherToHostMessage } from './parcel-watcher-process-protocol' +import type { + WatcherProcessSubscription, + WatcherProcessSubscriptionRecord +} from './parcel-watcher-process-subscription' + +export function handleWatcherHostMessage( + message: Exclude< + WatcherToHostMessage, + { op: 'subscribe-started' } | { op: 'cancel-requires-restart' } + >, + records: Map, + pendingUnsubscribes: Map void>, + reportTerminalError: (record: WatcherProcessSubscriptionRecord, error: Error) => void, + killWatcherChildIfIdle: () => void +): void { + if (message.op === 'unsubscribed') { + const resolve = pendingUnsubscribes.get(message.id) + pendingUnsubscribes.delete(message.id) + resolve?.() + return + } + const record = records.get(message.id) + if (!record) { + return + } + if (message.op === 'subscribed') { + resetPendingSubscribeAttempt(record) + takePendingSubscribe(record)?.resolve() + if (record.interrupted) { + record.interrupted = false + record.hooks.onInterruption?.() + } + return + } + if (message.op === 'subscribe-failed') { + records.delete(message.id) + resetPendingSubscribeAttempt(record) + const pending = takePendingSubscribe(record) + const error = new WatcherProcessFailure(message.message, 'subscription', 'subscribe_failed') + if (pending) { + pending.reject(error) + } else { + reportTerminalError(record, error) + } + killWatcherChildIfIdle() + return + } + if (message.op === 'events') { + record.callback(null, message.events) + return + } + if (message.op === 'overflow') { + record.hooks.onOverflow?.() + return + } + record.callback(new Error(message.message), []) +} + +export function reportWatcherTerminalError( + record: WatcherProcessSubscriptionRecord, + error: Error +): void { + if (record.hooks.onTerminalError) { + record.hooks.onTerminalError(error) + return + } + record.callback(error, []) +} + +export function failAllWatcherSubscriptions( + records: Map, + error: Error +): void { + // Snapshot first: onTerminalError hooks can dispose the supervisor and clear `records`. + for (const record of Array.from(records.values())) { + resetPendingSubscribeAttempt(record) + const pending = takePendingSubscribe(record) + if (pending) { + pending.reject(error) + } else { + reportWatcherTerminalError(record, error) + } + } + records.clear() +} + +export function resolvePendingWatcherUnsubscribes( + pendingUnsubscribes: Map void> +): void { + for (const resolve of pendingUnsubscribes.values()) { + resolve() + } + pendingUnsubscribes.clear() +} + +export function disposeWatcherSupervisorSubscriptions( + records: Map, + pendingUnsubscribes: Map void>, + cancelledSubscribesAwaitingChild: Set, + error: Error +): void { + for (const record of records.values()) { + resetPendingSubscribeAttempt(record) + takePendingSubscribe(record)?.reject(error) + } + resolvePendingWatcherUnsubscribes(pendingUnsubscribes) + cancelledSubscribesAwaitingChild.clear() + records.clear() +} + +export type CreateHostWatcherSubscriptionOptions = { + record: WatcherProcessSubscriptionRecord + records: Map + pendingUnsubscribes: Map void> + getChild: () => ChildProcess | null + killWatcherChildIfIdle: () => void + sendToChild: (child: ChildProcess, message: HostToWatcherMessage) => void +} + +export function createHostWatcherSubscription({ + record, + records, + pendingUnsubscribes, + getChild, + killWatcherChildIfIdle, + sendToChild +}: CreateHostWatcherSubscriptionOptions): WatcherProcessSubscription { + return { + unsubscribe: (): Promise => { + if (!records.delete(record.id)) { + return Promise.resolve() + } + resetPendingSubscribeAttempt(record) + const child = getChild() + if (!child?.connected) { + return Promise.resolve() + } + if (records.size === 0) { + killWatcherChildIfIdle() + return Promise.resolve() + } + return new Promise((resolve) => { + pendingUnsubscribes.set(record.id, resolve) + sendToChild(child, { op: 'unsubscribe', id: record.id }) + }) + } + } +} diff --git a/src/main/ipc/parcel-watcher-in-process-fallback.ts b/src/main/ipc/parcel-watcher-in-process-fallback.ts new file mode 100644 index 00000000000..bcd2812d35d --- /dev/null +++ b/src/main/ipc/parcel-watcher-in-process-fallback.ts @@ -0,0 +1,124 @@ +import type * as ParcelWatcher from '@parcel/watcher' +import { createWatcherProcessEventDeliveryQueue } from './parcel-watcher-event-delivery' +import { WatcherProcessFailure } from './parcel-watcher-process-failure' +import type { + WatcherProcessCallback, + WatcherProcessHooks, + WatcherProcessSubscription +} from './parcel-watcher-process-subscription' +import type { WatcherProcessSubscribeOptions } from './parcel-watcher-process-protocol' + +/** + * Use Parcel directly when Vitest mocks it. Production builds fail closed if + * the crash-isolated child entry is unavailable. + */ +export async function subscribeWithInProcessWatcher( + dir: string, + callback: WatcherProcessCallback, + opts: WatcherProcessSubscribeOptions, + hooks: WatcherProcessHooks +): Promise { + if (hooks.signal?.aborted) { + throw new WatcherProcessFailure( + 'file watcher subscription aborted', + 'subscription', + 'subscribe_aborted' + ) + } + let abortListener: (() => void) | undefined + let timer: ReturnType | undefined + const cancellation = new Promise((_resolve, reject) => { + if (hooks.signal) { + abortListener = () => + reject( + new WatcherProcessFailure( + 'file watcher subscription aborted', + 'subscription', + 'subscribe_aborted' + ) + ) + hooks.signal.addEventListener('abort', abortListener, { once: true }) + } + if (hooks.subscribeTimeoutMs !== undefined) { + timer = setTimeout(() => { + reject( + new WatcherProcessFailure( + `file watcher subscription timed out after ${hooks.subscribeTimeoutMs}ms`, + 'subscription', + 'subscribe_timeout' + ) + ) + }, hooks.subscribeTimeoutMs) + timer.unref?.() + } + }) + const clearPendingControls = (): void => { + if (abortListener && hooks.signal) { + hooks.signal.removeEventListener('abort', abortListener) + } + if (timer) { + clearTimeout(timer) + } + } + let watcher: typeof ParcelWatcher + try { + // Why: setup ownership starts before module loading; an abort or timeout + // during the import must settle the caller just like one during the crawl. + watcher = await Promise.race([import('@parcel/watcher'), cancellation]) + } catch (error) { + clearPendingControls() + throw error + } + let active = true + const eventDelivery = createWatcherProcessEventDeliveryQueue( + hooks.delivery, + async (events) => { + if (!active) { + return + } + if (events === null) { + hooks.onOverflow?.() + } else { + callback(null, events) + } + }, + (error) => callback(error instanceof Error ? error : new Error(String(error)), []) + ) + const subscriptionPromise = watcher.subscribe( + dir, + (err, events) => { + if (!active) { + return + } + if (err) { + callback(err, []) + return + } + eventDelivery.enqueue(events) + }, + opts as ParcelWatcher.Options + ) + let subscription: ParcelWatcher.AsyncSubscription + try { + subscription = await Promise.race([subscriptionPromise, cancellation]) + } catch (error) { + active = false + eventDelivery.close() + clearPendingControls() + // Why: Parcel cannot cancel a crawl in-process. If it eventually finishes, + // release the late native handle instead of leaking it into the test host. + // Swallow unsubscribe failures so they never surface as unhandled rejections. + void subscriptionPromise + .then((lateSubscription) => lateSubscription.unsubscribe()) + .catch(() => undefined) + throw error + } + clearPendingControls() + return { + unsubscribe: async (): Promise => { + active = false + eventDelivery.close() + await subscription.unsubscribe() + } + } +} diff --git a/src/main/ipc/parcel-watcher-pending-subscribe.ts b/src/main/ipc/parcel-watcher-pending-subscribe.ts new file mode 100644 index 00000000000..67a020b9d5e --- /dev/null +++ b/src/main/ipc/parcel-watcher-pending-subscribe.ts @@ -0,0 +1,105 @@ +import { WatcherProcessFailure } from './parcel-watcher-process-failure' +import type { + PendingWatcherProcessSubscribe, + WatcherProcessSubscriptionRecord +} from './parcel-watcher-process-subscription' + +export function installPendingSubscribeControls( + record: WatcherProcessSubscriptionRecord, + cancel: (error: WatcherProcessFailure) => void +): void { + const pending = record.pendingSubscribe + if (!pending) { + return + } + if (record.hooks.signal) { + pending.abortListener = () => + cancel( + new WatcherProcessFailure( + 'file watcher subscription aborted', + 'subscription', + 'subscribe_aborted' + ) + ) + record.hooks.signal.addEventListener('abort', pending.abortListener, { once: true }) + } +} + +export function startPendingSubscribeTimeout( + record: WatcherProcessSubscriptionRecord, + cancel: (error: WatcherProcessFailure) => void +): void { + const pending = record.pendingSubscribe + if (!pending) { + return + } + record.crawlStarted = true + if (pending.timer || record.hooks.subscribeTimeoutMs === undefined) { + return + } + pending.timer = setTimeout(() => { + cancel( + new WatcherProcessFailure( + `file watcher subscription timed out after ${record.hooks.subscribeTimeoutMs}ms`, + 'subscription', + 'subscribe_timeout' + ) + ) + }, record.hooks.subscribeTimeoutMs) + pending.timer.unref?.() +} + +export function startInterruptedSubscribeTimeout( + record: WatcherProcessSubscriptionRecord, + cancel: (error: WatcherProcessFailure) => void +): void { + if ( + !record.interrupted || + record.pendingSubscribe || + record.resubscribeTimer || + record.hooks.subscribeTimeoutMs === undefined + ) { + return + } + record.crawlStarted = true + record.resubscribeTimer = setTimeout(() => { + cancel( + new WatcherProcessFailure( + `file watcher resubscription timed out after ${record.hooks.subscribeTimeoutMs}ms`, + 'subscription', + 'subscribe_timeout' + ) + ) + }, record.hooks.subscribeTimeoutMs) + record.resubscribeTimer.unref?.() +} + +export function resetPendingSubscribeAttempt(record: WatcherProcessSubscriptionRecord): void { + record.crawlStarted = false + if (record.resubscribeTimer) { + clearTimeout(record.resubscribeTimer) + record.resubscribeTimer = undefined + } + const pending = record.pendingSubscribe + if (pending?.timer) { + clearTimeout(pending.timer) + pending.timer = undefined + } +} + +export function takePendingSubscribe( + record: WatcherProcessSubscriptionRecord +): PendingWatcherProcessSubscribe | undefined { + const pending = record.pendingSubscribe + if (!pending) { + return undefined + } + record.pendingSubscribe = undefined + if (pending.abortListener && record.hooks.signal) { + record.hooks.signal.removeEventListener('abort', pending.abortListener) + } + if (pending.timer) { + clearTimeout(pending.timer) + } + return pending +} diff --git a/src/main/ipc/parcel-watcher-process-entry.test.ts b/src/main/ipc/parcel-watcher-process-entry.test.ts index 13f808ab650..19740f17fff 100644 --- a/src/main/ipc/parcel-watcher-process-entry.test.ts +++ b/src/main/ipc/parcel-watcher-process-entry.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { subscribeMock, writeFileSyncMock } = vi.hoisted(() => ({ +const { statMock, subscribeMock, writeFileSyncMock } = vi.hoisted(() => ({ + statMock: vi.fn(), subscribeMock: vi.fn(), writeFileSyncMock: vi.fn() })) @@ -10,21 +11,25 @@ vi.mock('node:fs', () => ({ rmSync: vi.fn(), writeFileSync: writeFileSyncMock })) +vi.mock('node:fs/promises', () => ({ stat: statMock })) vi.mock('@parcel/watcher', () => ({ subscribe: subscribeMock })) describe('parcel watcher process canary', () => { let originalMessageListeners: ReturnType let originalExitListeners: ReturnType let originalDisconnectListeners: ReturnType + let originalSend: typeof process.send beforeEach(() => { vi.useFakeTimers() vi.resetModules() subscribeMock.mockReset() + statMock.mockReset() writeFileSyncMock.mockReset() originalMessageListeners = process.listeners('message') originalExitListeners = process.listeners('exit') originalDisconnectListeners = process.listeners('disconnect') + originalSend = process.send }) afterEach(() => { @@ -43,6 +48,7 @@ describe('parcel watcher process canary', () => { process.off('disconnect', listener) } } + process.send = originalSend vi.useRealTimers() vi.restoreAllMocks() }) @@ -201,6 +207,80 @@ describe('parcel watcher process canary', () => { expect(exitSpy).toHaveBeenCalledWith(2) }) + it('removes a cancelled queued crawl before it reaches Parcel', async () => { + let finishActiveCrawl: + | ((subscription: { unsubscribe: () => Promise }) => void) + | undefined + subscribeMock.mockResolvedValueOnce({ unsubscribe: vi.fn() }).mockReturnValueOnce( + new Promise((resolve) => { + finishActiveCrawl = resolve + }) + ) + const sendMock = vi.fn() + process.send = sendMock + + await import('./parcel-watcher-process-entry') + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'subscribe', id: 1, dir: '/active', opts: {} }) + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'subscribe', id: 2, dir: '/queued', opts: {} }) + process.emit('message', { op: 'cancel-subscribe', id: 2 }) + + // Why: non-restart cancel reuses async unsubscribe teardown; drain the + // exclusive lifecycle queue after the active crawl finishes. + finishActiveCrawl?.({ unsubscribe: vi.fn().mockResolvedValue(undefined) }) + await vi.advanceTimersByTimeAsync(0) + + expect(sendMock).toHaveBeenCalledWith({ op: 'unsubscribed', id: 2 }) + expect(subscribeMock).toHaveBeenCalledTimes(2) + expect(sendMock).not.toHaveBeenCalledWith({ op: 'subscribe-started', id: 2 }) + expect(sendMock).not.toHaveBeenCalledWith({ op: 'subscribed', id: 2 }) + }) + + it('unsubscribes a late cancel after the crawl already finished', async () => { + const unsubscribe = vi.fn().mockResolvedValue(undefined) + subscribeMock.mockResolvedValueOnce({ unsubscribe: vi.fn() }).mockResolvedValueOnce({ + unsubscribe + }) + const sendMock = vi.fn() + process.send = sendMock + + await import('./parcel-watcher-process-entry') + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'subscribe', id: 1, dir: '/finished', opts: {} }) + await vi.advanceTimersByTimeAsync(0) + + expect(sendMock).toHaveBeenCalledWith({ op: 'subscribed', id: 1 }) + process.emit('message', { op: 'cancel-subscribe', id: 1 }) + await vi.advanceTimersByTimeAsync(0) + + expect(unsubscribe).toHaveBeenCalledTimes(1) + expect(sendMock).toHaveBeenCalledWith({ op: 'unsubscribed', id: 1 }) + expect(sendMock).not.toHaveBeenCalledWith({ op: 'cancel-requires-restart', id: 1 }) + }) + + it('asks the host to restart when an active crawl is cancelled', async () => { + let finishCrawl: ((subscription: { unsubscribe: () => Promise }) => void) | undefined + subscribeMock.mockResolvedValueOnce({ unsubscribe: vi.fn() }).mockReturnValueOnce( + new Promise((resolve) => { + finishCrawl = resolve + }) + ) + const sendMock = vi.fn() + process.send = sendMock + + await import('./parcel-watcher-process-entry') + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'subscribe', id: 1, dir: '/active', opts: {} }) + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'cancel-subscribe', id: 1 }) + + expect(sendMock).toHaveBeenCalledWith({ op: 'cancel-requires-restart', id: 1 }) + finishCrawl?.({ unsubscribe: vi.fn().mockResolvedValue(undefined) }) + await vi.advanceTimersByTimeAsync(0) + expect(sendMock).not.toHaveBeenCalledWith({ op: 'subscribed', id: 1 }) + }) + it('still restarts after consecutive missed events once every subscription is live', async () => { subscribeMock.mockResolvedValue({ unsubscribe: vi.fn() }) const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never) @@ -215,4 +295,198 @@ describe('parcel watcher process canary', () => { expect(writeFileSyncMock).toHaveBeenCalledTimes(3) expect(exitSpy).toHaveBeenCalledWith(2) }) + + it('collapses and enriches runtime batches before they cross child IPC', async () => { + let callback: + | ((err: Error | null, events: { type: string; path: string }[]) => void) + | undefined + subscribeMock + .mockResolvedValueOnce({ unsubscribe: vi.fn() }) + .mockImplementationOnce(async (_dir, nextCallback) => { + callback = nextCallback + return { unsubscribe: vi.fn() } + }) + statMock.mockImplementation(async (eventPath: string) => ({ + isDirectory: () => eventPath.endsWith('/dir') + })) + const sendMock = vi.fn() + process.send = sendMock + + await import('./parcel-watcher-process-entry') + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { + op: 'subscribe', + id: 1, + dir: '/repo', + opts: {}, + delivery: { includeDirectoryMetadata: true, maxEventsPerBatch: 2 } + }) + await vi.advanceTimersByTimeAsync(0) + + callback?.(null, [ + { type: 'update', path: '/repo/file.txt' }, + { type: 'create', path: '/repo/dir' } + ]) + await vi.advanceTimersByTimeAsync(0) + expect(sendMock).toHaveBeenCalledWith( + { + op: 'events', + id: 1, + events: [ + { type: 'update', path: '/repo/file.txt', isDirectory: false }, + { type: 'create', path: '/repo/dir', isDirectory: true } + ] + }, + expect.any(Function) + ) + + sendMock.mockClear() + callback?.(null, [ + { type: 'update', path: '/repo/a' }, + { type: 'update', path: '/repo/b' }, + { type: 'update', path: '/repo/c' } + ]) + await vi.advanceTimersByTimeAsync(0) + expect(sendMock).toHaveBeenCalledWith({ op: 'overflow', id: 1 }, expect.any(Function)) + expect(statMock).toHaveBeenCalledTimes(2) + }) + + it('reports an FSEvents overflow and keeps delivering later events', async () => { + let callback: + | ((err: Error | null, events: { type: string; path: string }[]) => void) + | undefined + subscribeMock + .mockResolvedValueOnce({ unsubscribe: vi.fn() }) + .mockImplementationOnce(async (_dir, nextCallback) => { + callback = nextCallback + return { unsubscribe: vi.fn() } + }) + const sendMock = vi.fn() + process.send = sendMock + + await import('./parcel-watcher-process-entry') + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'subscribe', id: 1, dir: '/repo', opts: {} }) + await vi.advanceTimersByTimeAsync(0) + + callback?.( + new Error('Events were dropped by the FSEvents client. File system must be re-scanned.'), + [] + ) + callback?.(null, [{ type: 'update', path: '/repo/after-overflow.txt' }]) + await vi.advanceTimersByTimeAsync(0) + + expect(sendMock).toHaveBeenCalledWith({ + op: 'watch-error', + id: 1, + message: 'Events were dropped by the FSEvents client. File system must be re-scanned.' + }) + expect(sendMock).toHaveBeenCalledWith( + { + op: 'events', + id: 1, + events: [{ type: 'update', path: '/repo/after-overflow.txt' }] + }, + expect.any(Function) + ) + }) + + it('bounds pending event batches while child IPC reports backpressure', async () => { + let callback: + | ((err: Error | null, events: { type: string; path: string }[]) => void) + | undefined + let releaseFirstEvent: (() => void) | undefined + const sentEventPaths: string[] = [] + subscribeMock + .mockResolvedValueOnce({ unsubscribe: vi.fn() }) + .mockImplementationOnce(async (_dir, nextCallback) => { + callback = nextCallback + return { unsubscribe: vi.fn() } + }) + process.send = vi.fn((message, onSent) => { + if (message.op !== 'events') { + return true + } + sentEventPaths.push(message.events[0]?.path ?? '') + if (!releaseFirstEvent) { + releaseFirstEvent = onSent + return false + } + return true + }) as typeof process.send + + await import('./parcel-watcher-process-entry') + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { + op: 'subscribe', + id: 1, + dir: '/repo', + opts: {}, + delivery: { includeDirectoryMetadata: true, maxEventsPerBatch: 2 } + }) + await vi.advanceTimersByTimeAsync(0) + + callback?.(null, [{ type: 'update', path: '/repo/first.txt' }]) + callback?.(null, [{ type: 'update', path: '/repo/second.txt' }]) + callback?.(null, [{ type: 'update', path: '/repo/third.txt' }]) + callback?.(null, [{ type: 'update', path: '/repo/fourth.txt' }]) + await vi.advanceTimersByTimeAsync(0) + expect(sentEventPaths).toEqual(['/repo/first.txt']) + + releaseFirstEvent?.() + await vi.advanceTimersByTimeAsync(0) + expect(sentEventPaths).toEqual(['/repo/first.txt']) + expect(process.send).toHaveBeenCalledWith({ op: 'overflow', id: 1 }, expect.any(Function)) + expect(statMock).toHaveBeenCalledTimes(1) + }) + + it('drops queued event work when the subscription is removed', async () => { + let callback: + | ((err: Error | null, events: { type: string; path: string }[]) => void) + | undefined + let releaseFirstEvent: (() => void) | undefined + const unsubscribe = vi.fn().mockResolvedValue(undefined) + subscribeMock + .mockResolvedValueOnce({ unsubscribe: vi.fn() }) + .mockImplementationOnce(async (_dir, nextCallback) => { + callback = nextCallback + return { unsubscribe } + }) + process.send = vi.fn((message, onSent) => { + if (message.op === 'events' && !releaseFirstEvent) { + releaseFirstEvent = onSent + return false + } + return true + }) as typeof process.send + + await import('./parcel-watcher-process-entry') + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { + op: 'subscribe', + id: 1, + dir: '/repo', + opts: {}, + delivery: { includeDirectoryMetadata: true, maxEventsPerBatch: 2 } + }) + await vi.advanceTimersByTimeAsync(0) + + callback?.(null, [{ type: 'update', path: '/repo/first.txt' }]) + callback?.(null, [{ type: 'update', path: '/repo/queued.txt' }]) + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'unsubscribe', id: 1 }) + await vi.advanceTimersByTimeAsync(0) + releaseFirstEvent?.() + await vi.advanceTimersByTimeAsync(0) + + expect(unsubscribe).toHaveBeenCalledTimes(1) + expect(statMock).toHaveBeenCalledTimes(1) + expect(process.send).not.toHaveBeenCalledWith( + expect.objectContaining({ + op: 'events', + events: [expect.objectContaining({ path: '/repo/queued.txt' })] + }), + expect.any(Function) + ) + }) }) diff --git a/src/main/ipc/parcel-watcher-process-entry.ts b/src/main/ipc/parcel-watcher-process-entry.ts index 6a296eb2af9..1e043739a4a 100644 --- a/src/main/ipc/parcel-watcher-process-entry.ts +++ b/src/main/ipc/parcel-watcher-process-entry.ts @@ -1,4 +1,4 @@ -// Forked (ELECTRON_RUN_AS_NODE) child that hosts all local @parcel/watcher +// Forked (ELECTRON_RUN_AS_NODE) child that hosts its assigned @parcel/watcher // subscriptions. Why: watcher.node has native teardown races that fail-fast // the hosting process (issue #7547, 0xc0000409 on Windows; same class as // #5377/#6635). Running the native module here turns a watcher fault into a @@ -8,28 +8,16 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import type * as ParcelWatcher from '@parcel/watcher' - -export type WatcherProcessEvent = { - type: 'create' | 'update' | 'delete' - path: string -} - -export type WatcherProcessSubscribeOptions = { - ignore?: string[] - ignoreGlobs?: string[] - backend?: string -} - -export type HostToWatcherMessage = - | { op: 'subscribe'; id: number; dir: string; opts: WatcherProcessSubscribeOptions } - | { op: 'unsubscribe'; id: number } - -export type WatcherToHostMessage = - | { op: 'subscribed'; id: number } - | { op: 'subscribe-failed'; id: number; message: string } - | { op: 'events'; id: number; events: WatcherProcessEvent[] } - | { op: 'watch-error'; id: number; message: string } - | { op: 'unsubscribed'; id: number } +import { + createWatcherProcessEventDeliveryQueue, + type WatcherProcessEventDeliveryQueue +} from './parcel-watcher-event-delivery' +import type { + HostToWatcherMessage, + WatcherProcessDeliveryOptions, + WatcherProcessSubscribeOptions, + WatcherToHostMessage +} from './parcel-watcher-process-protocol' function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err) @@ -49,8 +37,9 @@ const CANARY_MAX_MISSES = 2 async function startCanary(getStableActivityRevision: () => number | null): Promise { let canaryDir: string let lastEventAt = 0 + const configuredCanaryDir = process.env.ORCA_WATCHER_CANARY_DIR try { - canaryDir = mkdtempSync(join(tmpdir(), 'orca-watcher-canary-')) + canaryDir = configuredCanaryDir ?? mkdtempSync(join(tmpdir(), 'orca-watcher-canary-')) const watcher = await import('@parcel/watcher') // Why: pin the Windows backend like the main subscriptions do, so the // canary never probes for Watchman. @@ -71,6 +60,9 @@ async function startCanary(getStableActivityRevision: () => number | null): Prom return } process.on('exit', () => { + if (configuredCanaryDir) { + return + } try { rmSync(canaryDir, { recursive: true, force: true }) } catch { @@ -125,12 +117,34 @@ function main(): void { } } + const sendEventWithBackpressure = (message: WatcherToHostMessage): Promise => { + return new Promise((resolve) => { + let settled = false + const finish = (): void => { + if (!settled) { + settled = true + resolve() + } + } + try { + const accepted = process.send?.(message, finish) + if (accepted !== false) { + finish() + } + } catch { + finish() + } + }) + } + // Subscribe promises are kept (not just subscriptions) so an unsubscribe // that races a still-crawling subscribe awaits it instead of leaking the // native handle — on Windows a leaked handle keeps the worktree dir locked. const subscriptions = new Map>() const liveSubscriptionIds = new Set() + const eventDeliveries = new Map() let pendingSubscriptionCrawls = 0 + let activeSubscriptionCrawlId: number | null = null let subscriptionActivityRevision = 0 let nativeLifecycleTail = Promise.resolve() @@ -156,11 +170,29 @@ function main(): void { const handleSubscribe = async ( id: number, dir: string, - opts: WatcherProcessSubscribeOptions + opts: WatcherProcessSubscribeOptions, + delivery: WatcherProcessDeliveryOptions | undefined ): Promise => { + const eventDelivery = createWatcherProcessEventDeliveryQueue( + delivery, + async (events) => { + await sendEventWithBackpressure( + events === null ? { op: 'overflow', id } : { op: 'events', id, events } + ) + }, + (deliveryError) => { + send({ op: 'watch-error', id, message: errorMessage(deliveryError) }) + } + ) + eventDeliveries.set(id, eventDelivery) try { const subscription = await runNativeWatcherLifecycleExclusive(async () => { + if (!subscriptions.has(id)) { + return null + } + activeSubscriptionCrawlId = id beginSubscriptionCrawl() + send({ op: 'subscribe-started', id }) try { const watcher = await import('@parcel/watcher') return await watcher.subscribe( @@ -170,31 +202,32 @@ function main(): void { send({ op: 'watch-error', id, message: errorMessage(err) }) return } - if (events.length > 0) { - send({ - op: 'events', - id, - events: events.map((event) => ({ type: event.type, path: event.path })) - }) - } + eventDelivery.enqueue(events) }, opts as ParcelWatcher.Options ) } finally { + activeSubscriptionCrawlId = null finishSubscriptionCrawl() } }) // An unsubscribe can remove the record while subscribe() is crawling. // Only advertise it as live if it is still owned by this process. - if (subscriptions.has(id)) { + const stillOwned = subscriptions.has(id) + if (stillOwned) { liveSubscriptionIds.add(id) + send({ op: 'subscribed', id }) } - send({ op: 'subscribed', id }) return subscription } catch (err) { + const stillOwned = subscriptions.has(id) + eventDelivery.close() + eventDeliveries.delete(id) subscriptions.delete(id) liveSubscriptionIds.delete(id) - send({ op: 'subscribe-failed', id, message: errorMessage(err) }) + if (stillOwned) { + send({ op: 'subscribe-failed', id, message: errorMessage(err) }) + } return null } } @@ -206,6 +239,8 @@ function main(): void { const pending = subscriptions.get(id) subscriptions.delete(id) liveSubscriptionIds.delete(id) + eventDeliveries.get(id)?.close() + eventDeliveries.delete(id) try { // Why: Parcel already serializes crawl and teardown on one backend mutex. // Mirror that ordering here so neither operation can mask a canary miss. @@ -223,16 +258,40 @@ function main(): void { send({ op: 'unsubscribed', id }) } + const handleCancelSubscribe = (id: number): void => { + // Why: an in-flight native crawl cannot be cancelled safely; ask the host to + // kill this child instead of risking the Parcel teardown deadlock. + if (activeSubscriptionCrawlId === id) { + subscriptions.delete(id) + liveSubscriptionIds.delete(id) + eventDeliveries.get(id)?.close() + eventDeliveries.delete(id) + send({ op: 'cancel-requires-restart', id }) + return + } + // Why: cancel can race a crawl that already finished. Reuse unsubscribe + // teardown so a live native handle is released instead of leaked (Windows + // keeps the worktree locked while the handle stays open). + void handleUnsubscribe(id) + } + process.on('message', (message: HostToWatcherMessage) => { if (!message || typeof message !== 'object') { return } if (message.op === 'subscribe') { - subscriptions.set(message.id, handleSubscribe(message.id, message.dir, message.opts)) + subscriptions.set( + message.id, + handleSubscribe(message.id, message.dir, message.opts, message.delivery) + ) return } if (message.op === 'unsubscribe') { void handleUnsubscribe(message.id) + return + } + if (message.op === 'cancel-subscribe') { + handleCancelSubscribe(message.id) } }) diff --git a/src/main/ipc/parcel-watcher-process-failure.ts b/src/main/ipc/parcel-watcher-process-failure.ts new file mode 100644 index 00000000000..80eb79ddd33 --- /dev/null +++ b/src/main/ipc/parcel-watcher-process-failure.ts @@ -0,0 +1,32 @@ +export type WatcherProcessFailureScope = 'subscription' | 'supervisor' + +export type WatcherProcessFailureCode = + | 'entry_missing' + | 'process_unavailable' + | 'subscribe_aborted' + | 'subscribe_failed' + | 'subscribe_timeout' + | 'supervisor_crash_fuse' + | 'supervisor_disposed' + +export class WatcherProcessFailure extends Error { + constructor( + message: string, + readonly scope: WatcherProcessFailureScope, + readonly code: WatcherProcessFailureCode + ) { + super(message) + this.name = 'WatcherProcessFailure' + } +} + +export function watcherHostFailure( + message: string, + code: WatcherProcessFailureCode +): WatcherProcessFailure { + return new WatcherProcessFailure(message, 'supervisor', code) +} + +export function isWatcherProcessFailure(error: unknown): error is WatcherProcessFailure { + return error instanceof WatcherProcessFailure +} diff --git a/src/main/ipc/parcel-watcher-process-protocol.ts b/src/main/ipc/parcel-watcher-process-protocol.ts new file mode 100644 index 00000000000..17dbcc36a88 --- /dev/null +++ b/src/main/ipc/parcel-watcher-process-protocol.ts @@ -0,0 +1,37 @@ +export type WatcherProcessEvent = { + type: 'create' | 'update' | 'delete' + path: string + isDirectory?: boolean +} + +export type WatcherProcessSubscribeOptions = { + ignore?: string[] + ignoreGlobs?: string[] + backend?: string +} + +export type WatcherProcessDeliveryOptions = { + includeDirectoryMetadata?: boolean + maxEventsPerBatch?: number +} + +export type HostToWatcherMessage = + | { + op: 'subscribe' + id: number + dir: string + opts: WatcherProcessSubscribeOptions + delivery?: WatcherProcessDeliveryOptions + } + | { op: 'unsubscribe'; id: number } + | { op: 'cancel-subscribe'; id: number } + +export type WatcherToHostMessage = + | { op: 'subscribe-started'; id: number } + | { op: 'subscribed'; id: number } + | { op: 'subscribe-failed'; id: number; message: string } + | { op: 'events'; id: number; events: WatcherProcessEvent[] } + | { op: 'overflow'; id: number } + | { op: 'watch-error'; id: number; message: string } + | { op: 'cancel-requires-restart'; id: number } + | { op: 'unsubscribed'; id: number } diff --git a/src/main/ipc/parcel-watcher-process-subscription.ts b/src/main/ipc/parcel-watcher-process-subscription.ts new file mode 100644 index 00000000000..54489b86397 --- /dev/null +++ b/src/main/ipc/parcel-watcher-process-subscription.ts @@ -0,0 +1,35 @@ +import type { + WatcherProcessDeliveryOptions, + WatcherProcessEvent, + WatcherProcessSubscribeOptions +} from './parcel-watcher-process-protocol' + +export type WatcherProcessCallback = (err: Error | null, events: WatcherProcessEvent[]) => void +export type WatcherProcessSubscription = { unsubscribe(): Promise } +export type WatcherProcessHooks = { + delivery?: WatcherProcessDeliveryOptions + onInterruption?: () => void + onOverflow?: () => void + onTerminalError?: (error: Error) => void + signal?: AbortSignal + subscribeTimeoutMs?: number +} + +export type PendingWatcherProcessSubscribe = { + resolve: () => void + reject: (err: Error) => void + abortListener?: () => void + timer?: ReturnType +} + +export type WatcherProcessSubscriptionRecord = { + id: number + dir: string + opts: WatcherProcessSubscribeOptions + callback: WatcherProcessCallback + hooks: WatcherProcessHooks + interrupted: boolean + crawlStarted: boolean + resubscribeTimer?: ReturnType + pendingSubscribe?: PendingWatcherProcessSubscribe +} diff --git a/src/main/ipc/parcel-watcher-process-supervisor-options.ts b/src/main/ipc/parcel-watcher-process-supervisor-options.ts new file mode 100644 index 00000000000..47f4282a7e6 --- /dev/null +++ b/src/main/ipc/parcel-watcher-process-supervisor-options.ts @@ -0,0 +1,4 @@ +export type WatcherProcessSupervisorOptions = { + entryPath?: string + useInProcessVitestFallback?: boolean +} diff --git a/src/main/ipc/parcel-watcher-process-supervisor.ts b/src/main/ipc/parcel-watcher-process-supervisor.ts new file mode 100644 index 00000000000..9be9b232b08 --- /dev/null +++ b/src/main/ipc/parcel-watcher-process-supervisor.ts @@ -0,0 +1,316 @@ +import type { ChildProcess } from 'node:child_process' +import { existsSync } from 'node:fs' +import { getWatcherProcessEntryPath } from './parcel-watcher-entry-path' +import { removeWatcherCanaryDirectory } from './parcel-watcher-canary-directory' +import { launchWatcherChild } from './parcel-watcher-child-launch' +import { WatcherProcessCrashFuse } from './parcel-watcher-crash-fuse' +import { + disposeWatcherSupervisorSubscriptions, + failAllWatcherSubscriptions, + handleWatcherHostMessage, + reportWatcherTerminalError, + resolvePendingWatcherUnsubscribes +} from './parcel-watcher-host-subscriptions' +import { + resetPendingSubscribeAttempt, + startInterruptedSubscribeTimeout, + startPendingSubscribeTimeout, + takePendingSubscribe +} from './parcel-watcher-pending-subscribe' +import { watcherHostFailure } from './parcel-watcher-process-failure' +import type { WatcherProcessFailure } from './parcel-watcher-process-failure' +import type { + HostToWatcherMessage, + WatcherProcessSubscribeOptions, + WatcherToHostMessage +} from './parcel-watcher-process-protocol' +import type { + WatcherProcessCallback, + WatcherProcessHooks, + WatcherProcessSubscription, + WatcherProcessSubscriptionRecord +} from './parcel-watcher-process-subscription' +import type { WatcherProcessSupervisorOptions } from './parcel-watcher-process-supervisor-options' +import { subscribeThroughWatcherSupervisor } from './parcel-watcher-supervisor-subscribe' + +export type { + WatcherProcessCallback, + WatcherProcessHooks, + WatcherProcessSubscription +} from './parcel-watcher-process-subscription' +export type { WatcherProcessSupervisorOptions } from './parcel-watcher-process-supervisor-options' + +export class WatcherProcessSupervisor { + private child: ChildProcess | null = null + private nextSubscriptionId = 1 + private readonly crashFuse = new WatcherProcessCrashFuse() + private shutdownRequested = false + private canaryDir: string | null = null + private readonly records = new Map() + private readonly pendingUnsubscribes = new Map void>() + private readonly cancelledSubscribesAwaitingChild = new Set() + + constructor(private readonly options: WatcherProcessSupervisorOptions = {}) {} + + subscribe( + dir: string, + callback: WatcherProcessCallback, + opts: WatcherProcessSubscribeOptions, + hooks: WatcherProcessHooks = {} + ): Promise { + return subscribeThroughWatcherSupervisor({ + dir, + callback, + opts, + hooks, + shutdownRequested: this.shutdownRequested, + entryPath: this.options.entryPath ?? getWatcherProcessEntryPath(), + useInProcessVitestFallback: this.options.useInProcessVitestFallback ?? true, + allocateId: () => this.nextSubscriptionId++, + records: this.records, + pendingUnsubscribes: this.pendingUnsubscribes, + ensureWatcherProcess: (entryPath) => this.ensureWatcherProcess(entryPath), + getChild: () => this.child, + killWatcherChildIfIdle: () => this.killWatcherChildIfIdle(), + sendSubscribe: (child, record) => this.sendSubscribe(child, record), + sendToChild: (child, message) => this.sendToChild(child, message), + cancelPendingSubscribe: (record, error) => this.cancelPendingSubscribe(record, error) + }) + } + + dispose(): void { + this.shutdownRequested = true + const proc = this.child + this.child = null + const error = watcherHostFailure('file watcher supervisor disposed', 'supervisor_disposed') + disposeWatcherSupervisorSubscriptions( + this.records, + this.pendingUnsubscribes, + this.cancelledSubscribesAwaitingChild, + error + ) + proc?.kill() + this.canaryDir = removeWatcherCanaryDirectory(this.canaryDir) + } + + resetForTest(): void { + this.dispose() + this.shutdownRequested = false + this.crashFuse.reset() + } + + private ensureWatcherProcess( + entryPath = this.options.entryPath ?? getWatcherProcessEntryPath() + ): ChildProcess | null { + if (this.shutdownRequested) { + return null + } + if (this.child?.connected) { + return this.child + } + if (this.crashFuse.isOpen()) { + return null + } + if (!existsSync(entryPath)) { + console.error(`[parcel-watcher-process] entry not found at ${entryPath}; refusing fail-open`) + return null + } + const launched = launchWatcherChild( + entryPath, + this.canaryDir, + (child, message) => { + if (this.child === child) { + this.handleChildMessage(message) + } + }, + (child, code, signal) => this.handleChildGone(child, code, signal) + ) + if (!launched) { + this.canaryDir = null + return null + } + this.canaryDir = launched.canaryDir + this.child = launched.child + return launched.child + } + + private handleChildMessage(message: WatcherToHostMessage): void { + if (message.op === 'subscribe-started') { + const record = this.records.get(message.id) + if (record) { + startPendingSubscribeTimeout(record, (error) => this.cancelPendingSubscribe(record, error)) + startInterruptedSubscribeTimeout(record, (error) => + this.cancelInterruptedSubscribe(record, error) + ) + } + return + } + if (message.op === 'cancel-requires-restart') { + if (this.cancelledSubscribesAwaitingChild.delete(message.id)) { + this.restartAfterCancelledSubscribe() + } + return + } + if (message.op === 'unsubscribed') { + const completedCancellation = this.cancelledSubscribesAwaitingChild.delete(message.id) + handleWatcherHostMessage( + message, + this.records, + this.pendingUnsubscribes, + reportWatcherTerminalError, + () => this.killWatcherChildIfIdle() + ) + if (completedCancellation) { + this.killWatcherChildIfIdle() + } + return + } + handleWatcherHostMessage( + message, + this.records, + this.pendingUnsubscribes, + reportWatcherTerminalError, + () => this.killWatcherChildIfIdle() + ) + } + + private handleChildGone( + proc: ChildProcess, + code?: number | null, + signal?: NodeJS.Signals | null + ): void { + if (this.child !== proc) { + return + } + if (code !== undefined && (code !== 0 || signal)) { + console.error( + `[parcel-watcher-process] watcher process exited (code=${code}, signal=${signal})` + ) + } + this.child = null + this.cancelledSubscribesAwaitingChild.clear() + resolvePendingWatcherUnsubscribes(this.pendingUnsubscribes) + if (this.shutdownRequested || this.records.size === 0) { + return + } + this.crashFuse.recordCrash() + for (const record of this.records.values()) { + record.interrupted = true + resetPendingSubscribeAttempt(record) + } + const replacement = this.ensureWatcherProcess() + if (!replacement) { + console.error( + '[parcel-watcher-process] watcher process crashed repeatedly; disabling file watching' + ) + failAllWatcherSubscriptions( + this.records, + watcherHostFailure('file watcher process crashed repeatedly', 'supervisor_crash_fuse') + ) + this.canaryDir = removeWatcherCanaryDirectory(this.canaryDir) + return + } + console.error( + `[parcel-watcher-process] watcher process crashed; resubscribing ${this.records.size} root(s)` + ) + for (const record of this.records.values()) { + this.sendSubscribe(replacement, record) + } + } + + private killWatcherChildIfIdle(): void { + const proc = this.child + if (!proc || this.records.size > 0) { + return + } + this.child = null + resolvePendingWatcherUnsubscribes(this.pendingUnsubscribes) + proc.kill() + this.canaryDir = removeWatcherCanaryDirectory(this.canaryDir) + } + + private cancelPendingSubscribe( + record: WatcherProcessSubscriptionRecord, + error: WatcherProcessFailure + ): void { + if (!record.pendingSubscribe || !this.records.delete(record.id)) { + return + } + const crawlStarted = record.crawlStarted + const pending = takePendingSubscribe(record) + pending?.reject(error) + if (crawlStarted) { + this.restartAfterCancelledSubscribe() + return + } + const proc = this.child + if (!proc?.connected) { + this.killWatcherChildIfIdle() + return + } + this.cancelledSubscribesAwaitingChild.add(record.id) + this.sendToChild(proc, { op: 'cancel-subscribe', id: record.id }) + } + + private cancelInterruptedSubscribe( + record: WatcherProcessSubscriptionRecord, + error: WatcherProcessFailure + ): void { + if (!record.interrupted || record.pendingSubscribe || !this.records.delete(record.id)) { + return + } + resetPendingSubscribeAttempt(record) + // Why: one slow recovery root must not pin every healthy root in its shard. + // End that subscription so the runtime pool can retry it in quarantine. + reportWatcherTerminalError(record, error) + this.restartAfterCancelledSubscribe() + } + + private restartAfterCancelledSubscribe(): void { + const proc = this.child + this.child = null + this.cancelledSubscribesAwaitingChild.clear() + resolvePendingWatcherUnsubscribes(this.pendingUnsubscribes) + proc?.kill() + if (this.records.size === 0) { + this.canaryDir = removeWatcherCanaryDirectory(this.canaryDir) + return + } + const replacement = this.ensureWatcherProcess() + if (!replacement) { + failAllWatcherSubscriptions( + this.records, + watcherHostFailure( + 'file watcher process unavailable after subscription cancellation', + 'process_unavailable' + ) + ) + this.canaryDir = removeWatcherCanaryDirectory(this.canaryDir) + return + } + for (const liveRecord of this.records.values()) { + liveRecord.interrupted = true + resetPendingSubscribeAttempt(liveRecord) + this.sendSubscribe(replacement, liveRecord) + } + } + + private sendSubscribe(proc: ChildProcess, record: WatcherProcessSubscriptionRecord): void { + resetPendingSubscribeAttempt(record) + this.sendToChild(proc, { + op: 'subscribe', + id: record.id, + dir: record.dir, + opts: record.opts, + delivery: record.hooks.delivery + }) + } + + private sendToChild(proc: ChildProcess, message: HostToWatcherMessage): void { + try { + proc.send(message) + } catch { + // Channel already closed — the child's exit handler recovers. + } + } +} diff --git a/src/main/ipc/parcel-watcher-process.test.ts b/src/main/ipc/parcel-watcher-process.test.ts index 4df3b666fbb..1809ad484a7 100644 --- a/src/main/ipc/parcel-watcher-process.test.ts +++ b/src/main/ipc/parcel-watcher-process.test.ts @@ -4,19 +4,37 @@ import { EventEmitter } from 'node:events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { forkMock, existsSyncMock, parcelSubscribeMock } = vi.hoisted(() => ({ +const { + forkMock, + existsSyncMock, + mkdtempSyncMock, + parcelSubscribeMock, + rmSyncMock, + writeFileSyncMock +} = vi.hoisted(() => ({ forkMock: vi.fn(), existsSyncMock: vi.fn(), - parcelSubscribeMock: vi.fn() + mkdtempSyncMock: vi.fn(() => '/tmp/orca-watcher-canary-supervisor-test'), + parcelSubscribeMock: vi.fn(), + rmSyncMock: vi.fn(), + writeFileSyncMock: vi.fn() })) vi.mock('node:child_process', () => ({ fork: forkMock })) -vi.mock('node:fs', () => ({ existsSync: existsSyncMock })) +vi.mock('node:fs', () => ({ + existsSync: existsSyncMock, + mkdtempSync: mkdtempSyncMock, + rmSync: rmSyncMock, + writeFileSync: writeFileSyncMock +})) vi.mock('@parcel/watcher', () => ({ subscribe: parcelSubscribeMock })) import { + createWatcherProcessSupervisor, disposeWatcherProcess, + resetRuntimeWatcherProcessForTest, resetWatcherProcessForTest, + subscribeViaRuntimeWatcherProcess, subscribeViaWatcherProcess } from './parcel-watcher-process' @@ -24,6 +42,7 @@ type SentMessage = { op: string; id: number; dir?: string } class FakeChild extends EventEmitter { connected = true + pid = 1234 sent: SentMessage[] = [] stderr = new EventEmitter() kill = vi.fn(() => { @@ -55,6 +74,7 @@ function ackSubscribe(child: FakeChild, index = -1): number { describe('subscribeViaWatcherProcess', () => { beforeEach(() => { resetWatcherProcessForTest() + resetRuntimeWatcherProcessForTest() // Why: the client deliberately runs in-process under vitest; these tests // exercise the forked-process mode, so hide the vitest marker. vi.stubEnv('VITEST', '') @@ -64,6 +84,7 @@ describe('subscribeViaWatcherProcess', () => { afterEach(() => { disposeWatcherProcess() + resetRuntimeWatcherProcessForTest() vi.unstubAllEnvs() vi.clearAllMocks() }) @@ -81,6 +102,19 @@ describe('subscribeViaWatcherProcess', () => { expect(callback).toHaveBeenCalledWith(null, events) }) + it('creates the fault-harness pid file without clobbering an existing path', async () => { + vi.stubEnv('ORCA_WATCHER_CHILD_PID_FILE', '/tmp/orca-watcher.pid') + + const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {}) + const child = currentChild() + + expect(writeFileSyncMock).toHaveBeenCalledWith('/tmp/orca-watcher.pid', '1234', { + flag: 'wx' + }) + ackSubscribe(child) + await promise + }) + it('forwards watcher errors to the callback', async () => { const callback = vi.fn() const promise = subscribeViaWatcherProcess('/repo', callback, {}) @@ -92,6 +126,42 @@ describe('subscribeViaWatcherProcess', () => { expect(callback).toHaveBeenCalledWith(expect.objectContaining({ message: 'boom' }), []) }) + it('reports a terminal resubscribe failure separately from recoverable watch errors', async () => { + const callback = vi.fn() + const onTerminalError = vi.fn() + const promise = subscribeViaWatcherProcess('/repo', callback, {}, { onTerminalError }) + const child = currentChild() + const id = ackSubscribe(child) + await promise + + child.emit('message', { op: 'subscribe-failed', id, message: 'root unavailable' }) + + expect(onTerminalError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'root unavailable' }) + ) + expect(callback).not.toHaveBeenCalled() + }) + + it('passes bounded event-delivery options to the watcher child', async () => { + const promise = subscribeViaWatcherProcess( + '/repo', + vi.fn(), + {}, + { + delivery: { includeDirectoryMetadata: true, maxEventsPerBatch: 200 } + } + ) + const child = currentChild() + + expect(child.sent[0]).toMatchObject({ + op: 'subscribe', + dir: '/repo', + delivery: { includeDirectoryMetadata: true, maxEventsPerBatch: 200 } + }) + ackSubscribe(child) + await promise + }) + it('rejects the subscribe when the watcher process reports failure', async () => { const promise = subscribeViaWatcherProcess('/gone', vi.fn(), {}) const child = currentChild() @@ -135,7 +205,7 @@ describe('subscribeViaWatcherProcess', () => { it('respawns after a crash, resubscribes, and reports the interruption', async () => { const callback = vi.fn() const onInterruption = vi.fn() - const promise = subscribeViaWatcherProcess('/repo', callback, {}, onInterruption) + const promise = subscribeViaWatcherProcess('/repo', callback, {}, { onInterruption }) const first = currentChild() const id = ackSubscribe(first) await promise @@ -157,8 +227,71 @@ describe('subscribeViaWatcherProcess', () => { expect(callback).toHaveBeenCalledWith(null, events) }) + it('recovers existing records when IPC disconnects before child exit', async () => { + const firstPromise = subscribeViaWatcherProcess('/existing', vi.fn(), {}) + const first = currentChild() + ackSubscribe(first) + await firstPromise + + first.connected = false + first.emit('disconnect') + const replacement = currentChild() + const secondPromise = subscribeViaWatcherProcess('/new', vi.fn(), {}) + + expect(replacement).not.toBe(first) + expect(replacement.sent.filter((message) => message.op === 'subscribe')).toEqual([ + expect.objectContaining({ dir: '/existing' }), + expect.objectContaining({ dir: '/new' }) + ]) + ackSubscribe(replacement, 0) + ackSubscribe(replacement, 1) + await secondPromise + }) + + it('spreads runtime roots across healthy children and contains a shard crash', async () => { + const firstInterruption = vi.fn() + const secondInterruption = vi.fn() + const firstPromise = subscribeViaRuntimeWatcherProcess( + '/repo-a', + vi.fn(), + {}, + { + onInterruption: firstInterruption + } + ) + const firstChild = currentChild() + ackSubscribe(firstChild) + await firstPromise + + const secondPromise = subscribeViaRuntimeWatcherProcess( + '/repo-b', + vi.fn(), + {}, + { + onInterruption: secondInterruption + } + ) + const secondChild = currentChild() + expect(secondChild).not.toBe(firstChild) + ackSubscribe(secondChild) + await secondPromise + expect(forkMock).toHaveBeenCalledTimes(2) + + firstChild.connected = false + firstChild.emit('exit', null, 'SIGSEGV') + + const replacement = currentChild() + expect(replacement).not.toBe(firstChild) + expect(replacement.sent.filter((message) => message.op === 'subscribe')).toHaveLength(1) + ackSubscribe(replacement) + expect(firstInterruption).toHaveBeenCalledTimes(1) + expect(secondInterruption).not.toHaveBeenCalled() + expect(secondChild.connected).toBe(true) + }) + it('completes an in-flight subscribe across a crash-respawn', async () => { - const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {}) + const onInterruption = vi.fn() + const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {}, { onInterruption }) const first = currentChild() expect(first.sent[0].op).toBe('subscribe') @@ -168,6 +301,190 @@ describe('subscribeViaWatcherProcess', () => { const second = currentChild() ackSubscribe(second) await expect(promise).resolves.toMatchObject({ unsubscribe: expect.any(Function) }) + expect(onInterruption).toHaveBeenCalledTimes(1) + }) + + it('cancels a queued crawl without restarting healthy roots', async () => { + const healthyInterruption = vi.fn() + const healthyPromise = subscribeViaWatcherProcess( + '/healthy', + vi.fn(), + {}, + { + onInterruption: healthyInterruption + } + ) + const child = currentChild() + ackSubscribe(child) + await healthyPromise + + const controller = new AbortController() + const pending = subscribeViaWatcherProcess( + '/queued', + vi.fn(), + {}, + { + signal: controller.signal + } + ) + const queuedId = child.sent.at(-1)?.id + controller.abort() + + await expect(pending).rejects.toThrow('aborted') + expect(child.kill).not.toHaveBeenCalled() + expect(child.sent.at(-1)).toEqual({ op: 'cancel-subscribe', id: queuedId }) + expect(healthyInterruption).not.toHaveBeenCalled() + }) + + it('cancels an active crawl and restores healthy roots when its owner aborts', async () => { + const healthyInterruption = vi.fn() + const healthyPromise = subscribeViaWatcherProcess( + '/healthy', + vi.fn(), + {}, + { + onInterruption: healthyInterruption + } + ) + const first = currentChild() + ackSubscribe(first) + await healthyPromise + + const controller = new AbortController() + const pending = subscribeViaWatcherProcess('/slow', vi.fn(), {}, { signal: controller.signal }) + first.emit('message', { op: 'subscribe-started', id: first.sent.at(-1)?.id }) + controller.abort() + + await expect(pending).rejects.toThrow('aborted') + expect(first.kill).toHaveBeenCalledTimes(1) + const replacement = currentChild() + expect(replacement).not.toBe(first) + expect(replacement.sent.filter((message) => message.op === 'subscribe')).toEqual([ + expect.objectContaining({ dir: '/healthy' }) + ]) + ackSubscribe(replacement) + expect(healthyInterruption).toHaveBeenCalledTimes(1) + }) + + it('bounds a pending crawl and restores healthy roots after setup timeout', async () => { + vi.useFakeTimers() + try { + const healthyInterruption = vi.fn() + const healthyPromise = subscribeViaWatcherProcess( + '/healthy', + vi.fn(), + {}, + { + onInterruption: healthyInterruption + } + ) + const first = currentChild() + ackSubscribe(first) + await healthyPromise + + const pending = subscribeViaWatcherProcess('/slow', vi.fn(), {}, { subscribeTimeoutMs: 100 }) + first.emit('message', { op: 'subscribe-started', id: first.sent.at(-1)?.id }) + const timedOut = expect(pending).rejects.toThrow('timed out') + await vi.advanceTimersByTimeAsync(100) + + await timedOut + const replacement = currentChild() + expect(replacement).not.toBe(first) + ackSubscribe(replacement) + expect(healthyInterruption).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('ends a root whose crash resubscription crawl exceeds its deadline', async () => { + vi.useFakeTimers() + try { + const supervisor = createWatcherProcessSupervisor() + const onTerminalError = vi.fn() + const initial = supervisor.subscribe( + '/slow-recovery', + vi.fn(), + {}, + { + onTerminalError, + subscribeTimeoutMs: 100 + } + ) + const first = currentChild() + ackSubscribe(first) + await initial + + first.connected = false + first.emit('exit', null, 'SIGSEGV') + const replacement = currentChild() + replacement.emit('message', { + op: 'subscribe-started', + id: replacement.sent[0].id + }) + + await vi.advanceTimersByTimeAsync(100) + + expect(onTerminalError).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'subscribe_timeout', + message: 'file watcher resubscription timed out after 100ms' + }) + ) + expect(replacement.kill).toHaveBeenCalledTimes(1) + expect(forkMock).toHaveBeenCalledTimes(2) + supervisor.dispose() + } finally { + vi.useRealTimers() + } + }) + + it('starts the setup timeout only when the child begins that crawl', async () => { + vi.useFakeTimers() + try { + const pending = subscribeViaWatcherProcess( + '/queued', + vi.fn(), + {}, + { + subscribeTimeoutMs: 100 + } + ) + const child = currentChild() + let settled = false + void pending.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + + await vi.advanceTimersByTimeAsync(100) + expect(settled).toBe(false) + expect(child.kill).not.toHaveBeenCalled() + + child.emit('message', { op: 'subscribe-started', id: child.sent.at(-1)?.id }) + const timedOut = expect(pending).rejects.toThrow('timed out') + await vi.advanceTimersByTimeAsync(100) + await timedOut + } finally { + vi.useRealTimers() + } + }) + + it('rejects an in-flight and later subscribe when the supervisor is disposed', async () => { + const supervisor = createWatcherProcessSupervisor() + const pending = supervisor.subscribe('/repo', vi.fn(), {}) + const child = currentChild() + + supervisor.dispose() + + await expect(pending).rejects.toThrow('supervisor disposed') + await expect(supervisor.subscribe('/later', vi.fn(), {})).rejects.toThrow('supervisor disposed') + expect(child.kill).toHaveBeenCalledTimes(1) + expect(forkMock).toHaveBeenCalledTimes(1) }) it('disables watching after repeated crashes instead of fork-bombing', async () => { @@ -203,6 +520,10 @@ describe('subscribeViaWatcherProcess', () => { await expect(subscription.unsubscribe()).resolves.toBeUndefined() expect(first.kill).toHaveBeenCalledTimes(1) + expect(rmSyncMock).toHaveBeenCalledWith('/tmp/orca-watcher-canary-supervisor-test', { + recursive: true, + force: true + }) // Why: the deliberate idle kill must not count as a crash — no respawn // and no crash-fuse advance when the killed child's exit event lands. @@ -279,8 +600,101 @@ describe('subscribeViaWatcherProcess', () => { ignore: ['**/.git'] }) expect(forkMock).not.toHaveBeenCalled() - expect(parcelSubscribeMock).toHaveBeenCalledWith('/repo', callback, { ignore: ['**/.git'] }) + expect(parcelSubscribeMock).toHaveBeenCalledWith('/repo', expect.any(Function), { + ignore: ['**/.git'] + }) await subscription.unsubscribe() expect(unsubscribe).toHaveBeenCalled() }) + + it('rejects a pre-aborted in-process subscribe before loading Parcel', async () => { + vi.stubEnv('VITEST', 'true') + const controller = new AbortController() + controller.abort() + + await expect( + subscribeViaWatcherProcess('/repo', vi.fn(), {}, { signal: controller.signal }) + ).rejects.toThrow('aborted') + expect(parcelSubscribeMock).not.toHaveBeenCalled() + }) + + it('releases a late in-process subscription after pending setup is aborted', async () => { + vi.stubEnv('VITEST', 'true') + const unsubscribe = vi.fn().mockResolvedValue(undefined) + let resolveSubscription!: (subscription: { unsubscribe: typeof unsubscribe }) => void + parcelSubscribeMock.mockReturnValue( + new Promise((resolve) => { + resolveSubscription = resolve + }) + ) + const controller = new AbortController() + const pending = subscribeViaWatcherProcess('/repo', vi.fn(), {}, { signal: controller.signal }) + + await vi.waitFor(() => expect(parcelSubscribeMock).toHaveBeenCalledTimes(1)) + controller.abort() + await expect(pending).rejects.toThrow('aborted') + resolveSubscription({ unsubscribe }) + + await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledTimes(1)) + }) + + it('fails closed when the isolated watcher entry is absent outside tests', async () => { + existsSyncMock.mockReturnValue(false) + + await expect(subscribeViaWatcherProcess('/repo', vi.fn(), {})).rejects.toThrow( + 'watcher process entry is missing' + ) + expect(forkMock).not.toHaveBeenCalled() + expect(parcelSubscribeMock).not.toHaveBeenCalled() + }) + + it('starts without a canary when the diagnostic temp directory is unavailable', async () => { + mkdtempSyncMock.mockImplementationOnce(() => { + throw new Error('read-only temp directory') + }) + + const pending = subscribeViaWatcherProcess('/repo', vi.fn(), {}) + const child = currentChild() + const forkOptions = forkMock.mock.calls.at(-1)?.[2] as { env?: NodeJS.ProcessEnv } | undefined + expect(forkOptions?.env?.ORCA_WATCHER_CANARY_DIR).toBeUndefined() + ackSubscribe(child) + await expect(pending).resolves.toMatchObject({ unsubscribe: expect.any(Function) }) + }) + + it('keeps independently-created supervisors in separate crash-fuse domains', async () => { + const firstSupervisor = createWatcherProcessSupervisor() + const secondSupervisor = createWatcherProcessSupervisor() + const firstCallback = vi.fn() + const secondCallback = vi.fn() + + const firstPromise = firstSupervisor.subscribe('/faulting', firstCallback, {}) + const firstChild = currentChild() + ackSubscribe(firstChild) + await firstPromise + + const secondPromise = secondSupervisor.subscribe('/healthy', secondCallback, {}) + const secondChild = currentChild() + ackSubscribe(secondChild) + await secondPromise + + let faultingChild = firstChild + for (let crash = 0; crash < 2; crash++) { + faultingChild.connected = false + faultingChild.emit('exit', null, 'SIGSEGV') + faultingChild = currentChild() + ackSubscribe(faultingChild) + } + faultingChild.connected = false + faultingChild.emit('exit', null, 'SIGSEGV') + + expect(firstCallback).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('crashed repeatedly') }), + [] + ) + expect(secondCallback).not.toHaveBeenCalled() + expect(secondChild.connected).toBe(true) + + firstSupervisor.dispose() + secondSupervisor.dispose() + }) }) diff --git a/src/main/ipc/parcel-watcher-process.ts b/src/main/ipc/parcel-watcher-process.ts index 72e2ccf58ce..2d6b45ee97f 100644 --- a/src/main/ipc/parcel-watcher-process.ts +++ b/src/main/ipc/parcel-watcher-process.ts @@ -1,329 +1,75 @@ -// Host side of the out-of-process @parcel/watcher (see -// parcel-watcher-process-entry.ts). Why: native watcher teardown races -// fail-fast the hosting process (issue #7547), so local subscriptions live in -// a disposable forked child; when it crashes the host respawns it and -// resubscribes every live root, and callers are told via onInterruption so -// they can refresh state that changed during the gap. -import { fork, type ChildProcess } from 'node:child_process' -import { existsSync } from 'node:fs' -import type * as ParcelWatcher from '@parcel/watcher' -import { getWatcherProcessEntryPath } from './parcel-watcher-entry-path' -import type { - HostToWatcherMessage, +// Host API for crash-isolated @parcel/watcher children. Desktop uses one +// supervisor; runtime roots use a bounded pool with independent crash fuses. +import { + WatcherProcessSupervisor, + type WatcherProcessCallback, + type WatcherProcessHooks, + type WatcherProcessSubscription +} from './parcel-watcher-process-supervisor' +import type { WatcherProcessSubscribeOptions } from './parcel-watcher-process-protocol' +import { RuntimeWatcherProcessPool } from './runtime-watcher-process-pool' + +export type { + WatcherProcessCallback, + WatcherProcessHooks, + WatcherProcessSubscription +} from './parcel-watcher-process-supervisor' +export type { + WatcherProcessDeliveryOptions, WatcherProcessEvent, - WatcherProcessSubscribeOptions, - WatcherToHostMessage -} from './parcel-watcher-process-entry' + WatcherProcessSubscribeOptions +} from './parcel-watcher-process-protocol' -export type { WatcherProcessEvent, WatcherProcessSubscribeOptions } +const sharedWatcherProcessSupervisor = new WatcherProcessSupervisor() +// Why: healthy and quarantine pools each cap at four children, containing a +// failed shard without scaling process count with its number of roots. +const runtimeWatcherProcessPool = new RuntimeWatcherProcessPool() -export type WatcherProcessCallback = (err: Error | null, events: WatcherProcessEvent[]) => void -export type WatcherProcessSubscription = { unsubscribe(): Promise } - -// Why: a crash loop (e.g. a root that faults the native watcher on every -// resubscribe) must degrade to "file watching disabled" rather than fork-bomb. -const CRASH_WINDOW_MS = 30_000 -const MAX_CRASHES_PER_WINDOW = 3 - -type SubscriptionRecord = { - id: number - dir: string - opts: WatcherProcessSubscribeOptions - callback: WatcherProcessCallback - onInterruption?: () => void - pendingSubscribe?: { resolve: () => void; reject: (err: Error) => void } +export function createWatcherProcessSupervisor(): WatcherProcessSupervisor { + return new WatcherProcessSupervisor() } -let child: ChildProcess | null = null -let nextSubscriptionId = 1 -let crashTimes: number[] = [] -let shutdownRequested = false -let loggedInProcessFallback = false -const records = new Map() -const pendingUnsubscribes = new Map void>() - -function shouldRunInProcess(entryPath: string): boolean { - // Why: vitest suites mock '@parcel/watcher' at the module level; a forked - // child would bypass those mocks (and could execute a stale build output), - // so tests keep the historical in-process path. - if (process.env.VITEST) { - return true - } - if (existsSync(entryPath)) { - return false - } - if (!loggedInProcessFallback) { - loggedInProcessFallback = true - console.warn( - `[parcel-watcher-process] entry not found at ${entryPath}; using in-process watcher (no crash isolation)` - ) - } - return true -} - -async function subscribeInProcess( - dir: string, - callback: WatcherProcessCallback, - opts: WatcherProcessSubscribeOptions -): Promise { - const watcher = await import('@parcel/watcher') - const subscription = await watcher.subscribe( - dir, - callback as ParcelWatcher.SubscribeCallback, - opts as ParcelWatcher.Options - ) - return { unsubscribe: () => subscription.unsubscribe() } -} - -function inCrashCooldown(): boolean { - const now = Date.now() - crashTimes = crashTimes.filter((time) => now - time < CRASH_WINDOW_MS) - return crashTimes.length >= MAX_CRASHES_PER_WINDOW -} - -function sendToChild(proc: ChildProcess, message: HostToWatcherMessage): void { - try { - proc.send(message) - } catch { - // Channel already closed — the child's exit handler recovers. - } -} - -function ensureWatcherProcess(): ChildProcess | null { - if (shutdownRequested) { - return null - } - if (child?.connected) { - return child - } - if (inCrashCooldown()) { - return null - } - let proc: ChildProcess - try { - proc = fork(getWatcherProcessEntryPath(), [], { - stdio: ['ignore', 'ignore', 'pipe', 'ipc'], - env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, - ...(process.platform === 'win32' ? { windowsHide: true } : {}) - }) - } catch (err) { - console.error('[parcel-watcher-process] failed to fork watcher process:', err) - return null - } - proc.stderr?.on('data', (chunk: Buffer) => { - console.error('[parcel-watcher-process]', String(chunk).trimEnd()) - }) - proc.on('message', (message) => { - if (child === proc) { - handleChildMessage(message as WatcherToHostMessage) - } - }) - proc.on('error', () => handleChildGone(proc)) - proc.on('exit', (code, signal) => { - if (child === proc && (code !== 0 || signal)) { - console.error( - `[parcel-watcher-process] watcher process exited (code=${code}, signal=${signal})` - ) - } - handleChildGone(proc) - }) - child = proc - return proc -} - -function handleChildMessage(message: WatcherToHostMessage): void { - if (!message || typeof message !== 'object') { - return - } - if (message.op === 'unsubscribed') { - const resolve = pendingUnsubscribes.get(message.id) - pendingUnsubscribes.delete(message.id) - resolve?.() - return - } - const record = records.get(message.id) - if (!record) { - return - } - if (message.op === 'subscribed') { - const pending = record.pendingSubscribe - record.pendingSubscribe = undefined - if (pending) { - pending.resolve() - } else { - // Reached after a crash-respawn resubscribe: events emitted while the - // watcher process was down are lost, so let the caller refresh. - record.onInterruption?.() - } - return - } - if (message.op === 'subscribe-failed') { - records.delete(message.id) - const pending = record.pendingSubscribe - record.pendingSubscribe = undefined - if (pending) { - pending.reject(new Error(message.message)) - } else { - record.callback(new Error(message.message), []) - } - // Why: a failed last subscribe empties the records map without any - // unsubscribe ever being called — tear down the idle child here too. - killWatcherChildIfIdle() - return - } - if (message.op === 'events') { - record.callback(null, message.events) - return - } - if (message.op === 'watch-error') { - record.callback(new Error(message.message), []) - } -} - -function failAllSubscriptions(err: Error): void { - for (const record of records.values()) { - const pending = record.pendingSubscribe - record.pendingSubscribe = undefined - if (pending) { - pending.reject(err) - } else { - record.callback(err, []) - } - } - records.clear() -} - -function handleChildGone(proc: ChildProcess): void { - if (child !== proc) { - return - } - child = null - // Why: process death released every native handle, so a pending unsubscribe - // is complete by definition — resolve rather than hang worktree deletion. - for (const resolve of pendingUnsubscribes.values()) { - resolve() - } - pendingUnsubscribes.clear() - if (shutdownRequested || records.size === 0) { - return - } - crashTimes.push(Date.now()) - const replacement = ensureWatcherProcess() - if (!replacement) { - console.error( - '[parcel-watcher-process] watcher process crashed repeatedly; disabling file watching' - ) - failAllSubscriptions(new Error('file watcher process crashed repeatedly')) - return - } - console.error( - `[parcel-watcher-process] watcher process crashed; resubscribing ${records.size} root(s)` - ) - for (const record of records.values()) { - sendToChild(replacement, { - op: 'subscribe', - id: record.id, - dir: record.dir, - opts: record.opts - }) - } -} - -// Why: the last record gone leaves the child idle until app shutdown. Killing -// it releases native handles without running watcher.node's crash-prone async -// teardown (same rationale as disposeWatcherProcess) and reclaims the process; -// the next subscribe forks a fresh child. `child = null` before kill so the -// exit event neither counts as a crash nor respawns. Process death also -// completes any still-pending unsubscribe acks. -function killWatcherChildIfIdle(): void { - const proc = child - if (!proc || records.size > 0) { - return - } - child = null - for (const resolve of pendingUnsubscribes.values()) { - resolve() - } - pendingUnsubscribes.clear() - proc.kill() -} - -function makeSubscription(record: SubscriptionRecord): WatcherProcessSubscription { - return { - unsubscribe: (): Promise => { - if (!records.delete(record.id)) { - return Promise.resolve() - } - const proc = child - if (!proc?.connected) { - return Promise.resolve() - } - if (records.size === 0) { - killWatcherChildIfIdle() - return Promise.resolve() - } - return new Promise((resolve) => { - pendingUnsubscribes.set(record.id, resolve) - sendToChild(proc, { op: 'unsubscribe', id: record.id }) - }) - } - } -} - -/** Subscribe to a directory tree via the isolated watcher process. Falls back - * to an in-process @parcel/watcher subscription when the forked entry is not - * available (tests, unbuilt trees). `onInterruption` fires after the watcher - * process crashed and this subscription was transparently re-established — - * events in the gap were lost, so callers should refresh. */ export function subscribeViaWatcherProcess( dir: string, callback: WatcherProcessCallback, opts: WatcherProcessSubscribeOptions, - onInterruption?: () => void + hooks: WatcherProcessHooks = {} ): Promise { - if (shouldRunInProcess(getWatcherProcessEntryPath())) { - return subscribeInProcess(dir, callback, opts) - } - const record: SubscriptionRecord = { - id: nextSubscriptionId++, - dir, - opts, - callback, - onInterruption - } - return new Promise((resolve, reject) => { - const proc = ensureWatcherProcess() - if (!proc) { - reject(new Error('file watcher process unavailable')) - return - } - records.set(record.id, record) - record.pendingSubscribe = { - resolve: () => resolve(makeSubscription(record)), - reject - } - sendToChild(proc, { op: 'subscribe', id: record.id, dir, opts }) - }) + return sharedWatcherProcessSupervisor.subscribe(dir, callback, opts, hooks) } -/** Kill the watcher process at app shutdown. Process death releases native - * handles without running watcher.node's crash-prone async teardown. */ +export function subscribeViaRuntimeWatcherProcess( + dir: string, + callback: WatcherProcessCallback, + opts: WatcherProcessSubscribeOptions, + hooks: WatcherProcessHooks = {} +): Promise { + return runtimeWatcherProcessPool.subscribe(dir, callback, opts, hooks) +} + +/** Kill shared desktop and runtime watcher children at app/runtime shutdown. */ export function disposeWatcherProcess(): void { - shutdownRequested = true - const proc = child - child = null - for (const resolve of pendingUnsubscribes.values()) { - resolve() + // Why: Vitest reuses this module singleton after closeAllWatchers(), while + // production shutdown must remain final and reject any later subscription. + if (process.env.VITEST) { + sharedWatcherProcessSupervisor.resetForTest() + runtimeWatcherProcessPool.resetForTest() + } else { + sharedWatcherProcessSupervisor.dispose() + // Why: the runtime pool owns independent children that otherwise outlive a + // shutdown sequence that does not immediately exit the main process. + runtimeWatcherProcessPool.dispose() } - pendingUnsubscribes.clear() - records.clear() - proc?.kill() } export function resetWatcherProcessForTest(): void { - disposeWatcherProcess() - shutdownRequested = false - crashTimes = [] - loggedInProcessFallback = false - nextSubscriptionId = 1 + sharedWatcherProcessSupervisor.resetForTest() +} + +export function resetRuntimeWatcherProcessForTest(): void { + runtimeWatcherProcessPool.resetForTest() +} + +export function forgetRuntimeWatcherProcessRoot(rootPath: string): void { + runtimeWatcherProcessPool.forgetRoot(rootPath) } diff --git a/src/main/ipc/parcel-watcher-supervisor-subscribe.ts b/src/main/ipc/parcel-watcher-supervisor-subscribe.ts new file mode 100644 index 00000000000..abfa9ffe540 --- /dev/null +++ b/src/main/ipc/parcel-watcher-supervisor-subscribe.ts @@ -0,0 +1,129 @@ +import type { ChildProcess } from 'node:child_process' +import { existsSync } from 'node:fs' +import { createHostWatcherSubscription } from './parcel-watcher-host-subscriptions' +import { subscribeWithInProcessWatcher } from './parcel-watcher-in-process-fallback' +import { installPendingSubscribeControls } from './parcel-watcher-pending-subscribe' +import { WatcherProcessFailure } from './parcel-watcher-process-failure' +import type { + HostToWatcherMessage, + WatcherProcessSubscribeOptions +} from './parcel-watcher-process-protocol' +import type { + WatcherProcessCallback, + WatcherProcessHooks, + WatcherProcessSubscription, + WatcherProcessSubscriptionRecord +} from './parcel-watcher-process-subscription' + +type WatcherSupervisorSubscribeOptions = { + dir: string + callback: WatcherProcessCallback + opts: WatcherProcessSubscribeOptions + hooks: WatcherProcessHooks + shutdownRequested: boolean + entryPath: string + useInProcessVitestFallback: boolean + allocateId: () => number + records: Map + pendingUnsubscribes: Map void> + ensureWatcherProcess: (entryPath: string) => ChildProcess | null + getChild: () => ChildProcess | null + killWatcherChildIfIdle: () => void + sendSubscribe: (child: ChildProcess, record: WatcherProcessSubscriptionRecord) => void + sendToChild: (child: ChildProcess, message: HostToWatcherMessage) => void + cancelPendingSubscribe: ( + record: WatcherProcessSubscriptionRecord, + error: WatcherProcessFailure + ) => void +} + +export function subscribeThroughWatcherSupervisor({ + dir, + callback, + opts, + hooks, + shutdownRequested, + entryPath, + useInProcessVitestFallback, + allocateId, + records, + pendingUnsubscribes, + ensureWatcherProcess, + getChild, + killWatcherChildIfIdle, + sendSubscribe, + sendToChild, + cancelPendingSubscribe +}: WatcherSupervisorSubscribeOptions): Promise { + if (shutdownRequested) { + return Promise.reject( + new WatcherProcessFailure( + 'file watcher supervisor disposed', + 'supervisor', + 'supervisor_disposed' + ) + ) + } + if (hooks.signal?.aborted) { + return Promise.reject( + new WatcherProcessFailure( + 'file watcher subscription aborted', + 'subscription', + 'subscribe_aborted' + ) + ) + } + // Why: under Vitest we cannot fork a real watcher child, so exercise the + // subscription path in-process (against mocked @parcel/watcher) instead. + if (process.env.VITEST && useInProcessVitestFallback) { + return subscribeWithInProcessWatcher(dir, callback, opts, hooks) + } + if (!existsSync(entryPath)) { + return Promise.reject( + new WatcherProcessFailure( + `watcher process entry is missing: ${entryPath}`, + 'supervisor', + 'entry_missing' + ) + ) + } + const record: WatcherProcessSubscriptionRecord = { + id: allocateId(), + dir, + opts, + callback, + hooks, + interrupted: false, + crawlStarted: false + } + return new Promise((resolve, reject) => { + const child = ensureWatcherProcess(entryPath) + if (!child) { + reject( + new WatcherProcessFailure( + 'file watcher process unavailable', + 'supervisor', + 'process_unavailable' + ) + ) + return + } + records.set(record.id, record) + record.pendingSubscribe = { + resolve: () => + resolve( + createHostWatcherSubscription({ + record, + records, + pendingUnsubscribes, + getChild, + killWatcherChildIfIdle, + sendToChild + }) + ), + reject + } + installPendingSubscribeControls(record, (error) => cancelPendingSubscribe(record, error)) + sendSubscribe(child, record) + }) +} diff --git a/src/main/ipc/preflight-wsl-agent-detection.test.ts b/src/main/ipc/preflight-wsl-agent-detection.test.ts index a1f9d7cfda5..1853540d8db 100644 --- a/src/main/ipc/preflight-wsl-agent-detection.test.ts +++ b/src/main/ipc/preflight-wsl-agent-detection.test.ts @@ -78,11 +78,11 @@ describe('detectWslCommandsOnPath', () => { it('ignores commands whose resolved path is not absolute', async () => { execFileAsyncMock.mockResolvedValue({ - stdout: '__ORCA_AGENT_PATH__claude\tclaude\n', + stdout: '__ORCA_AGENT_PATH__claude\tclaude\n' + '__ORCA_AGENT_PATH__codex\tC:\\spoof\n', stderr: '' }) - const found = await detectWslCommandsOnPath({ distro: 'Ubuntu' }, ['claude']) + const found = await detectWslCommandsOnPath({ distro: 'Ubuntu' }, ['claude', 'codex']) expect(found).toEqual(new Set()) }) diff --git a/src/main/ipc/preflight-wsl-agent-detection.ts b/src/main/ipc/preflight-wsl-agent-detection.ts index be1f216ed9c..b5eff064325 100644 --- a/src/main/ipc/preflight-wsl-agent-detection.ts +++ b/src/main/ipc/preflight-wsl-agent-detection.ts @@ -114,7 +114,9 @@ function parseWslDetectedCommands(stdout: string): Set { } const command = payload.slice(0, separatorIndex) const resolvedPath = payload.slice(separatorIndex + 1) - if (path.posix.isAbsolute(resolvedPath) || path.win32.isAbsolute(resolvedPath)) { + // Why: a real guest executable always resolves to a POSIX-absolute path, so + // a Windows-style C:\ path here is spoofed/non-guest output, not an install. + if (path.posix.isAbsolute(resolvedPath)) { found.add(command) } } diff --git a/src/main/ipc/pty-management.test.ts b/src/main/ipc/pty-management.test.ts index 443b356ba4a..4e30669fb9e 100644 --- a/src/main/ipc/pty-management.test.ts +++ b/src/main/ipc/pty-management.test.ts @@ -226,10 +226,11 @@ describe('pty:management IPC handlers', () => { async function runKillAllWithPolls( handler: (event: unknown, args?: unknown) => unknown, pollCount: number = 65 - ): Promise<{ killedCount: number; remainingCount: number }> { + ): Promise<{ killedCount: number; remainingCount: number; killedSessionIds: string[] }> { const resultPromise = handler({}) as Promise<{ killedCount: number remainingCount: number + killedSessionIds: string[] }> // Why: advance the loop's sleeps one at a time. Between each sleep the // handler awaits collectSessions (a microtask), so we need to flush @@ -275,7 +276,11 @@ describe('pty:management IPC handlers', () => { const handlers = buildHandlerMap() const result = await runKillAllWithPolls(handlers['pty:management:killAll']) - expect(result).toEqual({ killedCount: 3, remainingCount: 0 }) + expect(result).toEqual({ + killedCount: 3, + remainingCount: 0, + killedSessionIds: ['new-1', 'new-2', 'old-1'] + }) // Each initial session receives exactly one shutdown — no retries. expect(current.shutdown).toHaveBeenCalledTimes(2) expect(current.shutdown).toHaveBeenCalledWith('new-1', { immediate: true }) @@ -302,7 +307,7 @@ describe('pty:management IPC handlers', () => { const handlers = buildHandlerMap() const result = await runKillAllWithPolls(handlers['pty:management:killAll']) - expect(result).toEqual({ killedCount: 0, remainingCount: 1 }) + expect(result).toEqual({ killedCount: 0, remainingCount: 1, killedSessionIds: [] }) // One shutdown fired — no per-session retry. Initial-snapshot // accounting means the stuck session is counted once. expect(current.shutdown).toHaveBeenCalledTimes(1) @@ -336,7 +341,11 @@ describe('pty:management IPC handlers', () => { const handlers = buildHandlerMap() const result = await runKillAllWithPolls(handlers['pty:management:killAll']) - expect(result).toEqual({ killedCount: 2, remainingCount: 0 }) + expect(result).toEqual({ + killedCount: 2, + remainingCount: 0, + killedSessionIds: ['a', 'b'] + }) }) it('swallows per-session shutdown rejections without stopping the batch', async () => { @@ -370,7 +379,11 @@ describe('pty:management IPC handlers', () => { expect(current.shutdown).toHaveBeenCalledWith('a', { immediate: true }) expect(current.shutdown).toHaveBeenCalledWith('b', { immediate: true }) // 'a' rejected and is still alive → counts as remaining; 'b' reaped. - expect(result).toEqual({ killedCount: 1, remainingCount: 1 }) + expect(result).toEqual({ + killedCount: 1, + remainingCount: 1, + killedSessionIds: ['b'] + }) }) }) diff --git a/src/main/ipc/pty-management.ts b/src/main/ipc/pty-management.ts index 107ca8e150d..9459984c5e3 100644 --- a/src/main/ipc/pty-management.ts +++ b/src/main/ipc/pty-management.ts @@ -74,7 +74,11 @@ export function registerDaemonManagementHandlers(): void { // daemons aren't killed here. ipcMain.handle( 'pty:management:killAll', - async (): Promise<{ killedCount: number; remainingCount: number }> => { + async (): Promise<{ + killedCount: number + remainingCount: number + killedSessionIds: string[] + }> => { const adapters = getDaemonAdapters() // Why: snapshot the initial session set once, up front. All subsequent // accounting is relative to these IDs. If the renderer respawns panes @@ -87,7 +91,7 @@ export function registerDaemonManagementHandlers(): void { const initialCount = initial.length if (initialCount === 0) { - return { killedCount: 0, remainingCount: 0 } + return { killedCount: 0, remainingCount: 0, killedSessionIds: [] } } // Why: fire one shutdown per initial session, in parallel, once — no @@ -125,20 +129,29 @@ export function registerDaemonManagementHandlers(): void { // session count) is what keeps the math honest when the renderer // respawns panes with fresh IDs mid-kill. let remainingOriginalCount = initialCount + let remainingOriginalIds = initialIds for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt += 1) { await sleep(POLL_INTERVAL_MS) const current = await collectSessions(adapters) - remainingOriginalCount = current.reduce( - (count, s) => (initialIds.has(s.sessionId) ? count + 1 : count), - 0 + remainingOriginalIds = new Set( + current + .filter((session) => initialIds.has(session.sessionId)) + .map((session) => session.sessionId) ) + remainingOriginalCount = remainingOriginalIds.size if (remainingOriginalCount === 0) { break } } const killedCount = initialCount - remainingOriginalCount - return { killedCount, remainingCount: remainingOriginalCount } + return { + killedCount, + remainingCount: remainingOriginalCount, + killedSessionIds: [...initialIds].filter( + (sessionId) => !remainingOriginalIds.has(sessionId) + ) + } } ) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 751245fb62d..5923c9ea0c2 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -169,6 +169,13 @@ vi.mock('../telemetry/classify-error', () => ({ classifyError: classifyErrorMock })) +// Why: the real ensure writes to disk from process.resourcesPath, which does +// not exist under vitest; env assembly only needs the returned dir path. +vi.mock('../cli/linux-terminal-orca-cli-shim', () => ({ + ensureLinuxTerminalOrcaCliShimDir: (options: { userDataPath: string }) => + join(options.userDataPath, 'linux-orca-cli-shim') +})) + vi.mock('../memory/pty-registry', () => ({ registerPty: registerPtyMock, unregisterPty: unregisterPtyMock @@ -195,7 +202,10 @@ import { setLocalPtyProvider, rebindLocalProviderListeners, unregisterSshPtyProvider, - getLocalPtyProvider + releasePendingSshShutdownsForTarget, + getLocalPtyProvider, + shutdownPtyWithRetainedOwnership, + _resetRetainedPtyShutdownsForTests } from './pty' import { _resetHiddenRendererPtyDeliveryGateForTest, @@ -403,12 +413,16 @@ describe('registerPtyHandlers', () => { } ) isPwshAvailableMock.mockReturnValue(false) + let defaultExitHandler: ((event: { exitCode: number }) => void) | null = null spawnMock.mockReturnValue({ onData: vi.fn(() => makeDisposable()), - onExit: vi.fn(() => makeDisposable()), + onExit: vi.fn((handler: (event: { exitCode: number }) => void) => { + defaultExitHandler = handler + return makeDisposable() + }), write: vi.fn(), resize: vi.fn(), - kill: vi.fn(), + kill: vi.fn(() => defaultExitHandler?.({ exitCode: -1 })), process: 'zsh', pid: 12345 }) @@ -416,6 +430,9 @@ describe('registerPtyHandlers', () => { afterEach(() => { _resetWslCachesForTests() + // Why: retained shutdowns deliberately survive renderer loss in production; + // tests that prove that retention must not re-drive against the next case. + _resetRetainedPtyShutdownsForTests() vi.useRealTimers() // Why: sshProviders is module-level state; any id left registered leaks // into later tests (pty:listSessions sweeps every registered provider). @@ -431,6 +448,7 @@ describe('registerPtyHandlers', () => { 'ssh-runtime-env' ]) { unregisterSshPtyProvider(leakedConnectionId) + releasePendingSshShutdownsForTarget(leakedConnectionId) } setLocalPtyProvider(new LocalPtyProvider()) if (savedProcessPlatform) { @@ -503,6 +521,55 @@ describe('registerPtyHandlers', () => { } }) + it('enters runtime terminal-create admission before renderer spawn work', async () => { + const admissionError = new Error('terminal admission blocked') + const runtime = { + setPtyController: vi.fn(), + runWithTerminalCreateAdmission: vi.fn().mockRejectedValue(admissionError) + } + registerPtyHandlers(mainWindow as never, runtime as never) + + await expect( + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + worktreeId: 'repo-1::/workspace/feature' + }) + ).rejects.toBe(admissionError) + + expect(runtime.runWithTerminalCreateAdmission).toHaveBeenCalledWith( + 'repo-1::/workspace/feature', + expect.any(Function), + 'local' + ) + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('qualifies renderer terminal admission with the SSH owner', async () => { + const admissionError = new Error('terminal admission blocked') + const runtime = { + setPtyController: vi.fn(), + runWithTerminalCreateAdmission: vi.fn().mockRejectedValue(admissionError) + } + registerPtyHandlers(mainWindow as never, runtime as never) + + await expect( + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + worktreeId: 'repo-1::/workspace/feature', + connectionId: 'target-1' + }) + ).rejects.toBe(admissionError) + + expect(runtime.runWithTerminalCreateAdmission).toHaveBeenCalledWith( + 'repo-1::/workspace/feature', + expect.any(Function), + 'ssh:target-1' + ) + expect(spawnMock).not.toHaveBeenCalled() + }) + function createMockProc() { let dataHandler: ((data: string) => void) | null = null let exitHandler: ((event: { exitCode: number }) => void) | null = null @@ -1681,6 +1748,33 @@ describe('registerPtyHandlers', () => { } }) + it('prepends the bare-orca CLI shim dir to PATH for packaged Linux spawns', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'linux' + }) + try { + // Why: overriding process.platform does not change the already-loaded + // node:path dialect; keep this synthetic PATH internally consistent. + const env = await daemonSpawnAndGetEnv({ + PATH: ['/usr/local/bin', '/usr/bin'].join(delimiter) + }) + const entries = env.PATH.split(delimiter) + const shimDir = join('/tmp/orca-user-data', 'linux-orca-cli-shim') + // Why: bare `orca` must resolve to the Orca CLI before /usr/bin/orca + // (the GNOME screen reader) inside Orca-managed terminals (#7904). + expect(entries.indexOf(shimDir)).toBeGreaterThanOrEqual(0) + expect(entries.indexOf(shimDir)).toBeLessThan(entries.indexOf('/usr/bin')) + expect(env.ORCA_CLI_COMMAND).toBeUndefined() + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + it('injects the agent-hook receiver env on the daemon path', async () => { const env = await daemonSpawnAndGetEnv({}) expect(env.ORCA_AGENT_HOOK_PORT).toBe('5678') @@ -1772,10 +1866,13 @@ describe('registerPtyHandlers', () => { // Why: runtime-created spawns (e.g. the mobile-create materialize path) // must thread the same {tabId, leafId} so the catch-path rescue can find // and keep their live PTY (#7587). - expect(runtime.registerPty).toHaveBeenCalledWith(expect.any(String), 'wt-runtime', null, { - tabId: 'tab-1', - leafId - }) + expect(runtime.registerPty).toHaveBeenCalledWith( + expect.any(String), + 'wt-runtime', + null, + { tabId: 'tab-1', leafId }, + false + ) }) it('uses the owning project WSL runtime for runtime-created daemon PTYs', async () => { @@ -1830,6 +1927,13 @@ describe('registerPtyHandlers', () => { expect(spawnOptions.shellOverride).toBe('wsl.exe') expect(spawnOptions.terminalWindowsWslDistro).toBe('Ubuntu') expect(spawnOptions.terminalWindowsPowerShellImplementation).toBe('auto') + expect(runtime.registerPty).toHaveBeenCalledWith( + expect.any(String), + 'repo-1::C:\\repo', + null, + undefined, + true + ) }) }) @@ -2369,11 +2473,15 @@ describe('registerPtyHandlers', () => { }) ).rejects.toThrow('SSH_SESSION_EXPIRED: remote-pty') - expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'remote-pty', 'expired') + expect(store.markSshRemotePtyLease).toHaveBeenCalledWith( + 'ssh-1', + 'ssh:ssh-1@@remote-pty', + 'expired' + ) }) - it('marks a scoped SSH session expired using the raw relay lease id', async () => { - const scopedPtyId = 'ssh:ssh-1@@remote-pty' + it('marks a generation-scoped SSH session expired using its exact lease identity', async () => { + const scopedPtyId = 'ssh:ssh-1@@boot-a@@remote-pty' const sshSpawn = vi.fn(async () => { throw new Error('SSH_SESSION_EXPIRED: remote-pty') }) @@ -2427,7 +2535,7 @@ describe('registerPtyHandlers', () => { deletePtyOwnership(scopedPtyId) } - expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'remote-pty', 'expired') + expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', scopedPtyId, 'expired') expect(openCodeClearPtyMock).toHaveBeenCalledWith(scopedPtyId) expect(piClearPtyMock).toHaveBeenCalledWith(scopedPtyId) }) @@ -2606,7 +2714,7 @@ describe('registerPtyHandlers', () => { // which adds async hops; a single microtask flush is no longer enough. await new Promise((resolve) => setImmediate(resolve)) - expect(shutdown).toHaveBeenCalledWith('remote-pty', { immediate: false }) + expect(shutdown).toHaveBeenCalledWith('remote-pty', { immediate: true }) expect(store.markSshRemotePtyLease).toHaveBeenCalledWith( 'ssh-1', 'remote-pty', @@ -2869,14 +2977,19 @@ describe('registerPtyHandlers', () => { stopAndWait: (ptyId: string, opts?: { keepHistory?: boolean }) => Promise } + await expect(controller.stopAndWait('local-pty', { keepHistory: true })).resolves.toBe( + false + ) await expect(controller.stopAndWait('local-pty', { keepHistory: true })).resolves.toBe( false ) + expect(shutdown).toHaveBeenCalledOnce() expect(shutdown).toHaveBeenCalledWith('local-pty', { immediate: true, keepHistory: true }) + expect(listProcesses).toHaveBeenCalledTimes(2) expect(runtime.onPtyExit).not.toHaveBeenCalled() }) @@ -2948,12 +3061,16 @@ describe('registerPtyHandlers', () => { expect(controller.kill('ssh:ssh-1@@relay-pty')).toBe(true) await new Promise((resolve) => setImmediate(resolve)) - expect(shutdown).toHaveBeenCalledWith('ssh:ssh-1@@relay-pty', { immediate: false }) + expect(shutdown).toHaveBeenCalledWith('ssh:ssh-1@@relay-pty', { immediate: true }) expect(localShutdown).not.toHaveBeenCalled() - expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'relay-pty', 'terminated') + expect(store.markSshRemotePtyLease).toHaveBeenCalledWith( + 'ssh-1', + 'ssh:ssh-1@@relay-pty', + 'terminated' + ) }) - it('runtime controller kill tombstones app-scoped SSH ids when ownership and provider are absent', async () => { + it('runtime controller kill retains app-scoped SSH ids when the provider is absent', async () => { const localShutdown = vi.fn() setLocalPtyProvider({ spawn: vi.fn(), @@ -2977,7 +3094,10 @@ describe('registerPtyHandlers', () => { getDefaultShell: vi.fn(), getProfiles: vi.fn() } as never) - const store = { markSshRemotePtyLease: vi.fn() } + const store = { + markSshRemotePtyLease: vi.fn(), + markSshRemotePtyShutdownRequested: vi.fn(() => true) + } const runtime = { setPtyController: vi.fn(), onPtyExit: vi.fn() @@ -2998,13 +3118,46 @@ describe('registerPtyHandlers', () => { expect(controller.kill('ssh:ssh-1@@relay-pty')).toBe(true) expect(localShutdown).not.toHaveBeenCalled() - expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'relay-pty', 'terminated') - expect(runtime.onPtyExit).toHaveBeenCalledWith('ssh:ssh-1@@relay-pty', -1) + expect(store.markSshRemotePtyLease).not.toHaveBeenCalled() + expect(store.markSshRemotePtyShutdownRequested).toHaveBeenCalledWith( + 'ssh-1', + 'ssh:ssh-1@@relay-pty' + ) + expect(runtime.onPtyExit).not.toHaveBeenCalled() }) - it('marks a detached SSH lease terminated when runtime controller kill has no provider', async () => { + it('runtime controller kill fails closed when offline SSH intent cannot persist', () => { const store = { - markSshRemotePtyLease: vi.fn() + markSshRemotePtyLease: vi.fn(), + markSshRemotePtyShutdownRequested: vi.fn(() => { + throw new Error('disk full') + }) + } + const runtime = { + setPtyController: vi.fn(), + onPtyExit: vi.fn() + } + handlers.clear() + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + store as never + ) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + kill: (ptyId: string) => boolean + } + + expect(controller.kill('ssh:ssh-1@@relay-pty')).toBe(false) + expect(runtime.onPtyExit).not.toHaveBeenCalled() + }) + + it('retains a detached SSH lease when runtime controller kill has no provider', async () => { + const store = { + markSshRemotePtyLease: vi.fn(), + markSshRemotePtyShutdownRequested: vi.fn(() => true) } const runtime = { setPtyController: vi.fn(), @@ -3026,18 +3179,43 @@ describe('registerPtyHandlers', () => { expect(controller.kill('remote-pty')).toBe(true) - expect(store.markSshRemotePtyLease).toHaveBeenCalledWith( - 'ssh-1', - 'remote-pty', - 'terminated' + expect(store.markSshRemotePtyLease).not.toHaveBeenCalled() + expect(store.markSshRemotePtyShutdownRequested).toHaveBeenCalledWith('ssh-1', 'remote-pty') + expect(runtime.onPtyExit).not.toHaveBeenCalled() + }) + + it('retains a detached SSH lease when runtime controller stopAndWait has no provider', async () => { + const store = { markSshRemotePtyLease: vi.fn() } + const runtime = { setPtyController: vi.fn(), onPtyExit: vi.fn() } + setPtyOwnership('remote-pty', 'ssh-1') + handlers.clear() + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + store as never ) - expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + stopAndWait: (ptyId: string) => Promise + } + + await expect(controller.stopAndWait('remote-pty')).resolves.toBe(false) + expect(store.markSshRemotePtyLease).not.toHaveBeenCalled() + expect(runtime.onPtyExit).not.toHaveBeenCalled() }) it('preserves an SSH lease when runtime controller kill shutdown fails transiently', async () => { + vi.useFakeTimers() const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const shutdown = vi + .fn() + .mockRejectedValueOnce(new Error('Multiplexer disposed')) + .mockResolvedValue(undefined) const store = { - markSshRemotePtyLease: vi.fn() + markSshRemotePtyLease: vi.fn(), + markSshRemotePtyShutdownRequested: vi.fn() } const runtime = { setPtyController: vi.fn(), @@ -3047,7 +3225,7 @@ describe('registerPtyHandlers', () => { spawn: vi.fn(), write: vi.fn(), resize: vi.fn(), - shutdown: vi.fn().mockRejectedValue(new Error('Multiplexer disposed')), + shutdown, sendSignal: vi.fn(), getCwd: vi.fn(), getInitialCwd: vi.fn(), @@ -3081,18 +3259,24 @@ describe('registerPtyHandlers', () => { try { expect(controller.kill('remote-pty')).toBe(true) - await new Promise((resolve) => setImmediate(resolve)) + await vi.advanceTimersByTimeAsync(0) + expect(runtime.onPtyExit).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(250) } finally { warnSpy.mockRestore() deletePtyOwnership('remote-pty') } - expect(store.markSshRemotePtyLease).not.toHaveBeenCalledWith( + expect(shutdown).toHaveBeenCalledTimes(2) + expect(store.markSshRemotePtyLease).toHaveBeenCalledWith( 'ssh-1', 'remote-pty', 'terminated' ) expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1) + await vi.advanceTimersByTimeAsync(30_000) + expect(vi.getTimerCount()).toBe(0) + vi.useRealTimers() }) it('strips ORCA_PANE_KEY/TAB_ID/WORKTREE_ID from SSH spawn env when remote agent hooks are disabled', async () => { @@ -3221,12 +3405,76 @@ describe('registerPtyHandlers', () => { }) }) - it('rethrows non-not-found local provider shutdown failures', async () => { + it('routes runtime foreground confirmation to the provider owning the captured PTY', async () => { + const confirmForegroundProcess = vi.fn(async () => 'codex') + registerSshPtyProvider('ssh-1', { confirmForegroundProcess } as never) + setPtyOwnership('remote-pty', 'ssh-1') + const runtime = { setPtyController: vi.fn() } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + confirmForegroundProcess: (ptyId: string) => Promise + } + + await expect(controller.confirmForegroundProcess('remote-pty')).resolves.toBe('codex') + expect(confirmForegroundProcess).toHaveBeenCalledOnce() + expect(confirmForegroundProcess).toHaveBeenCalledWith('remote-pty') + deletePtyOwnership('remote-pty') + }) + + it('returns unavailable runtime confirmation for unsupported or missing providers', async () => { + registerSshPtyProvider('ssh-1', {} as never) + setPtyOwnership('unsupported-pty', 'ssh-1') + setPtyOwnership('missing-pty', 'missing-connection') + const runtime = { setPtyController: vi.fn() } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + confirmForegroundProcess: (ptyId: string) => Promise + } + + await expect(controller.confirmForegroundProcess('unsupported-pty')).resolves.toBeNull() + await expect(controller.confirmForegroundProcess('missing-pty')).resolves.toBeNull() + deletePtyOwnership('unsupported-pty') + deletePtyOwnership('missing-pty') + }) + + it('lists only the selected provider for host-scoped liveness proof', async () => { + const localSessions = [{ id: 'local-pty', cwd: '/local', title: 'Shell' }] + const localListProcesses = vi.fn().mockResolvedValue(localSessions) + const unrelatedSshListProcesses = vi.fn().mockRejectedValue(new Error('unrelated SSH down')) + setLocalPtyProvider({ + listProcesses: localListProcesses, + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}) + } as never) + registerSshPtyProvider('ssh-b', { listProcesses: unrelatedSshListProcesses } as never) + const runtime = { setPtyController: vi.fn() } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + listProcesses: (connectionId?: string | null) => Promise + } + + await expect(controller.listProcesses(null)).resolves.toEqual(localSessions) + expect(localListProcesses).toHaveBeenCalledOnce() + expect(unrelatedSshListProcesses).not.toHaveBeenCalled() + await expect(controller.listProcesses('ssh-b')).rejects.toThrow('unrelated SSH down') + }) + + it('persists local daemon shutdown and retries after renderer loss', async () => { + vi.useFakeTimers() + const shutdown = vi + .fn() + .mockRejectedValueOnce(new Error('daemon unavailable')) + .mockRejectedValueOnce(new Error('daemon still unavailable')) + .mockResolvedValue(undefined) setLocalPtyProvider({ spawn: vi.fn(), write: vi.fn(), resize: vi.fn(), - shutdown: vi.fn().mockRejectedValue(new Error('daemon unavailable')), + shutdown, sendSignal: vi.fn(), getCwd: vi.fn(), getInitialCwd: vi.fn(), @@ -3244,12 +3492,882 @@ describe('registerPtyHandlers', () => { getDefaultShell: vi.fn(), getProfiles: vi.fn() } as never) + const store = { + getPendingLocalPtyShutdowns: vi.fn(() => []), + upsertPendingLocalPtyShutdown: vi.fn(), + removePendingLocalPtyShutdown: vi.fn() + } + vi.spyOn(console, 'warn').mockImplementation(() => {}) + handlers.clear() + registerPtyHandlers( + mainWindow as never, + undefined, + undefined, + undefined, + undefined, + store as never + ) + + await expect( + handlers.get('pty:kill')!(null, { + id: 'local-pty', + expectedPaneKey: 'tab:leaf', + expectedTabId: 'tab' + }) + ).rejects.toThrow('daemon unavailable') + await vi.advanceTimersByTimeAsync(250) + await vi.advanceTimersByTimeAsync(500) + + expect(shutdown).toHaveBeenCalledTimes(3) + expect(store.upsertPendingLocalPtyShutdown).toHaveBeenCalledWith({ + ptyId: 'local-pty', + expectedPaneKey: 'tab:leaf', + expectedTabId: 'tab', + requestedAt: expect.any(Number) + }) + expect(store.removePendingLocalPtyShutdown).toHaveBeenCalledWith('local-pty') + await vi.advanceTimersByTimeAsync(30_000) + await vi.advanceTimersByTimeAsync(30_000) + vi.useRealTimers() + }) + + it('attempts local and SSH shutdown when durable intent writes throw', async () => { + vi.useFakeTimers() + const localShutdown = vi.fn().mockResolvedValue(undefined) + const sshShutdown = vi.fn().mockResolvedValue(undefined) + const provider = (shutdown: typeof localShutdown) => + ({ + shutdown, + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []) + }) as never + setLocalPtyProvider(provider(localShutdown)) + registerSshPtyProvider('ssh-persist-fail', provider(sshShutdown)) + handlers.clear() + registerPtyHandlers(mainWindow as never, undefined, undefined, undefined, undefined, { + getPendingLocalPtyShutdowns: () => [], + upsertPendingLocalPtyShutdown: () => { + throw new Error('local intent disk full') + }, + markSshRemotePtyShutdownRequested: () => { + throw new Error('SSH intent disk full') + }, + markSshRemotePtyLease: vi.fn() + } as never) + + try { + await expect(handlers.get('pty:kill')!(null, { id: 'local-persist-fail' })).rejects.toThrow( + 'local intent disk full' + ) + await expect( + handlers.get('pty:kill')!(null, { id: 'ssh:ssh-persist-fail@@remote-pty' }) + ).rejects.toThrow('SSH intent disk full') + await vi.advanceTimersByTimeAsync(0) + + expect(localShutdown).toHaveBeenCalledWith('local-persist-fail', { + immediate: true, + keepHistory: false + }) + expect(sshShutdown).toHaveBeenCalledWith('ssh:ssh-persist-fail@@remote-pty', { + immediate: true, + keepHistory: false + }) + } finally { + unregisterSshPtyProvider('ssh-persist-fail') + releasePendingSshShutdownsForTarget('ssh-persist-fail') + } + }) + + it('retries durable local intent removal without repeating provider shutdown', async () => { + vi.useFakeTimers() + const shutdown = vi.fn().mockResolvedValue(undefined) + const remove = vi + .fn() + .mockImplementationOnce(() => { + throw new Error('intent removal disk full') + }) + .mockReturnValue(undefined) + setLocalPtyProvider({ + shutdown, + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}) + } as never) + handlers.clear() + registerPtyHandlers(mainWindow as never, undefined, undefined, undefined, undefined, { + getPendingLocalPtyShutdowns: () => [], + upsertPendingLocalPtyShutdown: vi.fn(), + removePendingLocalPtyShutdown: remove + } as never) + + await expect(handlers.get('pty:kill')!(null, { id: 'local-remove-fail' })).rejects.toThrow( + 'intent removal disk full' + ) + expect(shutdown).toHaveBeenCalledOnce() + expect(remove).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(250) + + expect(shutdown).toHaveBeenCalledOnce() + expect(remove).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(30_000) + expect(shutdown).toHaveBeenCalledOnce() + expect(remove).toHaveBeenCalledTimes(2) + }) + + it('keeps exit fanout live while durable removal retries', async () => { + vi.useFakeTimers() + const exitListeners = new Set<(payload: { id: string; code: number }) => void>() + const shutdown = vi.fn(async () => { + for (const listener of exitListeners) { + listener({ id: 'local-exit', code: 0 }) + } + }) + const remove = vi + .fn() + .mockImplementationOnce(() => { + throw new Error('exit intent removal disk full') + }) + .mockReturnValue(undefined) + const runtime = { onPtyExit: vi.fn(), setPtyController: vi.fn() } + setLocalPtyProvider({ + requiresShutdownExitProof: true, + shutdown, + listProcesses: vi.fn().mockResolvedValue([{ id: 'local-exit', cwd: '', title: 'shell' }]), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn((listener) => { + exitListeners.add(listener) + return () => exitListeners.delete(listener) + }) + } as never) + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never, undefined, undefined, undefined, { + getPendingLocalPtyShutdowns: () => [], + upsertPendingLocalPtyShutdown: vi.fn(), + removePendingLocalPtyShutdown: remove + } as never) + + await expect(handlers.get('pty:kill')!(null, { id: 'local-exit' })).rejects.toThrow( + 'exit intent removal disk full' + ) + + expect(runtime.onPtyExit).toHaveBeenCalledWith('local-exit', 0) + // The synchronous exit callback owns settlement while shutdown is in flight. + expect(remove).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(250) + expect(shutdown).toHaveBeenCalledOnce() + expect(remove).toHaveBeenCalledTimes(2) + }) + + it('keeps accepted local kills until provider readback proves physical exit', async () => { + vi.useFakeTimers() + const shutdown = vi.fn().mockResolvedValue(undefined) + const listProcesses = vi + .fn() + .mockResolvedValueOnce([{ id: 'local-pty', cwd: '', title: 'shell' }]) + .mockResolvedValue([]) + const removePendingLocalPtyShutdown = vi.fn() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + warn.mockClear() + setLocalPtyProvider({ + requiresShutdownExitProof: true, + shutdown, + listProcesses, + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}) + } as never) + handlers.clear() + registerPtyHandlers(mainWindow as never, undefined, undefined, undefined, undefined, { + getPendingLocalPtyShutdowns: () => [], + upsertPendingLocalPtyShutdown: vi.fn(), + removePendingLocalPtyShutdown + } as never) + + await expect(handlers.get('pty:kill')!(null, { id: 'local-pty' })).resolves.toBeUndefined() + expect(removePendingLocalPtyShutdown).not.toHaveBeenCalled() + expect(warn).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(250) + + expect(shutdown).toHaveBeenCalledOnce() + expect(removePendingLocalPtyShutdown).toHaveBeenCalledWith('local-pty') + expect(warn).not.toHaveBeenCalled() + vi.useRealTimers() + }) + + it('coalesces 50 accepted SSH shutdown proofs into one provider inventory', async () => { + const connectionId = 'ssh-bulk-proof' + const ids = Array.from({ length: 50 }, (_, index) => `ssh:${connectionId}@@pty-${index}`) + let resolveListing!: (processes: []) => void + const listing = new Promise<[]>((resolve) => { + resolveListing = resolve + }) + const shutdown = vi.fn(async () => {}) + const listProcesses = vi.fn(() => listing) + const provider = { + requiresShutdownExitProof: true, + shutdown, + listProcesses, + onExit: vi.fn(() => () => {}) + } as never + registerSshPtyProvider(connectionId, provider) + try { + const shutdowns = ids.map((id) => + shutdownPtyWithRetainedOwnership({ + id, + connectionId, + provider, + options: { immediate: true } + }) + ) + await vi.waitFor(() => expect(shutdown).toHaveBeenCalledTimes(50)) + await vi.waitFor(() => expect(listProcesses).toHaveBeenCalledOnce()) + resolveListing([]) + await expect(Promise.all(shutdowns)).resolves.toHaveLength(50) + + expect(listProcesses).toHaveBeenCalledOnce() + } finally { + unregisterSshPtyProvider(connectionId) + releasePendingSshShutdownsForTarget(connectionId) + } + }) + + it('releases generation-qualified and legacy SSH retries after confirmed relay reset', async () => { + vi.useFakeTimers() + const connectionId = 'ssh-reset-release' + const oldShutdown = vi.fn().mockRejectedValue(new Error('transport unavailable')) + const replacementShutdown = vi.fn().mockResolvedValue(undefined) + const makeProvider = (shutdown: typeof oldShutdown) => + ({ + shutdown, + listProcesses: vi.fn(async () => []), + onExit: vi.fn(() => () => {}) + }) as never + const oldProvider = makeProvider(oldShutdown) + const replacementProvider = makeProvider(replacementShutdown) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + registerSshPtyProvider(connectionId, oldProvider) + try { + const attempts = [ + shutdownPtyWithRetainedOwnership({ + id: `ssh:${connectionId}@@legacy-pty`, + connectionId, + provider: oldProvider, + options: { immediate: true } + }), + shutdownPtyWithRetainedOwnership({ + id: `ssh:${connectionId}@@boot-a@@generated-pty`, + connectionId, + provider: oldProvider, + options: { immediate: true } + }) + ] + await Promise.all(attempts.map((attempt) => expect(attempt).rejects.toThrow())) + expect(oldShutdown).toHaveBeenCalledTimes(2) + + releasePendingSshShutdownsForTarget(connectionId) + registerSshPtyProvider(connectionId, replacementProvider) + await vi.advanceTimersByTimeAsync(30_000) + + expect(replacementShutdown).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + } finally { + unregisterSshPtyProvider(connectionId) + releasePendingSshShutdownsForTarget(connectionId) + warn.mockRestore() + vi.useRealTimers() + } + }) + + it('does not let an old provider exit readback settle replacement retry ownership', async () => { + vi.useFakeTimers() + type ProcessInfo = { id: string; cwd: string; title: string } + let resolveOldReadback!: (processes: ProcessInfo[]) => void + const oldReadback = new Promise((resolve) => { + resolveOldReadback = resolve + }) + const oldListProcesses = vi.fn(() => oldReadback) + const newShutdown = vi.fn().mockResolvedValue(undefined) + const provider = ( + shutdown: (id: string) => Promise, + listProcesses: () => Promise + ) => + ({ + requiresShutdownExitProof: true, + shutdown, + listProcesses, + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}) + }) as never + setLocalPtyProvider(provider(vi.fn().mockResolvedValue(undefined), oldListProcesses)) + handlers.clear() + registerPtyHandlers(mainWindow as never, undefined, undefined, undefined, undefined, { + getPendingLocalPtyShutdowns: () => [], + upsertPendingLocalPtyShutdown: vi.fn(), + removePendingLocalPtyShutdown: vi.fn() + } as never) + + const kill = handlers.get('pty:kill')!(null, { id: 'local-pty' }) as Promise + await vi.waitFor(() => expect(oldListProcesses).toHaveBeenCalledOnce()) + setLocalPtyProvider(provider(newShutdown, vi.fn().mockResolvedValue([]))) + resolveOldReadback([]) + + await expect(kill).resolves.toBeUndefined() + expect(newShutdown).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(30_000) + expect(vi.getTimerCount()).toBe(0) + vi.useRealTimers() + }) + + it('retries legacy intent retirement when durable removal fails', async () => { + vi.useFakeTimers() + const removePendingLocalPtyShutdown = vi + .fn() + .mockImplementationOnce(() => { + throw new Error('legacy intent removal disk full') + }) + .mockReturnValue(undefined) + const onPtyExit = vi.fn() + setLocalPtyProvider({ + shutdown: vi.fn().mockRejectedValue(new Error('Legacy PTY identity unavailable for "p"')), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}) + } as never) + handlers.clear() + registerPtyHandlers( + mainWindow as never, + { onPtyExit, setPtyController: vi.fn() } as never, + undefined, + undefined, + undefined, + { + getPendingLocalPtyShutdowns: () => [ + { + ptyId: 'legacy-pty', + expectedPaneKey: 'tab-a:leaf-a', + expectedTabId: 'tab-a', + requestedAt: 1 + } + ], + upsertPendingLocalPtyShutdown: vi.fn(), + removePendingLocalPtyShutdown + } as never + ) + + await vi.advanceTimersByTimeAsync(0) + + expect(removePendingLocalPtyShutdown).toHaveBeenCalledWith('legacy-pty') + expect(onPtyExit).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(249) + expect(removePendingLocalPtyShutdown).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(1) + expect(removePendingLocalPtyShutdown).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(30_000) + expect(removePendingLocalPtyShutdown).toHaveBeenCalledTimes(2) + vi.useRealTimers() + }) + + it('re-drives a direct local kill when its provider is replaced in flight', async () => { + vi.useFakeTimers() + const oldShutdown = makeDeferred() + const makeProvider = (shutdown: (id: string) => Promise) => + ({ + shutdown, + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}) + }) as never + const oldProviderShutdown = vi.fn(() => oldShutdown.promise) + const newShutdown = vi.fn().mockResolvedValue(undefined) + const removePendingLocalPtyShutdown = vi.fn() + const upsertPendingLocalPtyShutdown = vi.fn() + setLocalPtyProvider(makeProvider(oldProviderShutdown)) + handlers.clear() + registerPtyHandlers(mainWindow as never, undefined, undefined, undefined, undefined, { + getPendingLocalPtyShutdowns: () => [], + upsertPendingLocalPtyShutdown, + removePendingLocalPtyShutdown + } as never) + + const kill = handlers.get('pty:kill')!(null, { id: 'daemon-pty' }) as Promise + const coalescedKill = handlers.get('pty:kill')!(null, { + id: 'daemon-pty', + keepHistory: true, + expectedPaneKey: 'tab-a:leaf-a', + expectedTabId: 'tab-a' + }) as Promise + expect(oldProviderShutdown).toHaveBeenCalledOnce() + setLocalPtyProvider(makeProvider(newShutdown)) + oldShutdown.resolve() + + await expect(Promise.all([kill, coalescedKill])).resolves.toEqual([undefined, undefined]) + expect(newShutdown).toHaveBeenCalledOnce() + expect(newShutdown).toHaveBeenCalledWith('daemon-pty', { + immediate: true, + keepHistory: true, + expectedPaneKey: 'tab-a:leaf-a', + expectedTabId: 'tab-a' + }) + expect(upsertPendingLocalPtyShutdown).toHaveBeenLastCalledWith({ + ptyId: 'daemon-pty', + expectedPaneKey: 'tab-a:leaf-a', + expectedTabId: 'tab-a', + requestedAt: expect.any(Number) + }) + expect(removePendingLocalPtyShutdown).toHaveBeenCalledWith('daemon-pty') + await vi.advanceTimersByTimeAsync(30_000) + expect(vi.getTimerCount()).toBe(0) + vi.useRealTimers() + }) + + it('keeps a replacement pane retry owner when the prior local kill settles late', async () => { + vi.useFakeTimers() + const shutdownA = makeDeferred() + const shutdownB = makeDeferred() + const shutdown = vi.fn((_id: string, options: { expectedPaneKey?: string }) => + options.expectedPaneKey === 'tab-a:leaf-a' ? shutdownA.promise : shutdownB.promise + ) + const removePendingLocalPtyShutdown = vi.fn() + const upsertPendingLocalPtyShutdown = vi.fn() + setLocalPtyProvider({ + shutdown, + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}) + } as never) + handlers.clear() + registerPtyHandlers(mainWindow as never, undefined, undefined, undefined, undefined, { + getPendingLocalPtyShutdowns: () => [], + upsertPendingLocalPtyShutdown, + removePendingLocalPtyShutdown + } as never) + + const first = handlers.get('pty:kill')!(null, { + id: 'reused-daemon-pty', + expectedPaneKey: 'tab-a:leaf-a', + expectedTabId: 'tab-a' + }) as Promise + const replacement = handlers.get('pty:kill')!(null, { + id: 'reused-daemon-pty', + expectedPaneKey: 'tab-b:leaf-b', + expectedTabId: 'tab-b' + }) as Promise + expect(shutdown).toHaveBeenCalledTimes(2) + + shutdownA.resolve() + await expect(first).resolves.toBeUndefined() + expect(removePendingLocalPtyShutdown).not.toHaveBeenCalled() + + shutdownB.resolve() + await expect(replacement).resolves.toBeUndefined() + expect(upsertPendingLocalPtyShutdown).toHaveBeenLastCalledWith({ + ptyId: 'reused-daemon-pty', + expectedPaneKey: 'tab-b:leaf-b', + expectedTabId: 'tab-b', + requestedAt: expect.any(Number) + }) + expect(removePendingLocalPtyShutdown).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(30_000) + expect(vi.getTimerCount()).toBe(0) + vi.useRealTimers() + }) + + it('re-drives a direct SSH kill when its provider is replaced in flight', async () => { + vi.useFakeTimers() + const oldShutdown = makeDeferred() + const newShutdown = vi.fn().mockResolvedValue(undefined) + const lease: Record = { + targetId: 'ssh-1', + ptyId: 'relay-pty', + relayInstanceId: 'boot-1', + state: 'detached', + createdAt: 1, + updatedAt: 1 + } + const store = { + getSshRemotePtyLeases: vi.fn(() => [lease]), + markSshRemotePtyShutdownRequested: vi.fn(() => { + lease.shutdownRequestedAt = 2 + }), + markSshRemotePtyLease: vi.fn() + } + registerSshPtyProvider('ssh-1', { + shutdown: vi.fn(() => oldShutdown.promise), + onExit: vi.fn(() => () => {}) + } as never) + handlers.clear() + registerPtyHandlers( + mainWindow as never, + undefined, + undefined, + undefined, + undefined, + store as never + ) + const id = 'ssh:ssh-1@@boot-1@@relay-pty' + + const kill = handlers.get('pty:kill')!(null, { id }) as Promise + registerSshPtyProvider('ssh-1', { + shutdown: newShutdown, + onExit: vi.fn(() => () => {}) + } as never) + oldShutdown.resolve() + + await expect(kill).resolves.toBeUndefined() + expect(newShutdown).toHaveBeenCalledOnce() + expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', id, 'terminated') + unregisterSshPtyProvider('ssh-1') + await vi.advanceTimersByTimeAsync(30_000) + expect(vi.getTimerCount()).toBe(0) + vi.useRealTimers() + }) + + it('re-drives a lease-less SSH kill after provider unregister and replacement', async () => { + vi.useFakeTimers() + const oldShutdown = makeDeferred() + const oldProviderShutdown = vi.fn(() => oldShutdown.promise) + const newProviderShutdown = vi.fn().mockResolvedValue(undefined) + const store = { + getSshRemotePtyLeases: vi.fn(() => []), + markSshRemotePtyShutdownRequested: vi.fn(() => false), + markSshRemotePtyLease: vi.fn() + } + handlers.clear() + registerPtyHandlers( + mainWindow as never, + undefined, + undefined, + undefined, + undefined, + store as never + ) + registerSshPtyProvider('ssh-1', { + shutdown: oldProviderShutdown, + onExit: vi.fn(() => () => {}) + } as never) + const id = 'ssh:ssh-1@@boot-1@@lease-less-pty' + + const kill = handlers.get('pty:kill')!(null, { + id, + expectedPaneKey: 'tab-a:leaf-a', + expectedTabId: 'tab-a' + }) as Promise + expect(oldProviderShutdown).toHaveBeenCalledOnce() + unregisterSshPtyProvider('ssh-1') + registerSshPtyProvider('ssh-1', { + shutdown: newProviderShutdown, + onExit: vi.fn(() => () => {}) + } as never) + oldShutdown.resolve() + + await expect(kill).resolves.toBeUndefined() + expect(newProviderShutdown).toHaveBeenCalledOnce() + expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', id, 'terminated') + unregisterSshPtyProvider('ssh-1') + await vi.advanceTimersByTimeAsync(30_000) + expect(vi.getTimerCount()).toBe(0) + vi.useRealTimers() + }) + + it('rebinds a persisted local shutdown after async daemon startup replaces the provider', async () => { + vi.useFakeTimers() + const startup = makeDeferred() + const makeProvider = (shutdown: ReturnType) => + ({ + spawn: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown, + sendSignal: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}) + }) as never + const fallbackShutdown = vi.fn().mockResolvedValue(undefined) + const daemonShutdown = vi.fn().mockResolvedValue(undefined) + const removePendingLocalPtyShutdown = vi.fn() + const upsertPendingLocalPtyShutdown = vi.fn(() => { + throw new Error('startup replay must not rewrite durable intent') + }) + setLocalPtyProvider(makeProvider(fallbackShutdown)) + handlers.clear() + registerPtyHandlers( + mainWindow as never, + undefined, + undefined, + undefined, + undefined, + { + getPendingLocalPtyShutdowns: () => [{ ptyId: 'daemon-pty', requestedAt: 1 }], + upsertPendingLocalPtyShutdown, + removePendingLocalPtyShutdown + } as never, + { awaitLocalPtyStartup: () => startup.promise } + ) + await vi.advanceTimersByTimeAsync(0) + expect(fallbackShutdown).not.toHaveBeenCalled() + + setLocalPtyProvider(makeProvider(daemonShutdown)) + startup.resolve() + await vi.advanceTimersByTimeAsync(0) + + expect(fallbackShutdown).not.toHaveBeenCalled() + expect(upsertPendingLocalPtyShutdown).not.toHaveBeenCalled() + expect(daemonShutdown).toHaveBeenCalledOnce() + expect(removePendingLocalPtyShutdown).toHaveBeenCalledWith('daemon-pty') + await vi.advanceTimersByTimeAsync(30_000) + expect(vi.getTimerCount()).toBe(0) + vi.useRealTimers() + }) + + it('blocks renderer same-id restore until startup teardown replay completes', async () => { + const startup = makeDeferred() + const shutdownGate = makeDeferred() + const spawn = vi.fn().mockResolvedValue({ id: 'daemon-pty' }) + const shutdown = vi.fn(() => shutdownGate.promise) + const removePendingLocalPtyShutdown = vi.fn() + setLocalPtyProvider({ + spawn, + shutdown, + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}) + } as never) + handlers.clear() + registerPtyHandlers( + mainWindow as never, + undefined, + undefined, + undefined, + undefined, + { + getPendingLocalPtyShutdowns: () => [{ ptyId: 'daemon-pty', requestedAt: 1 }], + upsertPendingLocalPtyShutdown: vi.fn(), + removePendingLocalPtyShutdown + } as never, + { awaitLocalPtyStartup: () => startup.promise } + ) + + const restore = handlers.get('pty:spawn')!(null, { + sessionId: 'daemon-pty', + cols: 80, + rows: 24 + }) as Promise + startup.resolve() + + await expect(restore).rejects.toThrow('shutdown is still pending') + expect(shutdown).toHaveBeenCalledOnce() + expect(spawn).not.toHaveBeenCalled() + + shutdownGate.resolve() + await vi.waitFor(() => expect(removePendingLocalPtyShutdown).toHaveBeenCalledOnce()) + await expect( + handlers.get('pty:spawn')!(null, { sessionId: 'daemon-pty', cols: 80, rows: 24 }) + ).resolves.toMatchObject({ id: 'daemon-pty' }) + expect(spawn).toHaveBeenCalledOnce() + }) + + it('blocks runtime same-id restore while startup teardown replay owns the PTY', async () => { + const startup = makeDeferred() + const shutdownGate = makeDeferred() + const spawn = vi.fn().mockResolvedValue({ id: 'daemon-pty' }) + const shutdown = vi.fn(() => shutdownGate.promise) + const removePendingLocalPtyShutdown = vi.fn() + setLocalPtyProvider({ + spawn, + shutdown, + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}) + } as never) + const runtime = { setPtyController: vi.fn(), onPtyExit: vi.fn() } + handlers.clear() + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + { + getPendingLocalPtyShutdowns: () => [{ ptyId: 'daemon-pty', requestedAt: 1 }], + upsertPendingLocalPtyShutdown: vi.fn(), + removePendingLocalPtyShutdown + } as never, + { awaitLocalPtyStartup: () => startup.promise } + ) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + spawn: (args: { sessionId: string; cols: number; rows: number }) => Promise + } + + const restore = controller.spawn({ sessionId: 'daemon-pty', cols: 80, rows: 24 }) + startup.resolve() + + await expect(restore).rejects.toThrow('shutdown is still pending') + expect(shutdown).toHaveBeenCalledOnce() + expect(spawn).not.toHaveBeenCalled() + shutdownGate.resolve() + await vi.waitFor(() => expect(removePendingLocalPtyShutdown).toHaveBeenCalledOnce()) + }) + + it('does not let an in-flight old provider complete persisted shutdown ownership', async () => { + vi.useFakeTimers() + let rejectOldShutdown!: (error: Error) => void + const oldShutdown = new Promise((_resolve, reject) => { + rejectOldShutdown = reject + }) + const makeProvider = (shutdown: (id: string) => Promise) => + ({ + shutdown, + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}) + }) as never + const newShutdown = vi.fn().mockResolvedValue(undefined) + const removePendingLocalPtyShutdown = vi.fn() + setLocalPtyProvider(makeProvider(() => oldShutdown)) + handlers.clear() + registerPtyHandlers(mainWindow as never, undefined, undefined, undefined, undefined, { + getPendingLocalPtyShutdowns: () => [{ ptyId: 'daemon-pty', requestedAt: 1 }], + upsertPendingLocalPtyShutdown: vi.fn(), + removePendingLocalPtyShutdown + } as never) + await vi.advanceTimersByTimeAsync(0) + + const coalescedKill = handlers.get('pty:kill')!(null, { id: 'daemon-pty' }) as Promise + setLocalPtyProvider(makeProvider(newShutdown)) + rejectOldShutdown(new Error('Session not found: daemon-pty')) + await expect(coalescedKill).resolves.toBeUndefined() + + expect(newShutdown).toHaveBeenCalledOnce() + expect(removePendingLocalPtyShutdown).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(30_000) + expect(vi.getTimerCount()).toBe(0) + vi.useRealTimers() + }) + + it('does not let a stale SSH provider not-found rejection delete replacement ownership', async () => { + vi.useFakeTimers() + let rejectOldShutdown!: (error: Error) => void + const oldShutdown = vi.fn( + () => + new Promise((_resolve, reject) => { + rejectOldShutdown = reject + }) + ) + const newShutdown = vi.fn().mockResolvedValue(undefined) + const lease = { + targetId: 'ssh-1', + ptyId: 'relay-pty', + relayInstanceId: 'boot-1', + state: 'detached' as const, + shutdownRequestedAt: 2, + createdAt: 1, + updatedAt: 2 + } + const store = { + getSshRemotePtyLeases: vi.fn(() => [lease]), + markSshRemotePtyLease: vi.fn() + } + handlers.clear() + registerPtyHandlers( + mainWindow as never, + undefined, + undefined, + undefined, + undefined, + store as never + ) + registerSshPtyProvider('ssh-1', { + shutdown: oldShutdown, + onExit: vi.fn(() => () => {}) + } as never) + await vi.advanceTimersByTimeAsync(0) + + registerSshPtyProvider('ssh-1', { + shutdown: newShutdown, + onExit: vi.fn(() => () => {}) + } as never) + rejectOldShutdown(new Error('PTY "relay-pty" not found')) + await vi.advanceTimersByTimeAsync(0) + + expect(oldShutdown).toHaveBeenCalledOnce() + expect(newShutdown).toHaveBeenCalledOnce() + expect(store.markSshRemotePtyLease).toHaveBeenCalledWith( + 'ssh-1', + 'ssh:ssh-1@@boot-1@@relay-pty', + 'terminated' + ) + unregisterSshPtyProvider('ssh-1') + await vi.advanceTimersByTimeAsync(30_000) + expect(vi.getTimerCount()).toBe(0) + vi.useRealTimers() + }) + + it('does not let a generationless identity mismatch tombstone a replacement SSH lease', async () => { + vi.useFakeTimers() + const lease: Record = { + targetId: 'ssh-1', + ptyId: 'relay-pty', + state: 'detached', + shutdownRequestedAt: 2, + createdAt: 1, + updatedAt: 2 + } + const markSshRemotePtyLease = vi.fn() + const store = { + getSshRemotePtyLeases: vi.fn(() => [lease]), + markSshRemotePtyLease + } + handlers.clear() + registerPtyHandlers( + mainWindow as never, + undefined, + undefined, + undefined, + undefined, + store as never + ) + const staleShutdown = vi + .fn() + .mockRejectedValue(new Error('PTY "relay-pty" not found (identity mismatch)')) + registerSshPtyProvider('ssh-1', { + shutdown: staleShutdown, + onExit: vi.fn(() => () => {}) + } as never) + await vi.advanceTimersByTimeAsync(0) + + expect(staleShutdown).toHaveBeenCalledOnce() + expect(markSshRemotePtyLease).not.toHaveBeenCalled() + + Object.assign(lease, { relayInstanceId: 'boot-b', state: 'attached' }) + delete lease.shutdownRequestedAt + const replacementShutdown = vi.fn().mockResolvedValue(undefined) + registerSshPtyProvider('ssh-1', { + shutdown: replacementShutdown, + onExit: vi.fn(() => () => {}) + } as never) + await vi.advanceTimersByTimeAsync(30_000) + + expect(replacementShutdown).not.toHaveBeenCalled() + expect(markSshRemotePtyLease).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + unregisterSshPtyProvider('ssh-1') + vi.useRealTimers() + }) + + it('rejects runtime terminal IDs before unowned local provider routing', async () => { + const shutdown = vi.spyOn(getLocalPtyProvider(), 'shutdown') handlers.clear() registerPtyHandlers(mainWindow as never) - await expect(handlers.get('pty:kill')!(null, { id: 'local-pty' })).rejects.toThrow( - 'daemon unavailable' - ) + await expect( + handlers.get('pty:kill')!(null, { id: 'remote:env-1@@terminal-1' }) + ).rejects.toThrow('Invalid PTY provider id') + expect(shutdown).not.toHaveBeenCalled() }) it('synthesizes runtime exit after ordinary daemon-backed pty kill', async () => { @@ -3767,6 +4885,40 @@ describe('registerPtyHandlers', () => { await expect(handlers.get('pty:hasPty')!(null, { id: 'maybe-pty' })).resolves.toBe(null) }) + it('uses one SSH inventory when targeted liveness is unavailable', async () => { + const listProcesses = vi.fn(async () => [{ id: 'ssh-live', cwd: '/remote', title: 'shell' }]) + registerSshPtyProvider('ssh-overflow', { + spawn: vi.fn(), + 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, + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + setPtyOwnership('ssh-live', 'ssh-overflow') + setPtyOwnership('ssh-dead', 'ssh-overflow') + registerPtyHandlers(mainWindow as never) + + await expect(handlers.get('pty:hasPty')!(null, { id: 'ssh-live' })).resolves.toBe(true) + await expect(handlers.get('pty:hasPty')!(null, { id: 'ssh-dead' })).resolves.toBe(false) + + expect(listProcesses).toHaveBeenCalledTimes(2) + }) + it('lists duplicate SSH relay session ids as distinct app sessions', async () => { registerPtyHandlers(mainWindow as never) const shutdownA = vi.fn(async () => undefined) @@ -3871,7 +5023,10 @@ describe('registerPtyHandlers', () => { getProfiles: vi.fn() } as never) const sshShutdown = vi.fn(async () => undefined) - const store = { markSshRemotePtyLease: vi.fn() } + const store = { + markSshRemotePtyLease: vi.fn(), + markSshRemotePtyShutdownRequested: vi.fn() + } registerSshPtyProvider('ssh-1', { spawn: vi.fn(), write: vi.fn(), @@ -3903,17 +5058,27 @@ describe('registerPtyHandlers', () => { store as never ) - await handlers.get('pty:kill')!(null, { id: 'ssh:ssh-1@@relay-pty' }) + await handlers.get('pty:kill')!(null, { + id: 'ssh:ssh-1@@relay-pty', + expectedPaneKey: 'tab-a:leaf-a', + expectedTabId: 'tab-a' + }) expect(sshShutdown).toHaveBeenCalledWith('ssh:ssh-1@@relay-pty', { immediate: true, - keepHistory: false + keepHistory: false, + expectedPaneKey: 'tab-a:leaf-a', + expectedTabId: 'tab-a' }) expect(localShutdown).not.toHaveBeenCalled() - expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'relay-pty', 'terminated') + expect(store.markSshRemotePtyLease).toHaveBeenCalledWith( + 'ssh-1', + 'ssh:ssh-1@@relay-pty', + 'terminated' + ) }) - it('tombstones app-scoped SSH PTY ids instead of falling back local when ownership and provider are absent', async () => { + it('rejects app-scoped SSH PTY kill without tombstoning when the provider is absent', async () => { const localShutdown = vi.fn() setLocalPtyProvider({ spawn: vi.fn(), @@ -3937,7 +5102,10 @@ describe('registerPtyHandlers', () => { getDefaultShell: vi.fn(), getProfiles: vi.fn() } as never) - const store = { markSshRemotePtyLease: vi.fn() } + const store = { + markSshRemotePtyLease: vi.fn(), + markSshRemotePtyShutdownRequested: vi.fn() + } registerPtyHandlers( mainWindow as never, undefined, @@ -3947,10 +5115,117 @@ describe('registerPtyHandlers', () => { store as never ) - await handlers.get('pty:kill')!(null, { id: 'ssh:ssh-1@@relay-pty' }) + await expect(handlers.get('pty:kill')!(null, { id: 'ssh:ssh-1@@relay-pty' })).rejects.toThrow( + 'SSH PTY provider unavailable' + ) expect(localShutdown).not.toHaveBeenCalled() - expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'relay-pty', 'terminated') + expect(store.markSshRemotePtyLease).not.toHaveBeenCalled() + expect(store.markSshRemotePtyShutdownRequested).toHaveBeenCalledWith( + 'ssh-1', + 'ssh:ssh-1@@relay-pty' + ) + }) + + it('retries durable SSH shutdown through repeated reconnect failure then clears timers', async () => { + vi.useFakeTimers() + const lease = { + targetId: 'ssh-1', + ptyId: 'relay-pty', + relayInstanceId: 'boot-1', + tabId: 'tab-a', + leafId: '11111111-1111-4111-8111-111111111111', + state: 'detached' as const, + createdAt: 1, + updatedAt: 1 + } + const store = { + getSshRemotePtyLeases: vi.fn(() => [lease]), + markSshRemotePtyShutdownRequested: vi.fn(() => { + Object.assign(lease, { shutdownRequestedAt: 2 }) + }), + markSshRemotePtyLease: vi.fn() + } + registerPtyHandlers( + mainWindow as never, + undefined, + undefined, + undefined, + undefined, + store as never + ) + const id = 'ssh:ssh-1@@boot-1@@relay-pty' + await expect(handlers.get('pty:kill')!(null, { id })).rejects.toThrow( + 'SSH PTY provider unavailable' + ) + + const shutdown = vi + .fn() + .mockRejectedValueOnce(new Error('offline')) + .mockRejectedValueOnce(new Error('still offline')) + .mockResolvedValue(undefined) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + registerSshPtyProvider('ssh-1', { + shutdown, + onExit: vi.fn(() => () => {}) + } as never) + await vi.advanceTimersByTimeAsync(250) + await vi.advanceTimersByTimeAsync(500) + + expect(shutdown).toHaveBeenCalledTimes(3) + expect(shutdown).toHaveBeenLastCalledWith(id, { + immediate: true, + expectedPaneKey: 'tab-a:11111111-1111-4111-8111-111111111111', + expectedTabId: 'tab-a' + }) + expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', id, 'terminated') + unregisterSshPtyProvider('ssh-1') + await vi.advanceTimersByTimeAsync(30_000) + expect(vi.getTimerCount()).toBe(0) + }) + + it('drops a retained SSH retry when a new relay generation reuses the raw id', async () => { + vi.useFakeTimers() + const lease: Record = { + targetId: 'ssh-1', + ptyId: 'relay-pty', + relayInstanceId: 'boot-a', + state: 'detached', + shutdownRequestedAt: 2, + createdAt: 1, + updatedAt: 2 + } + const store = { getSshRemotePtyLeases: vi.fn(() => [lease]) } + handlers.clear() + registerPtyHandlers( + mainWindow as never, + undefined, + undefined, + undefined, + undefined, + store as never + ) + const oldShutdown = vi.fn().mockRejectedValue(new Error('relay replaced')) + registerSshPtyProvider('ssh-1', { + shutdown: oldShutdown, + onExit: vi.fn(() => () => {}) + } as never) + await vi.advanceTimersByTimeAsync(0) + + Object.assign(lease, { relayInstanceId: 'boot-b', state: 'attached' }) + delete lease.shutdownRequestedAt + const newShutdown = vi.fn() + registerSshPtyProvider('ssh-1', { + shutdown: newShutdown, + onExit: vi.fn(() => () => {}) + } as never) + await vi.advanceTimersByTimeAsync(30_000) + + expect(oldShutdown).toHaveBeenCalledOnce() + expect(newShutdown).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + unregisterSshPtyProvider('ssh-1') + vi.useRealTimers() }) it('ignores fire-and-forget IPC for detached SSH PTYs without a provider', async () => { @@ -4016,8 +5291,10 @@ describe('registerPtyHandlers', () => { listenerFor('pty:signal')(null, { id: 'remote-pty', signal: 'SIGINT' }) ).not.toThrow() - await expect(handlers.get('pty:kill')!(null, { id: 'remote-pty' })).resolves.toBeUndefined() - expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'remote-pty', 'terminated') + await expect(handlers.get('pty:kill')!(null, { id: 'remote-pty' })).rejects.toThrow( + 'SSH PTY provider unavailable' + ) + expect(store.markSshRemotePtyLease).not.toHaveBeenCalled() }) it('returns idle process inspection results for detached SSH PTYs without a provider', async () => { @@ -4495,10 +5772,13 @@ describe('registerPtyHandlers', () => { // Why: this is the load-bearing wiring for #7587 — the runtime can only back a // stalled mobile create from a live spawn if the spawn threads {tabId, leafId}. - expect(runtime.registerPty).toHaveBeenCalledWith(expect.any(String), 'wt-1', null, { - tabId: 'tab-1', - leafId - }) + expect(runtime.registerPty).toHaveBeenCalledWith( + expect.any(String), + 'wt-1', + null, + { tabId: 'tab-1', leafId }, + false + ) }) it('omits the pane identity from registerPty when the leafId is not a terminal leaf (#7587)', async () => { @@ -4527,7 +5807,13 @@ describe('registerPtyHandlers', () => { // Why: legacy numeric pane ids (`pane:N`) are not terminal leaf ids, so the // spawn seam must not fabricate a binding for them (registerPty would ignore // it anyway); this pins that the seam passes a clean `undefined`. - expect(runtime.registerPty).toHaveBeenCalledWith(expect.any(String), 'wt-1', null, undefined) + expect(runtime.registerPty).toHaveBeenCalledWith( + expect.any(String), + 'wt-1', + null, + undefined, + false + ) }) it('refreshes native Agent Teams env when captured teammate mode lives in launch args', async () => { @@ -5002,13 +6288,22 @@ describe('registerPtyHandlers', () => { // awaits a promise that never settles and the tab hangs forever. registerPtyHandlers(mainWindow as never) const leafId = '44444444-4444-4444-8444-444444444444' - const spawnArgs = { cols: 80, rows: 24, tabId: 'tab-reservation', leafId } + const paneKey = makePaneKey('tab-reservation', leafId) + const spawnArgs = { + cols: 80, + rows: 24, + tabId: 'tab-reservation', + leafId, + env: { ORCA_PANE_KEY: paneKey, ORCA_TAB_ID: 'tab-reservation' } + } registerPtyMock.mockImplementationOnce(() => { throw new Error('boom: post-spawn registration failed') }) await expect(handlers.get('pty:spawn')!(null, spawnArgs)).rejects.toThrow('boom') + const orphanedPtyId = (registerPtyMock.mock.calls[0]![0] as { ptyId: string }).ptyId + expect(unregisterPtyMock).toHaveBeenCalledWith(orphanedPtyId) // A second spawn for the same pane must run a fresh spawn rather than await // the leaked (never-settled) reservation promise. @@ -5048,12 +6343,16 @@ describe('registerPtyHandlers', () => { } let spawnCount = 0 const providerSpawn = vi.fn(async () => ({ id: `pty-${++spawnCount}` })) + const shutdown = vi + .fn() + .mockRejectedValueOnce(new Error('native shutdown failed')) + .mockResolvedValue(undefined) setLocalPtyProvider({ spawn: providerSpawn, write: vi.fn(), resize: vi.fn(), kill: vi.fn(), - shutdown: vi.fn(), + shutdown, sendSignal: vi.fn(), getCwd: vi.fn(), getInitialCwd: vi.fn(), @@ -5072,7 +6371,9 @@ describe('registerPtyHandlers', () => { getProfiles: vi.fn() } as never) const store = { - persistPtyBinding: vi.fn() + persistPtyBinding: vi.fn(), + upsertPendingLocalPtyShutdown: vi.fn(), + removePendingLocalPtyShutdown: vi.fn() } let controller: RuntimeSpawnController | null = null const runtime = { @@ -5113,6 +6414,21 @@ describe('registerPtyHandlers', () => { } await expect(spawnController.spawn(spawnArgs)).rejects.toThrow('boom') + expect(shutdown).toHaveBeenCalledWith('pty-1', { + immediate: true, + expectedPaneKey: paneKey, + expectedTabId: 'tab-runtime-reservation' + }) + expect(store.upsertPendingLocalPtyShutdown).toHaveBeenCalledWith({ + ptyId: 'pty-1', + expectedPaneKey: paneKey, + expectedTabId: 'tab-runtime-reservation', + requestedAt: expect.any(Number) + }) + expect(store.removePendingLocalPtyShutdown).not.toHaveBeenCalled() + await vi.waitFor(() => expect(shutdown).toHaveBeenCalledTimes(2)) + expect(store.removePendingLocalPtyShutdown).toHaveBeenCalledWith('pty-1') + expect(unregisterPtyMock).toHaveBeenCalledWith('pty-1') // The reservation must be gone, so a second materialization runs a fresh // provider.spawn instead of awaiting the leaked promise. @@ -5601,7 +6917,7 @@ describe('registerPtyHandlers', () => { persistHostSessionBinding?: boolean }): Promise<{ id: string }> } - const appPtyId = 'ssh:ssh-expired-runtime@@relay-pty' + const appPtyId = 'ssh:ssh-expired-runtime@@boot-a@@relay-pty' const remoteWrite = vi.fn() registerSshPtyProvider('ssh-expired-runtime', { spawn: vi.fn(async () => { @@ -5675,7 +6991,7 @@ describe('registerPtyHandlers', () => { expect(store.markSshRemotePtyLease).toHaveBeenCalledWith( 'ssh-expired-runtime', - 'relay-pty', + appPtyId, 'expired' ) expect(store.upsertSshRemotePtyLease).not.toHaveBeenCalled() @@ -5795,6 +7111,7 @@ describe('registerPtyHandlers', () => { }) it('cleans up fresh runtime-owned SSH spawns when binding persistence fails', async () => { + vi.useFakeTimers() type RuntimeSpawnController = { spawn(args: { cols: number @@ -5808,7 +7125,10 @@ describe('registerPtyHandlers', () => { }): Promise<{ id: string }> } const appPtyId = 'ssh:ssh-fresh-fail@@relay-pty' - const remoteShutdown = vi.fn() + const remoteShutdown = vi + .fn() + .mockRejectedValueOnce(new Error('relay cleanup unavailable')) + .mockResolvedValueOnce(undefined) registerSshPtyProvider('ssh-fresh-fail', { spawn: vi.fn(async () => ({ id: appPtyId })), write: vi.fn(), @@ -5836,7 +7156,8 @@ describe('registerPtyHandlers', () => { throw new Error('disk full') }), removeSshRemotePtyLease: vi.fn(), - markSshRemotePtyLease: vi.fn() + markSshRemotePtyLease: vi.fn(), + markSshRemotePtyShutdownRequested: vi.fn(() => true) } let controller: RuntimeSpawnController | null = null const runtime = { @@ -5877,9 +7198,24 @@ describe('registerPtyHandlers', () => { }) ).rejects.toThrow(/ORCA_TERMINAL_SESSION_STATE_SAVE_FAILED/) - expect(remoteShutdown).toHaveBeenCalledWith(appPtyId, { immediate: true }) - expect(store.upsertSshRemotePtyLease).not.toHaveBeenCalled() + expect(remoteShutdown).toHaveBeenCalledWith(appPtyId, { + immediate: true, + expectedPaneKey: makePaneKey('tab-remote', leafId), + expectedTabId: 'tab-remote' + }) + expect(store.upsertSshRemotePtyLease).toHaveBeenCalled() expect(store.removeSshRemotePtyLease).not.toHaveBeenCalled() + expect(openCodeClearPtyMock).not.toHaveBeenCalledWith(appPtyId) + expect(piClearPtyMock).not.toHaveBeenCalledWith(appPtyId) + + await vi.advanceTimersByTimeAsync(250) + + expect(remoteShutdown).toHaveBeenCalledTimes(2) + expect(store.markSshRemotePtyLease).toHaveBeenCalledWith( + 'ssh-fresh-fail', + appPtyId, + 'terminated' + ) expect(openCodeClearPtyMock).toHaveBeenCalledWith(appPtyId) expect(piClearPtyMock).toHaveBeenCalledWith(appPtyId) } finally { @@ -5887,6 +7223,78 @@ describe('registerPtyHandlers', () => { } }) + it('retains renderer SSH cleanup when binding persistence and first shutdown fail', async () => { + vi.useFakeTimers() + const appPtyId = 'ssh:ssh-1@@renderer-persist-fail' + const remoteShutdown = vi + .fn() + .mockRejectedValueOnce(new Error('relay cleanup unavailable')) + .mockResolvedValueOnce(undefined) + registerSshPtyProvider('ssh-1', { + spawn: vi.fn(async () => ({ id: appPtyId })), + write: vi.fn(), + resize: vi.fn(), + shutdown: remoteShutdown, + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + const store = { + upsertSshRemotePtyLease: vi.fn(), + persistPtyBinding: vi.fn(() => { + throw new Error('disk full') + }), + markSshRemotePtyShutdownRequested: vi.fn(() => true), + markSshRemotePtyLease: vi.fn(), + removeSshRemotePtyLease: vi.fn() + } + registerPtyHandlers( + mainWindow as never, + undefined, + undefined, + undefined, + undefined, + store as never + ) + const leafId = '11111111-1111-4111-8111-111111111111' + const paneKey = makePaneKey('tab-renderer', leafId) + + await expect( + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + connectionId: 'ssh-1', + worktreeId: 'wt-remote', + tabId: 'tab-renderer', + leafId, + env: { ORCA_PANE_KEY: paneKey } + }) + ).rejects.toThrow(/ORCA_TERMINAL_SESSION_STATE_SAVE_FAILED/) + + expect(remoteShutdown).toHaveBeenCalledWith(appPtyId, { + immediate: true, + expectedPaneKey: paneKey, + expectedTabId: 'tab-renderer' + }) + expect(openCodeClearPtyMock).not.toHaveBeenCalledWith(appPtyId) + await vi.advanceTimersByTimeAsync(250) + expect(remoteShutdown).toHaveBeenCalledTimes(2) + expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', appPtyId, 'terminated') + expect(openCodeClearPtyMock).toHaveBeenCalledWith(appPtyId) + }) + it('maps runtime-owned spawn paneKeys for renderer serializer settlement', async () => { type RuntimeSpawnController = { spawn(args: { @@ -6006,10 +7414,12 @@ describe('registerPtyHandlers', () => { expect(spawnCall[0]).toBe('wsl.exe') expect(env.ORCA_TERMINAL_HANDLE).toBe('term_wsl') expect(env.ORCA_USER_DATA_PATH).toBe('/tmp/orca-user-data') + expect(env.ORCA_CLI_COMMAND).toBe('orca-ide') expect(env.WSLENV?.split(':')).toEqual( expect.arrayContaining([ 'ORCA_TERMINAL_HANDLE/u', 'ORCA_USER_DATA_PATH/p', + 'ORCA_CLI_COMMAND/u', 'ORCA_AGENT_HOOK_PORT/u', 'ORCA_AGENT_HOOK_TOKEN/u', 'ORCA_OMP_SOURCE_AGENT_DIR/p', @@ -6639,6 +8049,8 @@ describe('registerPtyHandlers', () => { it('spawns a plain POSIX login shell and queues startup commands for the live session', async () => { const originalPlatform = process.platform + const originalHome = process.env.HOME + const originalOrcaOrigZdotdir = process.env.ORCA_ORIG_ZDOTDIR const originalShell = process.env.SHELL const originalZdotdir = process.env.ZDOTDIR @@ -6646,6 +8058,9 @@ describe('registerPtyHandlers', () => { configurable: true, value: 'darwin' }) + // Why: this test simulates macOS even when Vitest runs on a Windows host. + process.env.HOME = '/Users/test' + delete process.env.ORCA_ORIG_ZDOTDIR process.env.SHELL = '/bin/zsh' delete process.env.ZDOTDIR @@ -6663,6 +8078,16 @@ describe('registerPtyHandlers', () => { configurable: true, value: originalPlatform }) + if (originalHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = originalHome + } + if (originalOrcaOrigZdotdir === undefined) { + delete process.env.ORCA_ORIG_ZDOTDIR + } else { + process.env.ORCA_ORIG_ZDOTDIR = originalOrcaOrigZdotdir + } if (originalShell === undefined) { delete process.env.SHELL } else { @@ -10384,7 +11809,9 @@ describe('registerPtyHandlers', () => { expect(registerPaneKeyAliasMock).toHaveBeenCalledWith( 'tab-1:0', stablePaneKey, - expect.any(String) + expect.any(String), + expect.any(Number), + { authorityVerified: true } ) expect(clearMigrationUnsupportedPtysForPaneKeyMock).toHaveBeenCalledWith(stablePaneKey) expect(setMigrationUnsupportedPtyMock).not.toHaveBeenCalled() @@ -10558,17 +11985,21 @@ describe('registerPtyHandlers', () => { expect(piClearPtyMock).toHaveBeenCalledWith(spawnResult.id) }) - it('disposes PTY listeners before manual kill IPC', async () => { + it('keeps PTY listeners until manual native kill is accepted', async () => { const onDataDisposable = makeDisposable() const onExitDisposable = makeDisposable() // Why: hold a stable reference to the kill spy. On POSIX, destroyPtyProcess // in local-pty-provider reassigns proc.kill to a no-op to defuse the // SIGHUP-to-recycled-pid hazard (see docs/fix-pty-fd-leak.md). Reading // proc.kill.mock after that runs would yield a non-mock and crash. - const killSpy = vi.fn() + let exitCb: ((info: { exitCode: number }) => void) | null = null + const killSpy = vi.fn(() => exitCb?.({ exitCode: -1 })) const proc = { onData: vi.fn(() => onDataDisposable), - onExit: vi.fn(() => onExitDisposable), + onExit: vi.fn((cb: (info: { exitCode: number }) => void) => { + exitCb = cb + return onExitDisposable + }), write: vi.fn(), resize: vi.fn(), kill: killSpy, @@ -10585,21 +12016,25 @@ describe('registerPtyHandlers', () => { await handlers.get('pty:kill')!(null, { id: spawnResult.id }) - expect(onDataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - killSpy.mock.invocationCallOrder[0] + expect(killSpy.mock.invocationCallOrder[0]).toBeLessThan( + onDataDisposable.dispose.mock.invocationCallOrder[0] ) - expect(onExitDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - killSpy.mock.invocationCallOrder[0] + expect(killSpy.mock.invocationCallOrder[0]).toBeLessThan( + onExitDisposable.dispose.mock.invocationCallOrder[0] ) }) - it('disposes PTY listeners before runtime controller kill', async () => { + it('keeps PTY listeners until runtime-controller native kill is accepted', async () => { const onDataDisposable = makeDisposable() const onExitDisposable = makeDisposable() - const killSpy = vi.fn() + let exitCb: ((info: { exitCode: number }) => void) | null = null + const killSpy = vi.fn(() => exitCb?.({ exitCode: -1 })) const proc = { onData: vi.fn(() => onDataDisposable), - onExit: vi.fn(() => onExitDisposable), + onExit: vi.fn((cb: (info: { exitCode: number }) => void) => { + exitCb = cb + return onExitDisposable + }), write: vi.fn(), resize: vi.fn(), kill: killSpy, @@ -10626,21 +12061,25 @@ describe('registerPtyHandlers', () => { } expect(runtimeController.kill(spawnResult.id)).toBe(true) - expect(onDataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - killSpy.mock.invocationCallOrder[0] + expect(killSpy.mock.invocationCallOrder[0]).toBeLessThan( + onDataDisposable.dispose.mock.invocationCallOrder[0] ) - expect(onExitDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - killSpy.mock.invocationCallOrder[0] + expect(killSpy.mock.invocationCallOrder[0]).toBeLessThan( + onExitDisposable.dispose.mock.invocationCallOrder[0] ) }) - it('disposes PTY listeners before did-finish-load orphan cleanup', async () => { + it('keeps PTY listeners through did-finish-load orphan shutdown acceptance', async () => { const onDataDisposable = makeDisposable() const onExitDisposable = makeDisposable() - const killSpy = vi.fn() + let exitCb: ((info: { exitCode: number }) => void) | null = null + const killSpy = vi.fn(() => exitCb?.({ exitCode: -1 })) const proc = { onData: vi.fn(() => onDataDisposable), - onExit: vi.fn(() => onExitDisposable), + onExit: vi.fn((cb: (info: { exitCode: number }) => void) => { + exitCb = cb + return onExitDisposable + }), write: vi.fn(), resize: vi.fn(), kill: killSpy, @@ -10676,14 +12115,70 @@ describe('registerPtyHandlers', () => { didFinishLoad() didFinishLoad() - expect(onDataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - killSpy.mock.invocationCallOrder[0] + await vi.waitFor(() => expect(killSpy).toHaveBeenCalledOnce()) + expect(killSpy.mock.invocationCallOrder[0]).toBeLessThan( + onDataDisposable.dispose.mock.invocationCallOrder[0] ) - expect(onExitDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - killSpy.mock.invocationCallOrder[0] + expect(killSpy.mock.invocationCallOrder[0]).toBeLessThan( + onExitDisposable.dispose.mock.invocationCallOrder[0] ) }) + it('retains a reload-orphan PTY when native shutdown throws before exit proof', async () => { + const onDataDisposable = makeDisposable() + const onExitDisposable = makeDisposable() + const killSpy = vi.fn(() => { + throw new Error('native kill failed') + }) + spawnMock.mockReturnValue({ + onData: vi.fn(() => onDataDisposable), + onExit: vi.fn(() => onExitDisposable), + write: vi.fn(), + resize: vi.fn(), + kill: killSpy, + process: 'zsh', + pid: 12345 + }) + const runtime = { + setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyData: vi.fn(), + onPtyExit: vi.fn(), + preAllocateHandleForPty: vi.fn() + } + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const processProbe = vi.spyOn(process, 'kill').mockImplementation(() => true) + try { + registerPtyHandlers(mainWindow as never, runtime as never) + const didFinishLoadHandlers = mainWindow.webContents.on.mock.calls + .filter(([eventName]) => eventName === 'did-finish-load') + .map(([, handler]) => handler as () => void) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24 + })) as { id: string } + + for (const handler of didFinishLoadHandlers) { + handler() + } + for (const handler of didFinishLoadHandlers) { + handler() + } + await vi.waitFor(() => expect(killSpy).toHaveBeenCalledOnce()) + + expect(onDataDisposable.dispose).not.toHaveBeenCalled() + expect(onExitDisposable.dispose).not.toHaveBeenCalled() + expect(runtime.onPtyExit).not.toHaveBeenCalled() + expect((await getLocalPtyProvider().listProcesses()).map(({ id }) => id)).toContain( + spawnResult.id + ) + } finally { + processProbe.mockRestore() + warn.mockRestore() + } + }) + it('removes the previous orphan-cleanup listener from its original webContents', () => { const firstWindow = { isDestroyed: () => false, @@ -10809,10 +12304,14 @@ describe('registerPtyHandlers', () => { // Why: guard against over-suppression — when no recovery reload is in flight the // sweep MUST still reclaim genuinely orphaned local PTYs. it('still sweeps orphaned local PTYs when no recovery reload is in flight', async () => { - const killSpy = vi.fn() + let exitCb: ((info: { exitCode: number }) => void) | null = null + const killSpy = vi.fn(() => exitCb?.({ exitCode: -1 })) const proc = { onData: vi.fn(() => makeDisposable()), - onExit: vi.fn(() => makeDisposable()), + onExit: vi.fn((cb: (info: { exitCode: number }) => void) => { + exitCb = cb + return makeDisposable() + }), write: vi.fn(), resize: vi.fn(), kill: killSpy, @@ -10854,8 +12353,8 @@ describe('registerPtyHandlers', () => { didFinishLoad() didFinishLoad() - expect(killSpy).toHaveBeenCalled() - expect(runtime.onPtyExit).toHaveBeenCalledWith(spawnResult.id, -1) + await vi.waitFor(() => expect(killSpy).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(runtime.onPtyExit).toHaveBeenCalledWith(spawnResult.id, -1)) const listed = await getLocalPtyProvider().listProcesses() expect(listed.some((info) => info.id === spawnResult.id)).toBe(false) }) @@ -10925,7 +12424,7 @@ describe('registerPtyHandlers', () => { expect(ids).toContain(ptyB.id) }) - it('clears PTY state even when kill reports the process is already gone', async () => { + it('retains PTY state when native kill throws without an exit proof', async () => { const proc = { onData: vi.fn(() => makeDisposable()), onExit: vi.fn(() => makeDisposable()), @@ -10945,11 +12444,20 @@ describe('registerPtyHandlers', () => { rows: 24 })) as { id: string } - await handlers.get('pty:kill')!(null, { id: spawnResult.id }) + const processProbe = vi.spyOn(process, 'kill').mockImplementation(() => true) + try { + await expect(handlers.get('pty:kill')!(null, { id: spawnResult.id })).rejects.toThrow( + 'already dead' + ) + } finally { + processProbe.mockRestore() + } - expect(await handlers.get('pty:hasChildProcesses')!(null, { id: spawnResult.id })).toBe(false) - expect(openCodeClearPtyMock).toHaveBeenCalledWith(spawnResult.id) - expect(piClearPtyMock).toHaveBeenCalledWith(spawnResult.id) + expect((await getLocalPtyProvider().listProcesses()).map(({ id }) => id)).toContain( + spawnResult.id + ) + expect(openCodeClearPtyMock).not.toHaveBeenCalledWith(spawnResult.id) + expect(piClearPtyMock).not.toHaveBeenCalledWith(spawnResult.id) }) describe('agent_started telemetry', () => { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 4dcc451324a..070e7dba6ad 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -19,6 +19,7 @@ export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-rea import type { OrcaRuntimeService } from '../runtime/orca-runtime' import type { Store } from '../persistence' import type { GlobalSettings, TuiAgent } from '../../shared/types' +import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../shared/execution-host' import { terminalOutputBacklogCapChars } from '../../shared/terminal-scrollback-policy' import type { PtyDeliveryWriteOff, @@ -54,7 +55,13 @@ import { piTitlebarExtensionService } from '../pi/titlebar-extension-service' import { detectPiAgentKindFromCommand, type PiAgentKind } from '../../shared/pi-agent-kind' import { isPwshAvailable } from '../pwsh' import { LocalPtyProvider } from '../providers/local-pty-provider' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' +import type { + IPtyProvider, + PtyProcessInfo, + PtyShutdownOptions, + PtySpawnOptions, + PtySpawnResult +} from '../providers/types' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import { SSH_SESSION_EXPIRED_ERROR, @@ -78,6 +85,7 @@ import { applyTerminalAttributionEnv, resolveAttributionShellFamily } from '../attribution/terminal-attribution' +import { ensureLinuxTerminalOrcaCliShimDir } from '../cli/linux-terminal-orca-cli-shim' import { registerPty, unregisterPty } from '../memory/pty-registry' import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' import { track } from '../telemetry/client' @@ -165,6 +173,39 @@ type FreshLocalFallbackProvider = IPtyProvider & { routesFreshSpawnsToLocalProvider?: true } const sshProviders = new Map() +let activePtyStore: Store | null = null +let notifyRetainedPtyExit: ((id: string) => void) | null = null +type RetainedPtyShutdown = { + id: string + connectionId: string | null + options: PtyShutdownOptions + provider: IPtyProvider | null + store: Store | null + durable: boolean + attempts: number + shutdownAccepted: boolean + providerShutdownComplete: boolean + providerExitObserved: boolean + requireExitProof: boolean + nextRetryAt: number + inFlight: Promise | null + lastError: Error | null +} +const pendingSshShutdownRetries = new Map() +const pendingLocalShutdownRetries = new Map() +const providerProcessListsInFlight = new WeakMap>() +const sshRetainedExitUnsubscribers = new Map void>() +let retainedShutdownRetryTimer: ReturnType | null = null +const MAX_RETAINED_SHUTDOWN_BACKOFF_MS = 30_000 + +function assertPtySpawnHasNoRetainedShutdown(id: string | undefined): void { + if (id && (pendingLocalShutdownRetries.has(id) || pendingSshShutdownRetries.has(id))) { + // Why: replacing or attaching this exact id would strand the retained + // teardown owner on the older native process/provider generation. + throw new Error(`PTY "${id}" shutdown is still pending`) + } +} + const SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS = 30_000 // Why: producer flow control changes terminal physics — a flooding shell now // blocks on write instead of buffering in main. Kill switch: flip this one @@ -476,7 +517,10 @@ function normalizeNodePtySpawnError(err: unknown): Error { function isPtyAlreadyGoneError(err: unknown): boolean { const message = err instanceof Error ? err.message : String(err) - return isSshPtyNotFoundError(err) || /Session not found/i.test(message) + // Why: relay generation/identity mismatch proves this caller is stale, not + // that the replacement PTY is gone. Destructive cleanup must not cross it. + const identityMismatch = /identity mismatch|relay generation mismatch/i.test(message) + return (!identityMismatch && isSshPtyNotFoundError(err)) || /Session not found/i.test(message) } function delay(ms: number): Promise { @@ -489,7 +533,19 @@ function delay(ms: number): Promise { } async function isProviderPtyLive(provider: IPtyProvider, ptyId: string): Promise { - return (await provider.listProcesses()).some((session) => session.id === ptyId) + let listing = providerProcessListsInFlight.get(provider) + if (!listing) { + listing = provider.listProcesses() + providerProcessListsInFlight.set(provider, listing) + try { + return (await listing).some((session) => session.id === ptyId) + } finally { + if (providerProcessListsInFlight.get(provider) === listing) { + providerProcessListsInFlight.delete(provider) + } + } + } + return (await listing).some((session) => session.id === ptyId) } async function verifyPtyStopped( @@ -513,6 +569,25 @@ async function verifyPtyStopped( return true } +async function shutdownProviderAndDetectExit( + provider: IPtyProvider, + id: string, + opts: PtyShutdownOptions +): Promise { + let providerExitObserved = false + const unsubscribe = provider.onExit((payload) => { + if (payload.id === id) { + providerExitObserved = true + } + }) + try { + await provider.shutdown(id, opts) + } finally { + unsubscribe() + } + return providerExitObserved +} + function finishPtyShutdown( id: string, connectionId: string | null | undefined, @@ -520,12 +595,436 @@ function finishPtyShutdown( ): void { clearProviderPtyState(id) if (connectionId) { - store?.markSshRemotePtyLease(connectionId, getRelayPtyId(connectionId, id), 'terminated') + store?.markSshRemotePtyLease?.(connectionId, id, 'terminated') } ptyOwnership.delete(id) markClaudePtyExited(id) } +function scheduleRetainedShutdownRetries(): void { + if (retainedShutdownRetryTimer) { + clearTimeout(retainedShutdownRetryTimer) + } + retainedShutdownRetryTimer = null + let nextRetryAt = Number.POSITIVE_INFINITY + const retainedShutdowns = [ + ...pendingLocalShutdownRetries.values(), + ...pendingSshShutdownRetries.values() + ] + for (const retained of retainedShutdowns) { + if (!retained.inFlight) { + nextRetryAt = Math.min(nextRetryAt, retained.nextRetryAt) + } + } + if (!Number.isFinite(nextRetryAt)) { + return + } + retainedShutdownRetryTimer = setTimeout( + () => { + retainedShutdownRetryTimer = null + const now = Date.now() + // Why: target removal can invalidate owners while this timer waits; only live map entries may retry. + for (const retained of [ + ...pendingLocalShutdownRetries.values(), + ...pendingSshShutdownRetries.values() + ]) { + if (!retained.inFlight && retained.nextRetryAt <= now) { + // Why: timer-owned retries have no caller to observe a persistence + // rejection after provider shutdown has already completed. + void attemptRetainedPtyShutdown(retained).catch(() => {}) + } + } + }, + Math.max(0, nextRetryAt - Date.now()) + ) +} + +function completeRetainedPtyShutdown( + retained: RetainedPtyShutdown, + providerExitObserved: boolean +): void { + const ownerMap = retained.connectionId ? pendingSshShutdownRetries : pendingLocalShutdownRetries + if (ownerMap.get(retained.id) !== retained) { + return + } + if (!retained.connectionId) { + retained.store?.removePendingLocalPtyShutdown?.(retained.id) + } + finishPtyShutdown(retained.id, retained.connectionId, retained.store ?? undefined) + ownerMap.delete(retained.id) + if (!providerExitObserved) { + notifyRetainedPtyExit?.(retained.id) + } +} + +function retireUnprovableLegacyPtyShutdown(retained: RetainedPtyShutdown): void { + const ownerMap = retained.connectionId ? pendingSshShutdownRetries : pendingLocalShutdownRetries + if (ownerMap.get(retained.id) !== retained) { + return + } + // Why: legacy restart loses pane identity permanently. Drop only the stale + // durable request; without exit proof, provider/UI ownership must survive. + if (!retained.connectionId) { + try { + retained.store?.removePendingLocalPtyShutdown?.(retained.id) + } catch (error) { + recordRetainedPtyShutdownFailure(retained, error) + return + } + } + ownerMap.delete(retained.id) +} + +function deferRetainedPtyShutdownRetry(retained: RetainedPtyShutdown): void { + retained.attempts += 1 + retained.nextRetryAt = + Date.now() + + Math.min(MAX_RETAINED_SHUTDOWN_BACKOFF_MS, 250 * 2 ** Math.min(retained.attempts - 1, 7)) +} + +function recordRetainedPtyShutdownFailure(retained: RetainedPtyShutdown, error: unknown): void { + retained.lastError = error instanceof Error ? error : new Error(String(error)) + deferRetainedPtyShutdownRetry(retained) + if (retained.attempts <= 2 || retained.attempts === 8) { + console.warn('[pty] retained shutdown retry failed:', error) + } +} + +function settleRetainedPtyShutdownOnExit(retained: RetainedPtyShutdown): void { + retained.providerShutdownComplete = true + retained.providerExitObserved = true + try { + completeRetainedPtyShutdown(retained, true) + } catch (error) { + // Why: persistence failure must keep the retry owner without aborting the + // provider's exit fanout into runtime and renderer cleanup. + recordRetainedPtyShutdownFailure(retained, error) + scheduleRetainedShutdownRetries() + } +} + +function attemptRetainedPtyShutdown(retained: RetainedPtyShutdown): Promise { + if (retained.inFlight) { + return retained.inFlight + } + const currentProvider = retained.connectionId + ? sshProviders.get(retained.connectionId) + : localProvider + if (!currentProvider || (retained.provider && retained.provider !== currentProvider)) { + retained.nextRetryAt = Number.POSITIVE_INFINITY + scheduleRetainedShutdownRetries() + return Promise.resolve() + } + retained.provider = currentProvider + retained.lastError = null + const attempt = (async () => { + if (retained.providerShutdownComplete) { + completeRetainedPtyShutdown(retained, retained.providerExitObserved) + return + } + if (retained.shutdownAccepted) { + const stopped = await verifyPtyStopped(currentProvider, retained.id, retained.options) + if (retained.provider !== currentProvider) { + return + } + if (!stopped) { + deferRetainedPtyShutdownRetry(retained) + return + } + retained.providerShutdownComplete = true + retained.providerExitObserved = false + completeRetainedPtyShutdown(retained, false) + return + } + let providerExitObserved = false + let providerAlreadyGone = false + try { + providerExitObserved = await shutdownProviderAndDetectExit( + currentProvider, + retained.id, + retained.options + ) + } catch (error) { + if (!isPtyAlreadyGoneError(error)) { + throw error + } + providerAlreadyGone = true + } + // Why: provider replacement can finish while an older shutdown is in flight. + if (retained.provider !== currentProvider) { + return + } + // Why: an exit callback can settle this owner before shutdown() resolves. + // Its durable retry is already scheduled, so do not settle it twice. + if (retained.providerShutdownComplete) { + return + } + if (providerAlreadyGone) { + retained.providerShutdownComplete = true + retained.providerExitObserved = false + completeRetainedPtyShutdown(retained, false) + return + } + if ( + (currentProvider.requiresShutdownExitProof || retained.requireExitProof) && + !providerExitObserved + ) { + // Why: native Windows close is not idempotent. Once accepted, retry + // only the liveness proof; a second close can corrupt the ConPTY heap. + retained.shutdownAccepted = true + const stopped = await verifyPtyStopped(currentProvider, retained.id, retained.options) + // Why: provider replacement can also occur during the async liveness + // readback; only the provider that still owns this retry may settle it. + if (retained.provider !== currentProvider) { + return + } + if (!stopped) { + deferRetainedPtyShutdownRetry(retained) + return + } + } + retained.providerShutdownComplete = true + retained.providerExitObserved = providerExitObserved + completeRetainedPtyShutdown(retained, providerExitObserved) + })() + .catch((error: unknown) => { + const ownerMap = retained.connectionId + ? pendingSshShutdownRetries + : pendingLocalShutdownRetries + if (ownerMap.get(retained.id) !== retained) { + return + } + if (retained.provider !== currentProvider) { + return + } + if (isPtyAlreadyGoneError(error)) { + completeRetainedPtyShutdown(retained, false) + return + } + if (/Legacy PTY identity unavailable/i.test(error instanceof Error ? error.message : '')) { + // Why: after restart a pre-v22 daemon cannot prove pane identity. Retire + // the stale durable owner instead of retrying forever or killing by ID. + retireUnprovableLegacyPtyShutdown(retained) + return + } + recordRetainedPtyShutdownFailure(retained, error) + }) + .finally(() => { + if (retained.inFlight === attempt) { + retained.inFlight = null + } + scheduleRetainedShutdownRetries() + }) + retained.inFlight = attempt + return attempt +} + +async function attemptRetainedPtyShutdownForCaller( + retained: RetainedPtyShutdown, + settleWhenShutdownAccepted = false +): Promise { + const ownerMap = retained.connectionId ? pendingSshShutdownRetries : pendingLocalShutdownRetries + while (ownerMap.get(retained.id) === retained) { + await attemptRetainedPtyShutdown(retained) + if (ownerMap.get(retained.id) !== retained) { + return + } + if (retained.lastError) { + throw retained.lastError + } + const currentProvider = retained.connectionId + ? sshProviders.get(retained.connectionId) + : localProvider + // Why: an old provider can settle after its replacement is installed. + // Re-drive that immediately available owner before settling this caller. + if (currentProvider && retained.nextRetryAt <= Date.now()) { + continue + } + if (settleWhenShutdownAccepted && retained.shutdownAccepted) { + return + } + throw new Error('PTY shutdown still pending') + } +} + +export async function shutdownPtyWithRetainedOwnership(args: { + id: string + connectionId?: string | null + provider: IPtyProvider + options: PtyShutdownOptions + requireExitProof?: boolean + settleWhenShutdownAccepted?: boolean +}): Promise { + const retained = args.connectionId + ? retainSshPtyShutdown(args.id, args.connectionId, args.provider, args.options) + : retainLocalPtyShutdown(args.id, args.options) + if (!retained) { + throw new Error('PTY shutdown could not retain ownership') + } + retained.requireExitProof ||= args.requireExitProof === true + await attemptRetainedPtyShutdownForCaller(retained, args.settleWhenShutdownAccepted) +} + +function mergePtyShutdownOptions( + current: PtyShutdownOptions, + incoming: PtyShutdownOptions +): PtyShutdownOptions { + return { + immediate: current.immediate === true || incoming.immediate === true, + keepHistory: current.keepHistory === true || incoming.keepHistory === true, + ...((current.expectedPaneKey ?? incoming.expectedPaneKey) + ? { expectedPaneKey: current.expectedPaneKey ?? incoming.expectedPaneKey } + : {}), + ...((current.expectedTabId ?? incoming.expectedTabId) + ? { expectedTabId: current.expectedTabId ?? incoming.expectedTabId } + : {}) + } +} + +function ptyShutdownTabIds(options: PtyShutdownOptions): string[] { + const paneDelimiter = options.expectedPaneKey?.indexOf(':') ?? -1 + return [ + options.expectedTabId, + paneDelimiter > 0 ? options.expectedPaneKey?.slice(0, paneDelimiter) : undefined + ].filter((value): value is string => Boolean(value)) +} + +function ptyShutdownIdentityConflicts( + current: PtyShutdownOptions, + incoming: PtyShutdownOptions +): boolean { + const currentTabIds = ptyShutdownTabIds(current) + const incomingTabIds = ptyShutdownTabIds(incoming) + return Boolean( + (current.expectedPaneKey && + incoming.expectedPaneKey && + current.expectedPaneKey !== incoming.expectedPaneKey) || + currentTabIds.some((currentTabId) => + incomingTabIds.some((incomingTabId) => currentTabId !== incomingTabId) + ) + ) +} + +function retainLocalPtyShutdown( + id: string, + options: PtyShutdownOptions, + attempts = 0, + deferUntilProviderReady = false, + persist = true +): RetainedPtyShutdown { + let retained = pendingLocalShutdownRetries.get(id) + if (!retained || ptyShutdownIdentityConflicts(retained.options, options)) { + // Why: a daemon can reuse a PTY id for a replacement pane. Its teardown + // must own a distinct object so the prior pane's completion is fenced. + retained = { + id, + connectionId: null, + options, + provider: null, + store: activePtyStore, + durable: true, + attempts, + shutdownAccepted: false, + providerShutdownComplete: false, + providerExitObserved: false, + requireExitProof: false, + nextRetryAt: deferUntilProviderReady + ? Number.POSITIVE_INFINITY + : attempts === 0 + ? 0 + : Date.now() + 250, + inFlight: null, + lastError: null + } + pendingLocalShutdownRetries.set(id, retained) + } else { + retained.options = mergePtyShutdownOptions(retained.options, options) + } + // Why: disk failure must not strand the live process merely because its + // crash-durable intent could not be refreshed. + scheduleRetainedShutdownRetries() + if (persist && typeof activePtyStore?.upsertPendingLocalPtyShutdown === 'function') { + activePtyStore.upsertPendingLocalPtyShutdown({ + ptyId: id, + ...(retained.options.expectedPaneKey + ? { expectedPaneKey: retained.options.expectedPaneKey } + : {}), + ...(retained.options.expectedTabId ? { expectedTabId: retained.options.expectedTabId } : {}), + requestedAt: Date.now() + }) + } + return retained +} + +function retainSshPtyShutdown( + id: string, + connectionId: string, + provider: IPtyProvider | null, + options: PtyShutdownOptions +): RetainedPtyShutdown | null { + let persisted = false + let persistenceError: unknown + try { + persisted = activePtyStore?.markSshRemotePtyShutdownRequested?.(connectionId, id) === true + } catch (error) { + persistenceError = error + } + if (!provider && !persisted) { + if (persistenceError) { + throw persistenceError + } + return null + } + let retained = pendingSshShutdownRetries.get(id) + if (!retained || ptyShutdownIdentityConflicts(retained.options, options)) { + retained = { + id, + connectionId, + options, + provider, + store: activePtyStore, + durable: persisted, + attempts: 0, + shutdownAccepted: false, + providerShutdownComplete: false, + providerExitObserved: false, + requireExitProof: false, + nextRetryAt: provider ? 0 : Number.POSITIVE_INFINITY, + inFlight: null, + lastError: null + } + pendingSshShutdownRetries.set(id, retained) + } else { + retained.durable ||= persisted + retained.options = mergePtyShutdownOptions(retained.options, options) + if (retained.provider !== provider) { + retained.provider = provider + retained.shutdownAccepted = false + retained.nextRetryAt = provider ? 0 : Number.POSITIVE_INFINITY + } + } + scheduleRetainedShutdownRetries() + if (persistenceError) { + throw persistenceError + } + return retained +} + +async function retainFailedSpawnCleanup( + id: string, + connectionId: string | null | undefined, + provider: IPtyProvider, + options: PtyShutdownOptions +): Promise { + const retained = connectionId + ? retainSshPtyShutdown(id, connectionId, provider, options) + : retainLocalPtyShutdown(id, options) + if (!retained) { + throw new Error('PTY cleanup could not retain shutdown ownership') + } + await attemptRetainedPtyShutdownForCaller(retained) +} + // ─── Host PTY env assembly ────────────────────────────────────────── // Why: both the LocalPtyProvider.buildSpawnEnv closure and the daemon-active // fallback in pty:spawn need the same set of host-local env injections @@ -979,8 +1478,14 @@ export function buildPtyHostEnv( // 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 - } else if (!opts.isPackaged) { - baseEnv.ORCA_USER_DATA_PATH ??= opts.userDataPath + // Why: managed WSL registration deliberately uses `orca-ide`; exposing + // that literal keeps agent guidance scoped 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) { @@ -991,6 +1496,18 @@ export function buildPtyHostEnv( // the current working directory (a foot-gun we don't want to create // for dev terminals). baseEnv.PATH = inheritedPath ? `${devCliBin}${delimiter}${inheritedPath}` : devCliBin + } else if (process.platform === 'linux') { + // Why: the Linux CLI installs as `orca-ide` (never shadowing GNOME's + // /usr/bin/orca screen reader), but agent-facing guidance invokes bare + // `orca`. Scope a bare-`orca` shim to Orca-managed PTYs so agents reach + // the Orca CLI instead of the 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) + } } // Why: GitHub attribution should only affect commands launched from @@ -1033,12 +1550,149 @@ function routesFreshSpawnsToLocalProvider( /** Register an SSH PTY provider for a connection. */ export function registerSshPtyProvider(connectionId: string, provider: IPtyProvider): void { + sshRetainedExitUnsubscribers.get(connectionId)?.() + sshRetainedExitUnsubscribers.delete(connectionId) sshProviders.set(connectionId, provider) + if (typeof provider.onExit === 'function') { + sshRetainedExitUnsubscribers.set( + connectionId, + provider.onExit((payload) => { + const retained = pendingSshShutdownRetries.get(payload.id) + if ( + retained?.connectionId === connectionId && + retained.provider === provider && + sshProviders.get(connectionId) === provider + ) { + // Why: exit may arrive after shutdown RPC acceptance. Settle the exact + // provider owner here so its next timer cannot synthesize a duplicate. + settleRetainedPtyShutdownOnExit(retained) + } + }) + ) + } + // Durable leases reconcile generation first; a stale boot-qualified owner + // must be dropped before any replacement provider can observe it. + retryPersistedSshShutdowns(connectionId, provider) + for (const retained of pendingSshShutdownRetries.values()) { + if (retained.connectionId !== connectionId || retained.durable) { + continue + } + retained.provider = provider + retained.shutdownAccepted = false + retained.nextRetryAt = 0 + if (!retained.inFlight) { + void attemptRetainedPtyShutdown(retained) + } + } + scheduleRetainedShutdownRetries() } /** Remove an SSH PTY provider when a connection is closed. */ export function unregisterSshPtyProvider(connectionId: string): void { + sshRetainedExitUnsubscribers.get(connectionId)?.() + sshRetainedExitUnsubscribers.delete(connectionId) sshProviders.delete(connectionId) + for (const retained of pendingSshShutdownRetries.values()) { + if (retained.connectionId === connectionId) { + // Why: provider teardown is often a reconnect, not proof the remote PTY + // exited. Keep even lease-less accepted kills for the replacement owner. + retained.provider = null + retained.shutdownAccepted = false + retained.nextRetryAt = Number.POSITIVE_INFINITY + } + } + scheduleRetainedShutdownRetries() +} + +export function releasePendingSshShutdownsForTarget(connectionId: string): void { + for (const [id, retained] of pendingSshShutdownRetries) { + if (retained.connectionId === connectionId) { + pendingSshShutdownRetries.delete(id) + } + } + scheduleRetainedShutdownRetries() +} + +export function releasePendingSshShutdown(id: string): void { + pendingSshShutdownRetries.delete(id) + // Why: generation expiry proves this exact remote process is unreachable; + // its retained owner must not keep waking against the replacement relay. + scheduleRetainedShutdownRetries() +} + +export function hasPendingSshShutdown(id: string): boolean { + return pendingSshShutdownRetries.has(id) +} + +export function _resetRetainedPtyShutdownsForTests(): void { + pendingLocalShutdownRetries.clear() + pendingSshShutdownRetries.clear() + if (retainedShutdownRetryTimer) { + clearTimeout(retainedShutdownRetryTimer) + retainedShutdownRetryTimer = null + } +} + +function retryPersistedSshShutdowns(connectionId: string, provider: IPtyProvider): void { + const store = activePtyStore + if (!store || typeof store.getSshRemotePtyLeases !== 'function') { + return + } + const retainedLeases = store + .getSshRemotePtyLeases(connectionId) + .filter( + (lease) => + lease.shutdownRequestedAt && lease.state !== 'terminated' && lease.state !== 'expired' + ) + const retainedIds = new Set( + retainedLeases.map((lease) => toAppSshPtyId(connectionId, lease.ptyId, lease.relayInstanceId)) + ) + for (const [id, retained] of pendingSshShutdownRetries) { + if (retained.connectionId === connectionId && retained.durable && !retainedIds.has(id)) { + pendingSshShutdownRetries.delete(id) + } + } + for (const lease of retainedLeases) { + const id = toAppSshPtyId(connectionId, lease.ptyId, lease.relayInstanceId) + const options: PtyShutdownOptions = { + immediate: true, + ...(lease.tabId ? { expectedTabId: lease.tabId } : {}), + ...(lease.tabId && lease.leafId + ? { expectedPaneKey: makePaneKey(lease.tabId, lease.leafId) } + : {}) + } + let retained = pendingSshShutdownRetries.get(id) + if (!retained) { + retained = { + id, + connectionId, + provider, + store, + durable: true, + attempts: 0, + shutdownAccepted: false, + providerShutdownComplete: false, + providerExitObserved: false, + requireExitProof: false, + nextRetryAt: 0, + inFlight: null, + lastError: null, + options + } + pendingSshShutdownRetries.set(id, retained) + } else { + retained.durable = true + retained.options = mergePtyShutdownOptions(retained.options, options) + if (retained.provider !== provider) { + // Why: reconnect replaces the transport owner for the same generation-qualified id. + retained.provider = provider + retained.shutdownAccepted = false + retained.nextRetryAt = 0 + } + } + void attemptRetainedPtyShutdown(retained) + } + scheduleRetainedShutdownRetries() } /** Get the SSH PTY provider for a connection (for dispose on cleanup). */ @@ -1051,17 +1705,28 @@ export function getSshPtyProvider(connectionId: string): IPtyProvider | undefine * Returns the installed PTY provider — after `setLocalPtyProvider()` runs * during daemon init this may be the routed adapter (specifically either * `DaemonPtyAdapter` or its `DaemonPtyRouter` wrapper). Callers needing - * `LocalPtyProvider`-specific methods (`killOrphanedPtys`, + * `LocalPtyProvider`-specific methods (`listOrphanedPtys`, * `advanceGeneration`, `getPtyProcess`) must type-narrow or import the * concrete class directly. */ 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. */ +/** Replace the local PTY provider with a daemon-backed one and rebind durable shutdowns. */ export function setLocalPtyProvider(provider: IPtyProvider): void { localProvider = provider + // Why: durable daemon shutdowns hydrate before async daemon startup swaps in + // the routed provider. Re-arm them against that owner instead of fencing them + // forever to the temporary in-process fallback. + for (const retained of pendingLocalShutdownRetries.values()) { + retained.provider = null + retained.shutdownAccepted = false + retained.nextRetryAt = 0 + if (!retained.inFlight) { + void attemptRetainedPtyShutdown(retained) + } + } + scheduleRetainedShutdownRetries() } /** Get all PTY IDs owned by a given connectionId (for reconnection reattach). */ @@ -1455,6 +2120,55 @@ export function registerPtyHandlers( isRecoveryReloadInFlight?: (webContentsId: number) => boolean } ): void { + activePtyStore = store ?? null + let localPtyStartupPromise: Promise | undefined + const getLocalPtyStartupPromise = (connectionId?: string | null): Promise | undefined => { + if (connectionId) { + return undefined + } + // Why: cold-start replay and local spawn must wait for the final daemon owner. + localPtyStartupPromise ??= options?.awaitLocalPtyStartup?.() + return localPtyStartupPromise + } + notifyRetainedPtyExit = (id) => { + runtime?.onPtyExit(id, -1) + rememberSyntheticKillExit(id) + sendPtyExitToRenderer({ id, code: -1 }) + } + const persistedLocalShutdowns = + store && typeof store.getPendingLocalPtyShutdowns === 'function' + ? store.getPendingLocalPtyShutdowns() + : [] + for (const request of persistedLocalShutdowns) { + const startupReady = getLocalPtyStartupPromise() + const retained = retainLocalPtyShutdown( + request.ptyId, + { + immediate: true, + ...(request.expectedPaneKey ? { expectedPaneKey: request.expectedPaneKey } : {}), + ...(request.expectedTabId ? { expectedTabId: request.expectedTabId } : {}) + }, + 0, + Boolean(startupReady), + false + ) + const retryWithReadyProvider = (): void => { + if (pendingLocalShutdownRetries.get(retained.id) !== retained) { + return + } + retained.nextRetryAt = 0 + void attemptRetainedPtyShutdown(retained) + } + // Why: a rejected startup barrier means the in-process fallback is the final owner. + if (startupReady) { + void startupReady.then(retryWithReadyProvider, retryWithReadyProvider) + } else { + retryWithReadyProvider() + } + } + for (const [connectionId, provider] of sshProviders) { + retryPersistedSshShutdowns(connectionId, provider) + } // Why: a re-registration means a new window owns delivery. Cancel any watchdog the // prior closure armed, and neutralize its bridged reset so the registration-time // mark-hidden below can't arm a timer against the now-dead closure — the fresh @@ -1463,16 +2177,6 @@ export function registerPtyHandlers( resetRendererDeliveryAccountingForLifecycleReset = () => {} registerRendererLifecycleResetHandlers(mainWindow.webContents) - const getLocalPtyStartupPromise = (connectionId?: string | null): Promise | undefined => { - if (connectionId) { - return undefined - } - // Why: during desktop cold start the daemon provider swap now overlaps - // first paint. Local spawns must wait before resolving getProvider(), while - // SSH/headless paths do not use the desktop daemon. - return options?.awaitLocalPtyStartup?.() - } - // Remove any previously registered handlers so we can re-register them // (e.g. when macOS re-activates the app and creates a new window). ipcMain.removeHandler('pty:spawn') @@ -1555,6 +2259,10 @@ export function registerPtyHandlers( }, onSpawned: (id) => runtime?.onPtySpawned(id), onExit: (id, code) => { + const retained = pendingLocalShutdownRetries.get(id) + if (retained?.provider === localProvider) { + settleRetainedPtyShutdownOnExit(retained) + } clearProviderPtyState(id) ptyOwnership.delete(id) markClaudePtyExited(id) @@ -2526,25 +3234,6 @@ export function registerPtyHandlers( mainWindow.webContents.send('pty:exit', payload) } - async function shutdownProviderAndDetectExit( - provider: IPtyProvider, - id: string, - opts: { immediate?: boolean; keepHistory?: boolean } - ): Promise { - let providerExitObserved = false - const unsubscribe = provider.onExit((payload) => { - if (payload.id === id) { - providerExitObserved = true - } - }) - try { - await provider.shutdown(id, opts) - } finally { - unsubscribe() - } - return providerExitObserved - } - // Why: extracted so the "Restart daemon" flow can rebind against the fresh // adapter after replaceDaemonProvider runs. Both the startup registration // and the post-restart rebind go through the same code path — no risk of @@ -2709,6 +3398,10 @@ export function registerPtyHandlers( return } if (!isLocalProvider) { + const retained = pendingLocalShutdownRetries.get(payload.id) + if (retained?.provider === localProvider) { + settleRetainedPtyShutdownOnExit(retained) + } clearProviderPtyState(payload.id) ptyOwnership.delete(payload.id) markClaudePtyExited(payload.id) @@ -2846,12 +3539,17 @@ export function registerPtyHandlers( if (options?.isRecoveryReloadInFlight?.(mainWindow.webContents.id)) { return } - const killed = lp.killOrphanedPtys(generation - 1) - for (const { id } of killed) { - clearProviderPtyState(id) - ptyOwnership.delete(id) - markClaudePtyExited(id) - runtime?.onPtyExit(id, -1) + for (const { id } of lp.listOrphanedPtys(generation - 1)) { + // Why: native kill can throw while the process remains live. The + // retained owner keeps its handle/listeners and retries after reload. + void shutdownPtyWithRetainedOwnership({ + id, + provider: lp, + options: { immediate: true }, + requireExitProof: true + }).catch(() => { + // The retained owner logs bounded retry milestones and stays authoritative. + }) } } didFinishLoadWebContents = mainWindow.webContents @@ -3091,6 +3789,7 @@ export function registerPtyHandlers( : undefined } + assertPtySpawnHasNoRetainedShutdown(effectiveSessionAppId) const existingPaneSpawn = materializedPaneKey ? paneSpawnReservationsByPaneKey.get(materializedPaneKey) : undefined @@ -3100,7 +3799,8 @@ export function registerPtyHandlers( const paneSpawnReservation = materializedPaneKey ? reservePaneSpawn(materializedPaneKey) : null - let result: PtySpawnResult + let result: PtySpawnResult | undefined + let failedSpawnCleanupAttempted = false try { try { if (args.preAllocatedHandle) { @@ -3128,9 +3828,7 @@ export function registerPtyHandlers( if (effectiveSessionAppId !== undefined && !isIdentityMismatch) { clearProviderPtyState(effectiveSessionAppId) deletePtyOwnership(effectiveSessionAppId) - } - if (!isIdentityMismatch) { - store?.markSshRemotePtyLease(args.connectionId, effectiveSessionRelayId, 'expired') + store?.markSshRemotePtyLease(args.connectionId, effectiveSessionAppId, 'expired') } } if (isMintedSessionId && sessionId !== undefined) { @@ -3156,6 +3854,7 @@ export function registerPtyHandlers( markNativeWindowsConptyPty(result.id) } const relayResultId = getRelayPtyId(args.connectionId, result.id) + const relayInstanceId = parseAppSshPtyId(result.id)?.relayInstanceId const persistSshLease = (): void => { if (!store || !args.connectionId) { return @@ -3165,6 +3864,7 @@ export function registerPtyHandlers( store.upsertSshRemotePtyLease({ targetId: args.connectionId, ptyId: relayResultId, + ...(relayInstanceId ? { relayInstanceId } : {}), ...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}), ...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}), ...(typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) @@ -3192,14 +3892,28 @@ export function registerPtyHandlers( }) } catch (err) { console.error('[pty] failed to persist runtime PTY binding after spawn:', err) - deletePtyOwnership(result.id) - if (!result.isReattach) { + if (result.isReattach) { + // Why: the caller rejected this attach and must not keep routing + // writes, but the pre-existing provider session remains owned. + deletePtyOwnership(result.id) + } else { + failedSpawnCleanupAttempted = true + if (args.connectionId) { + // Why: the binding failed, so the SSH lease is the only durable + // identity that can carry cleanup across an app restart. + try { + persistSshLease() + } catch {} + } try { - await provider.shutdown(result.id, { immediate: true }) + await retainFailedSpawnCleanup(result.id, args.connectionId, provider, { + immediate: true, + ...(metadataPaneKey ? { expectedPaneKey: metadataPaneKey } : {}), + ...(typeof args.tabId === 'string' ? { expectedTabId: args.tabId } : {}) + }) } catch (shutdownErr) { console.warn('[pty] failed to clean up PTY after persistence failure:', shutdownErr) } - clearProviderPtyState(result.id) } throw new Error(createTerminalSessionStateSaveFailureMessage()) } @@ -3221,6 +3935,9 @@ export function registerPtyHandlers( args.tabId.length <= 512 && metadataLeafId !== null ? { tabId: args.tabId, leafId: metadataLeafId } + : undefined, + !args.connectionId + ? shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd) : undefined ) } @@ -3263,6 +3980,21 @@ export function registerPtyHandlers( const response = { id: result.id } return resolvePaneSpawnReservation(materializedPaneKey, paneSpawnReservation, response) } catch (err) { + if (result?.isReattach) { + // Why: this request published only routing ownership for an existing + // provider session; failed setup must not kill that session. + deletePtyOwnership(result.id) + } else if (result && !failedSpawnCleanupAttempted) { + try { + await retainFailedSpawnCleanup(result.id, args.connectionId, provider, { + immediate: true, + ...(metadataPaneKey ? { expectedPaneKey: metadataPaneKey } : {}), + ...(typeof args.tabId === 'string' ? { expectedTabId: args.tabId } : {}) + }) + } catch (shutdownErr) { + console.warn('[pty] failed to clean up PTY after post-spawn failure:', shutdownErr) + } + } // 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 @@ -3283,58 +4015,41 @@ export function registerPtyHandlers( } }, kill: (ptyId) => { - let provider: IPtyProvider let connectionId: string | null | undefined = ptyOwnership.get(ptyId) const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null connectionId ??= parsedSshId?.connectionId + // Why: paired/mobile close has no renderer left to observe a later + // graceful fallback. Require the provider's synchronous force-close path. + const options: PtyShutdownOptions = { immediate: true } + let provider: IPtyProvider | undefined 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. - finishPtyShutdown(ptyId, connectionId, store) - runtime?.onPtyExit(ptyId, -1) - rememberSyntheticKillExit(ptyId) - sendPtyExitToRenderer({ id: ptyId, code: -1 }) - return true + // Why: paired/mobile close has no renderer retry owner. Accept an + // offline close only after its exact durable lease owns the retry. + try { + return retainSshPtyShutdown(ptyId, connectionId, null, options) !== null + } catch { + return false + } } return false } - // Why: shutdown() is async but the PtyController interface is sync. Defer - // cleanup until shutdown resolves so transient SSH/daemon failures don't - // hide a still-running remote process or local daemon session. - // - // Same synthetic-exit contract as the renderer pty:kill handler: when the - // provider emitted its own exit during shutdown, the exit listener already - // delivered runtime + renderer exits — synthesizing again would double-fire. - void shutdownProviderAndDetectExit(provider, ptyId, { immediate: false }) - .then((providerExitObserved) => { - finishPtyShutdown(ptyId, connectionId, store) - if (!providerExitObserved) { - runtime?.onPtyExit(ptyId, -1) - rememberSyntheticKillExit(ptyId) - sendPtyExitToRenderer({ id: ptyId, code: -1 }) - } - }) - .catch((err) => { - if (isPtyAlreadyGoneError(err)) { - finishPtyShutdown(ptyId, connectionId, store) - runtime?.onPtyExit(ptyId, -1) - rememberSyntheticKillExit(ptyId) - sendPtyExitToRenderer({ id: ptyId, code: -1 }) - return - } - console.warn( - `[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}` - ) - // Why: callers of controller.kill must observe a kill→exit pair so - // runtime tail buffers close and agents stop treating the pane as - // live. Preserve provider/lease state so a retry can still target - // the remote PTY if it survived the transient failure. - runtime?.onPtyExit(ptyId, -1) - }) + let retained: RetainedPtyShutdown | null + try { + retained = connectionId + ? retainSshPtyShutdown(ptyId, connectionId, provider, options) + : retainLocalPtyShutdown(ptyId, options) + } catch { + return false + } + if (!retained) { + return false + } + // Why: controller callers need synchronous acceptance, not synchronous + // process proof. The retained owner delivers exit only after proof. + void attemptRetainedPtyShutdown(retained) return true }, stopAndWait: async (ptyId, opts) => { @@ -3345,49 +4060,27 @@ export function registerPtyHandlers( 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. - finishPtyShutdown(ptyId, connectionId, store) - runtime?.onPtyExit(ptyId, -1) - rememberSyntheticKillExit(ptyId) - sendPtyExitToRenderer({ id: ptyId, code: -1 }) - return true - } + // Why: provider absence is not process-death proof for an SSH relay. + // Retain ownership and fail closed so reconnect can verify and retry. return false } - let providerExitObserved = false try { - providerExitObserved = await shutdownProviderAndDetectExit(provider, ptyId, { - immediate: true, - keepHistory: opts?.keepHistory ?? false + await shutdownPtyWithRetainedOwnership({ + id: ptyId, + connectionId, + provider, + requireExitProof: true, + options: { + immediate: true, + keepHistory: opts?.keepHistory ?? false + } }) - } catch (err) { - if (!isPtyAlreadyGoneError(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))) { - return false - } } catch (err) { console.warn( - `[pty] Failed to verify PTY ${ptyId} stopped: ${ - err instanceof Error ? err.message : String(err) - }` + `[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}` ) return false } - finishPtyShutdown(ptyId, connectionId, store) - if (!providerExitObserved) { - runtime?.onPtyExit(ptyId, -1) - rememberSyntheticKillExit(ptyId) - sendPtyExitToRenderer({ id: ptyId, code: -1 }) - } return true }, getForegroundProcess: async (ptyId) => { @@ -3397,6 +4090,15 @@ export function registerPtyHandlers( return null } }, + 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) @@ -3423,7 +4125,17 @@ export function registerPtyHandlers( /* best effort: renderer clear still handles local PTYs */ } }, - listProcesses: async () => { + listProcesses: async (connectionId?: string | null) => { + if (connectionId === null) { + return localProvider.listProcesses() + } + if (connectionId) { + const provider = sshProviders.get(connectionId) + if (!provider) { + throw new Error(`SSH PTY provider not available: ${connectionId}`) + } + return provider.listProcesses() + } const providerSessions = await Promise.all([ localProvider.listProcesses(), ...Array.from(sshProviders.values(), (provider) => provider.listProcesses()) @@ -3549,9 +4261,22 @@ export function registerPtyHandlers( resetPtyRendererDeliveryDebug() }) + const withTerminalCreateAdmission = ( + worktreeId: string | undefined, + action: () => Promise, + connectionId?: string | null + ): Promise => + typeof runtime?.runWithTerminalCreateAdmission === 'function' + ? runtime.runWithTerminalCreateAdmission( + worktreeId, + action, + connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID + ) + : action() + ipcMain.handle( 'pty:spawn', - async ( + ( _event, args: { cols: number @@ -3602,781 +4327,824 @@ export function registerPtyHandlers( request_kind?: unknown } } - ) => { - const spawnTiming = createPtySpawnTiming() - const startupPromise = getLocalPtyStartupPromise(args.connectionId) - if (startupPromise) { - await startupPromise - } - await assertFolderWorkspacePtyPathUsable(args.worktreeId) - // Why: honor the fallback only for fresh local spawns even if a caller - // sends the flag — reattach must keep the session's exact cwd and - // remote/SSH paths cannot probe the local filesystem meaningfully. - const allowMissingCwdFallback = - !args.connectionId && !args.sessionId && args.cwdFallback === 'worktree' - let didFallbackToWorkspaceRootCwd = false - const cwd = resolvePtySpawnStartupCwd( + ) => + withTerminalCreateAdmission( args.worktreeId, - args.cwd, - allowMissingCwdFallback - ? { - directoryExists: localStartupCwdDirectoryExists, - onFallbackToWorkspaceRoot: () => { - didFallbackToWorkspaceRootCwd = true - } - } - : undefined - ) - const startupCwdFallback = - didFallbackToWorkspaceRootCwd && cwd ? ({ kind: 'worktree', cwd } as const) : undefined - spawnTiming.mark('preflight') - const provider = getProvider(args.connectionId) - const isClaudeLaunch = !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' + async () => { + const spawnTiming = createPtySpawnTiming() + const startupPromise = getLocalPtyStartupPromise(args.connectionId) + if (startupPromise) { + await startupPromise + } + await assertFolderWorkspacePtyPathUsable(args.worktreeId) + // Why: honor the fallback only for fresh local spawns even if a caller + // sends the flag — reattach must keep the session's exact cwd and + // remote/SSH paths cannot probe the local filesystem meaningfully. + const allowMissingCwdFallback = + !args.connectionId && !args.sessionId && args.cwdFallback === 'worktree' + let didFallbackToWorkspaceRootCwd = false + const cwd = resolvePtySpawnStartupCwd( + args.worktreeId, + args.cwd, + allowMissingCwdFallback + ? { + directoryExists: localStartupCwdDirectoryExists, + onFallbackToWorkspaceRoot: () => { + didFallbackToWorkspaceRootCwd = true + } + } + : undefined + ) + const startupCwdFallback = + didFallbackToWorkspaceRootCwd && cwd ? ({ kind: 'worktree', cwd } as const) : undefined + spawnTiming.mark('preflight') + const provider = getProvider(args.connectionId) + const isClaudeLaunch = !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 initialSelectionTarget = getCodexSelectionTargetForPty( + initialShellOverride, + cwd, + terminalRuntimeOptions.terminalWindowsWslDistro ?? null + ) + 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 replaces LocalPtyProvider and therefore + // never runs its buildSpawnEnv closure. We must assemble the same + // host-local env (OpenCode plugin, agent-hook server, Pi/OMP managed + // extensions, Codex home, dev CLI overrides, GitHub attribution shims) + // here so both spawn paths behave identically. buildPtyHostEnv is the + // shared helper that encapsulates the full set of injections and guards. + // + // Safety: skip the entire injection when a remote (SSH) connection is in + // play. Every injection here is either host-loopback (the agent-hook + // server binds 127.0.0.1, so shipping its token to an SSH host would + // leak a loopback secret for no functional benefit) or a path on the + // local filesystem (OpenCode plugin dir, Pi/OMP extension paths, Codex + // home, dev CLI bin, attribution shim dir) that would resolve to + // nothing — or something misleading — on the remote machine. + const isDaemonHostSpawn = + !args.connectionId && + !(provider instanceof LocalPtyProvider) && + !routesFreshSpawnsToLocalProvider(provider) + // Why: daemon host-env setup needs a stable id BEFORE provider.spawn so + // provider hooks and legacy Pi overlay cleanup can run in buildPtyHostEnv. + // DaemonPtyAdapter.doSpawn mints an id the same way when sessionId is + // absent — lifting the mint here gives pty.ts the id up-front without + // changing daemon semantics (the daemon still honors opts.sessionId ?? mint()). + // + // Note: the sessionId is STABLE across daemon restarts by design — + // DaemonPtyAdapter.reconcileOnStartup reuses it so that users' live + // shells survive crashes. Do NOT "simplify" id allocation back to a + // fresh UUID per spawn; that would orphan reconnectable terminal state. + // Why: only state for ids we minted in THIS request should be cleared on + // spawn failure. If the caller supplied args.sessionId it may refer to + // an existing PTY whose state (OpenCode hooks, legacy Pi overlay cleanup, + // agent-hook pane caches) we must not clobber on a retry/attach failure. + 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 startupTerminalColorQueryReplyColors = getStartupTerminalColorQueryReplyColors(args) + const preSpawnStartupTerminalColorReplyPtyId = + startupTerminalColorQueryReplyColors && effectiveSessionId !== undefined + ? (effectiveSessionAppId ?? effectiveSessionId) + : null + // Why: the renderer sets pane env for SSH too. Only forward it to the + // remote when the relay hook path is enabled; otherwise a newer relay + // could emit statuses this Orca build is not prepared to 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 = + !args.connectionId && + runtime !== undefined && + stablePaneKey !== null && + shouldRefreshNativeClaudeAgentTeamsEnv({ + command: args.command, + launchConfig: args.launchConfig }) - : { shellOverride: args.shellOverride, terminalWindowsWslDistro: null } - const initialShellOverride = terminalRuntimeOptions.shellOverride - const initialSelectionTarget = getCodexSelectionTargetForPty( - initialShellOverride, - cwd, - terminalRuntimeOptions.terminalWindowsWslDistro ?? null - ) - 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 replaces LocalPtyProvider and therefore - // never runs its buildSpawnEnv closure. We must assemble the same - // host-local env (OpenCode plugin, agent-hook server, Pi/OMP managed - // extensions, Codex home, dev CLI overrides, GitHub attribution shims) - // here so both spawn paths behave identically. buildPtyHostEnv is the - // shared helper that encapsulates the full set of injections and guards. - // - // Safety: skip the entire injection when a remote (SSH) connection is in - // play. Every injection here is either host-loopback (the agent-hook - // server binds 127.0.0.1, so shipping its token to an SSH host would - // leak a loopback secret for no functional benefit) or a path on the - // local filesystem (OpenCode plugin dir, Pi/OMP extension paths, Codex - // home, dev CLI bin, attribution shim dir) that would resolve to - // nothing — or something misleading — on the remote machine. - const isDaemonHostSpawn = - !args.connectionId && - !(provider instanceof LocalPtyProvider) && - !routesFreshSpawnsToLocalProvider(provider) - // Why: daemon host-env setup needs a stable id BEFORE provider.spawn so - // provider hooks and legacy Pi overlay cleanup can run in buildPtyHostEnv. - // DaemonPtyAdapter.doSpawn mints an id the same way when sessionId is - // absent — lifting the mint here gives pty.ts the id up-front without - // changing daemon semantics (the daemon still honors opts.sessionId ?? mint()). - // - // Note: the sessionId is STABLE across daemon restarts by design — - // DaemonPtyAdapter.reconcileOnStartup reuses it so that users' live - // shells survive crashes. Do NOT "simplify" id allocation back to a - // fresh UUID per spawn; that would orphan reconnectable terminal state. - // Why: only state for ids we minted in THIS request should be cleared on - // spawn failure. If the caller supplied args.sessionId it may refer to - // an existing PTY whose state (OpenCode hooks, legacy Pi overlay cleanup, - // agent-hook pane caches) we must not clobber on a retry/attach failure. - 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 startupTerminalColorQueryReplyColors = getStartupTerminalColorQueryReplyColors(args) - const preSpawnStartupTerminalColorReplyPtyId = - startupTerminalColorQueryReplyColors && effectiveSessionId !== undefined - ? (effectiveSessionAppId ?? effectiveSessionId) - : null - // Why: the renderer sets pane env for SSH too. Only forward it to the - // remote when the relay hook path is enabled; otherwise a newer relay - // could emit statuses this Orca build is not prepared to 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 = - !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 - ? runtime.createPreAllocatedTerminalHandle() - : null - if (shouldRefreshAgentTeamsEnv && preAllocatedHandle) { - // Why: native Agent Teams team ids/tokens are process-local. A sleeping - // record preserves the user's native launch shape, but the team env - // itself 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, + let effectiveLaunchConfig = args.launchConfig + const shouldPreAllocateTerminalHandle = + runtime !== undefined && + ((!(provider instanceof LocalPtyProvider) && + !routesFreshSpawnsToLocalProvider(provider)) || + shouldRefreshAgentTeamsEnv) + const preAllocatedHandle = shouldPreAllocateTerminalHandle + ? runtime.createPreAllocatedTerminalHandle() + : null + if (shouldRefreshAgentTeamsEnv && preAllocatedHandle) { + // Why: native Agent Teams team ids/tokens are process-local. A sleeping + // record preserves the user's native launch shape, but the team env + // itself must be regenerated for the new leader PTY. + const prepared = await runtime.prepareClaudeAgentTeamsLeaderForHandle({ + handle: preAllocatedHandle, + baseEnv: baseEnv ?? {} + }) + baseEnv = { + ...baseEnv, ...prepared.env } - } - } - } - const requestedAgentTeamsPath = baseEnv?.ORCA_AGENT_TEAMS_TEAM_ID ? baseEnv.PATH : undefined - const agentTeamsEnvToDelete = shouldRefreshAgentTeamsEnv - ? ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'] - : 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 and hook registries. Only the - // key proven to match this spawn's tab+leaf may leave 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 disabled; the - // IPC tab/leaf metadata still names the pane and matches runtime fallback. - const reservationPaneKey = metadataPaneKey ?? validatedPaneKey - const validatedLeafId = verifiedLeafId ?? metadataLeafId - let env: Record | undefined = baseEnv - const effectiveShellOverride = terminalRuntimeOptions.shellOverride - const codexSelectionTarget = getCodexSelectionTargetForPty( - effectiveShellOverride, - cwd, - terminalRuntimeOptions.terminalWindowsWslDistro ?? null - ) - const selectedCodexHomePath = isDaemonHostSpawn - ? getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget) ?? null - ) - : null - const skipCodexHomeEnv = - isDaemonHostSpawn && - shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd) && - !selectedCodexHomePath - if (isDaemonHostSpawn) { - if (effectiveSessionId === undefined) { - // Should be unreachable: the expression above returns a string when - // isDaemonHostSpawn is true. Defense-in-depth in case future edits - // break this invariant. - throw new Error('Invariant violation: daemon spawn without sessionId') - } - const sessionIdForEnv = effectiveSessionId - // Why: this id still reaches filesystem side-effects for provider - // hook state and stale pre-migration Pi overlay cleanup; reject - // traversal/path separators before a crafted IPC payload can escape - // the expected roots. - if (!isSafePtySessionId(sessionIdForEnv, app.getPath('userData'))) { - throw new Error('Invalid PTY session id') - } - // Why: clone before mutating so we don't leak injections back into - // args.env (which the renderer may reuse for other IPC calls). - env = { ...baseEnv } - try { - buildPtyHostEnv(sessionIdForEnv, env, { - isPackaged: app.isPackaged, - userDataPath: app.getPath('userData'), - selectedCodexHomePath, - skipCodexHomeEnv, - githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, - launchCommand: args.command, - shellPath: effectiveShellOverride ?? process.env.COMSPEC, - isWsl: shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd), - wslDistro: - codexSelectionTarget.runtime === 'wsl' ? codexSelectionTarget.wslDistro : null, - agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()), - networkProxySettings: getSettings?.() - }) - promoteAgentTeamsShimPath(env, requestedAgentTeamsPath) - } catch (err) { - // Why: buildPtyHostEnv has filesystem side-effects (Pi/OMP managed - // extension installation). If it throws before we reach provider.spawn, - // clear per-PTY state so the next attempt starts clean. - // - // Only sweep state for ids we MINTED in this request — caller- - // supplied ids may refer to existing PTYs whose overlay/hook state - // must not be clobbered by a transient overlay-mkdir failure on a - // retry/attach path. - 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 - const combinedEnvToDelete = mergePtyEnvDeletions( - mergePtyEnvDeletions( - mergePtyEnvDeletions( - mergePtyEnvDeletions(envToDelete, args.envToDelete ?? []), - agentTeamsEnvToDelete ?? [] - ), - isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(spawnEnv) : [] - ), - skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [] - ) - deleteRequestedEnvKeys(spawnEnv, combinedEnvToDelete) - promoteAgentTeamsShimPath(spawnEnv, requestedAgentTeamsPath) - const spawnOptions: PtySpawnOptions = { - cols: args.cols, - rows: args.rows, - cwd, - env: spawnEnv, - ...(isMintedSessionId ? { isNewSession: true } : {}) - } - if (combinedEnvToDelete) { - spawnOptions.envToDelete = combinedEnvToDelete - } - if (args.command !== undefined) { - spawnOptions.command = args.command - } - 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: on Windows, fall back to the persisted default-shell setting - // when the renderer didn't send a per-tab override. Without this, the - // daemon path ignores the user's "Default Shell" preference entirely — - // it just calls resolvePtyShellPath(env) which reads COMSPEC (cmd.exe) - // or falls back to PowerShell. The LocalPtyProvider already consults - // getWindowsShell(); this mirrors that on the daemon path so users who - // set WSL as default actually get WSL when pressing Ctrl+T. - 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 prompt/startup bytes before spawn() - // resolves. Runtime headless snapshots need the real pane geometry - // for those early bytes; otherwise they 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 only models PowerShell as one shell family. Thread - // the persisted implementation choice through spawnOptions so both the - // in-process and daemon-backed PTY paths can resolve the same effective - // executable without inventing a fourth top-level shell. - spawnOptions.terminalWindowsWslDistro = - terminalRuntimeOptions.terminalWindowsWslDistro ?? null - spawnOptions.terminalWindowsPowerShellImplementation = getSettings - ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') - : undefined - } - const existingPaneSpawn = reservationPaneKey - ? paneSpawnReservationsByPaneKey.get(reservationPaneKey) - : undefined - if (existingPaneSpawn) { - return await existingPaneSpawn.promise - } - const paneSpawnReservation = reservationPaneKey ? reservePaneSpawn(reservationPaneKey) : null - const initiallyHidden = args.initiallyHidden === true - // Why pre-spawn for daemon-host sessions (id minted up front): daemon - // PTYs can emit prompt bytes before spawn() resolves, and the hidden - // mark must beat the first byte so the gate + model responder own - // spawn-time queries (terminal-query-authority.md §races). Other - // providers cannot emit until spawn resolves; the post-spawn mark - // below is byte-zero-safe for them. - const preSpawnHiddenMarkId = - initiallyHidden && isDaemonHostSpawn && effectiveSessionAppId !== undefined - ? effectiveSessionAppId - : null - if (preSpawnHiddenMarkId !== null) { - markHiddenRendererPty(preSpawnHiddenMarkId) - } - let result: PtySpawnResult - try { - try { - if (preAllocatedHandle) { - trustedTerminalHandleEnv.add(preAllocatedHandle) - } - if (preSpawnStartupTerminalColorReplyPtyId && startupTerminalColorQueryReplyColors) { - // Why: Codex probes OSC 10/11 with a 100 ms timeout and daemon PTYs - // can emit that query before spawn() resolves to the renderer. - registerStartupTerminalColorQueryReplies( - preSpawnStartupTerminalColorReplyPtyId, - startupTerminalColorQueryReplyColors - ) - } - spawnTiming.mark('options') - result = await provider.spawn(spawnOptions) - spawnTiming.mark('provider_spawn') - } catch (err) { - // Why: a failed spawn must not leave a stale hidden mark on a session - // id a later visible attach may reuse. - if (preSpawnHiddenMarkId !== null) { - unmarkHiddenRendererPty(preSpawnHiddenMarkId) - } - const rawMessage = err instanceof Error ? err.message : String(err) - const spawnError = normalizeNodePtySpawnError(err) - const isIdentityMismatch = - isSshPtyIdentityMismatchError(spawnError) || isSshPtyIdentityMismatchError(rawMessage) - if (preSpawnStartupTerminalColorReplyPtyId) { - clearStartupTerminalColorQueryReplies(preSpawnStartupTerminalColorReplyPtyId) - } - 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 means the relay has already dropped - // the backing PTY. Clear the durable lease so later session writes - // cannot restore the stale pane binding. - if (effectiveSessionAppId !== undefined && !isIdentityMismatch) { - clearProviderPtyState(effectiveSessionAppId) - deletePtyOwnership(effectiveSessionAppId) - } - if (!isIdentityMismatch) { - store?.markSshRemotePtyLease(args.connectionId, effectiveSessionRelayId, 'expired') - } - } - // Why: if buildPtyHostEnv materialized provider state for this minted - // id but provider.spawn failed, that state would otherwise leak. - if (isMintedSessionId && effectiveSessionId !== undefined) { - clearProviderPtyState(effectiveSessionId) - } - // Why: telemetry-plan.md§agent_error — when the renderer threaded - // agent_kind through args.telemetry, attribute the error to that agent. - // Otherwise fall back to sniffing the command for `claude` (the one - // agent the main process can identify on its own via the existing - // `isClaudeLaunchCommand` regex used for auth gating). Bare-shell - // catches and unknown-agent catches without renderer telemetry remain - // unattributed. The event still emits with a classified `error_class`; - // raw error messages are dropped at the telemetry 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) - } - } - spawnTiming.log(result.id, { - daemon: isDaemonHostSpawn, - reattach: result.isReattach ?? false - }) - ptyOwnership.set(result.id, args.connectionId ?? null) - if (initiallyHidden) { - // Why marked synchronously before any await below: local/SSH provider - // data events dispatch on later tasks, so this is still ahead of the - // first byte's delivery decision. Idempotent for daemon hosts already - // marked pre-spawn; the renderer's first visibility sync re-marks or - // unmarks (emitting the restore marker) through the Phase-4 path. - markHiddenRendererPty(result.id) - if (preSpawnHiddenMarkId !== null && preSpawnHiddenMarkId !== result.id) { - // Defense: never strand a mark on an id the provider renamed. - unmarkHiddenRendererPty(preSpawnHiddenMarkId) - } - // Why after ptyOwnership.set: the provider lookup routes by - // ownership, and a hidden-spawned agent should be paceable from its - // first flood, not from its first visibility transition. - syncPtyBackgroundedDelivery(result.id, 'spawn') - } - // Why: Phase-5 ConPTY DA1 — record the native-Windows-local-PTY - // determination from the spawn record before the headless seed below, - // so the runtime emulator's DA1 override exists from byte zero. - if ( - isNativeWindowsLocalPtySpawn({ - connectionId: args.connectionId, - cwd: args.cwd, - shellOverride: effectiveShellOverride - }) - ) { - markNativeWindowsConptyPty(result.id) - } - if (startupTerminalColorQueryReplyColors) { - if (result.isReattach) { - if (preSpawnStartupTerminalColorReplyPtyId) { - clearStartupTerminalColorQueryReplies(preSpawnStartupTerminalColorReplyPtyId) - } - } else if (preSpawnStartupTerminalColorReplyPtyId) { - moveStartupTerminalColorQueryReplies(preSpawnStartupTerminalColorReplyPtyId, result.id) - } else { - registerStartupTerminalColorQueryReplies( - result.id, - startupTerminalColorQueryReplyColors - ) - } - } - 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 their IDs immediately so reconnect can reattach - // instead of treating the tab as 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) { - runtime?.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) - } - ptySizes.set(result.id, { cols: args.cols, rows: args.rows }) - // Why: closes the SIGKILL-between-spawn-and-persist race (Issue #217) - // for local daemon PTYs and the equivalent remote-relay race for SSH. - // The renderer's debounced session writer runs in parallel for every - // other field; patch the load-bearing (tab.ptyId, ptyIdsByLeafId) - // binding synchronously so a force-quit in the ~450 ms debounce window - // cannot orphan either daemon history or a remote relay PTY lease. - if ( - (isDaemonHostSpawn || args.connectionId) && - store && - typeof args.worktreeId === 'string' && - typeof args.tabId === 'string' && - validatedLeafId !== null - ) { - try { - store.persistPtyBinding({ - worktreeId: args.worktreeId, - tabId: args.tabId, - leafId: validatedLeafId, - ptyId: result.id, - ...(cwd ? { startupCwd: cwd } : {}) - }) - } 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) + if (args.launchConfig) { + effectiveLaunchConfig = { + ...args.launchConfig, + agentEnv: { + ...args.launchConfig.agentEnv, + ...prepared.env + } } - clearProviderPtyState(result.id) - deletePtyOwnership(result.id) } - if (!result.isReattach && args.connectionId && store) { - store.removeSshRemotePtyLease(args.connectionId, relayResultId) + } + const requestedAgentTeamsPath = baseEnv?.ORCA_AGENT_TEAMS_TEAM_ID + ? baseEnv.PATH + : undefined + const agentTeamsEnvToDelete = shouldRefreshAgentTeamsEnv + ? ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'] + : 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 } - throw new Error(createTerminalSessionStateSaveFailureMessage()) + 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 and hook registries. Only the + // key proven to match this spawn's tab+leaf may leave the IPC boundary. + delete baseEnv.ORCA_PANE_KEY + delete baseEnv.ORCA_TAB_ID + delete baseEnv.ORCA_WORKTREE_ID + delete baseEnv.ORCA_AGENT_LAUNCH_TOKEN } - } - // Why: pre-signal cooperation gate — when the renderer has declared it - // will own the serializer for this paneKey, suppress the daemon-snapshot - // seed so the renderer's hydration path (maybeHydrateHeadlessFromRenderer) - // is the sole authority. The pre-signal is keyed on paneKey because at - // spawn time the renderer doesn't yet know the new ptyId. See - // docs/mobile-prefer-renderer-scrollback.md. - const rendererPreSignaled = validatedPaneKey - ? pendingByPaneKey.has(validatedPaneKey) - : false - const rendererAlreadyRegistered = rendererSerializerByPtyId.has(result.id) - // Why: capture the pending gen at spawn time so teardown for THIS PTY - // only settles its own generation. A remount that replaces the entry - // with a new gen must not be stomped by the old PTY's teardown. - if (validatedPaneKey && rendererPreSignaled) { - const pending = pendingByPaneKey.get(validatedPaneKey) - if (pending) { - ptyPendingGenByPtyId.set(result.id, pending.gen) + const validatedPaneKey = stablePaneKey + // Why: SSH can strip ORCA_PANE_KEY when remote hooks are disabled; the + // IPC tab/leaf metadata still names the pane and matches runtime fallback. + const reservationPaneKey = metadataPaneKey ?? validatedPaneKey + const validatedLeafId = verifiedLeafId ?? metadataLeafId + let env: Record | undefined = baseEnv + const effectiveShellOverride = terminalRuntimeOptions.shellOverride + const codexSelectionTarget = getCodexSelectionTargetForPty( + effectiveShellOverride, + cwd, + terminalRuntimeOptions.terminalWindowsWslDistro ?? null + ) + const selectedCodexHomePath = isDaemonHostSpawn + ? getCompatibleSelectedCodexHomePath( + codexSelectionTarget, + getSelectedCodexHomePath?.(codexSelectionTarget) ?? null + ) + : null + const skipCodexHomeEnv = + isDaemonHostSpawn && + shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd) && + !selectedCodexHomePath + if (isDaemonHostSpawn) { + if (effectiveSessionId === undefined) { + // Should be unreachable: the expression above returns a string when + // isDaemonHostSpawn is true. Defense-in-depth in case future edits + // break this invariant. + throw new Error('Invariant violation: daemon spawn without sessionId') + } + const sessionIdForEnv = effectiveSessionId + // Why: this id still reaches filesystem side-effects for provider + // hook state and stale pre-migration Pi overlay cleanup; reject + // traversal/path separators before a crafted IPC payload can escape + // the expected roots. + if (!isSafePtySessionId(sessionIdForEnv, app.getPath('userData'))) { + throw new Error('Invalid PTY session id') + } + // Why: clone before mutating so we don't leak injections back into + // args.env (which the renderer may reuse for other IPC calls). + env = { ...baseEnv } + try { + buildPtyHostEnv(sessionIdForEnv, env, { + isPackaged: app.isPackaged, + userDataPath: app.getPath('userData'), + selectedCodexHomePath, + skipCodexHomeEnv, + githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, + launchCommand: args.command, + shellPath: effectiveShellOverride ?? process.env.COMSPEC, + isWsl: shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd), + wslDistro: + codexSelectionTarget.runtime === 'wsl' ? codexSelectionTarget.wslDistro : null, + agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()), + networkProxySettings: getSettings?.() + }) + promoteAgentTeamsShimPath(env, requestedAgentTeamsPath) + } catch (err) { + // Why: buildPtyHostEnv has filesystem side-effects (Pi/OMP managed + // extension installation). If it throws before we reach provider.spawn, + // clear per-PTY state so the next attempt starts clean. + // + // Only sweep state for ids we MINTED in this request — caller- + // supplied ids may refer to existing PTYs whose overlay/hook state + // must not be clobbered by a transient overlay-mkdir failure on a + // retry/attach path. + if (isMintedSessionId) { + clearProviderPtyState(sessionIdForEnv) + } + throw err + } } - } - - // Why: hydrate the runtime's headless emulator with the adapter's - // restore data BEFORE registerPty so any live PTY data that arrives - // concurrently lands on top of the seed instead of replacing it. Mobile - // subscribers then see the same scrollback the desktop xterm received - // via coldRestore/snapshot. Without this, mobile snapshots after a - // daemon-restored attach contain only bytes emitted since the relaunch - // and the prior agent output silently disappears. - // - // Skip when the renderer is or will be authoritative for this PTY: - // its hydration path will seed the emulator from xterm's live buffer, - // which is richer than the daemon snapshot. - if (runtime && !rendererPreSignaled && !rendererAlreadyRegistered) { - const seedSize = - 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 string omits - // them by design (renderer kitty reset stays authoritative), but - // the re-seeded emulator must answer hidden `CSI ? u` with the - // flags the still-running app pushed (terminal-query-authority.md). - runtime.seedHeadlessTerminal( - result.id, - result.snapshot, - seedSize, - typeof result.snapshotKittyKeyboardFlags === 'number' - ? { kittyKeyboardFlags: result.snapshotKittyKeyboardFlags } - : {} - ) - } else if ( - result.coldRestore && - typeof result.coldRestore.scrollback === 'string' && - result.coldRestore.scrollback.length > 0 - ) { - runtime.seedHeadlessTerminal(result.id, result.coldRestore.scrollback, seedSize, { - cwd: result.coldRestore.cwd, - oscLinks: result.coldRestore.oscLinks + 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 + const combinedEnvToDelete = mergePtyEnvDeletions( + mergePtyEnvDeletions( + mergePtyEnvDeletions( + mergePtyEnvDeletions(envToDelete, args.envToDelete ?? []), + agentTeamsEnvToDelete ?? [] + ), + isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(spawnEnv) : [] + ), + skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [] + ) + deleteRequestedEnvKeys(spawnEnv, combinedEnvToDelete) + promoteAgentTeamsShimPath(spawnEnv, requestedAgentTeamsPath) + const spawnOptions: PtySpawnOptions = { + cols: args.cols, + rows: args.rows, + cwd, + env: spawnEnv, + ...(isMintedSessionId ? { isNewSession: true } : {}) + } + if (combinedEnvToDelete) { + spawnOptions.envToDelete = combinedEnvToDelete + } + if (args.command !== undefined) { + spawnOptions.command = args.command + } + 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: on Windows, fall back to the persisted default-shell setting + // when the renderer didn't send a per-tab override. Without this, the + // daemon path ignores the user's "Default Shell" preference entirely — + // it just calls resolvePtyShellPath(env) which reads COMSPEC (cmd.exe) + // or falls back to PowerShell. The LocalPtyProvider already consults + // getWindowsShell(); this mirrors that on the daemon path so users who + // set WSL as default actually get WSL when pressing Ctrl+T. + 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 prompt/startup bytes before spawn() + // resolves. Runtime headless snapshots need the real pane geometry + // for those early bytes; otherwise they default to 80x24 and wrap TUIs. + ptySizes.set(effectiveSessionAppId ?? effectiveSessionId, { + cols: args.cols, + rows: args.rows }) } - } - if ( - typeof args.worktreeId === 'string' && - args.worktreeId.length > 0 && - args.worktreeId.length <= 512 - ) { - runtime?.registerPty( - result.id, - args.worktreeId, - args.connectionId ?? null, - // Why: pass the validated pane identity so a mobile create waiting on - // this renderer tab can publish its surface main-side when graph-sync - // is throttled, instead of destroying the live PTY (#7587). Bound the - // untrusted tabId like the sibling metadataPaneKey/spawnOptions.tabId. - typeof args.tabId === 'string' && - isValidTerminalTabId(args.tabId) && - args.tabId.length <= 512 && - metadataLeafId !== null - ? { tabId: args.tabId, leafId: metadataLeafId } + if (process.platform === 'win32' && !args.connectionId) { + // Why: the renderer only models PowerShell as one shell family. Thread + // the persisted implementation choice through spawnOptions so both the + // in-process and daemon-backed PTY paths can resolve the same effective + // executable without inventing a fourth top-level shell. + spawnOptions.terminalWindowsWslDistro = + terminalRuntimeOptions.terminalWindowsWslDistro ?? null + spawnOptions.terminalWindowsPowerShellImplementation = getSettings + ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') : undefined - ) - } - // Why: arms main's per-PTY Command Code output detector from the launch - // command (renderer startupCommand parity); banner detection covers - // PTYs spawned without one. - runtime?.noteTerminalSpawnCommand?.( - result.id, - typeof args.command === 'string' ? args.command : null - ) - if (isClaudeLaunch) { - markClaudePtySpawned(result.id) - } - // Why: renderer sets ORCA_PANE_KEY in `args.env` for every pane-owned - // spawn (see pty-connection.ts). Recording the mapping here lets - // clearProviderPtyState clear the agent-hooks server's per-paneKey - // caches when the PTY exits. - // Why: args.env arrives as untrusted JSON over IPC — the static - // Record type is not actually enforced at the boundary. - // Narrow to a bounded string so malformed or oversized values cannot - // pollute ptyPaneKey or the downstream clearPaneState call. - const rememberedPaneKey = validatedPaneKey - ? rememberPaneKeyForPty(result.id, validatedPaneKey) - : null - if (legacySpawnPaneKey && migrationUnsupportedPaneKey) { - agentHookServer.registerPaneKeyAlias( - legacySpawnPaneKey.paneKey, - migrationUnsupportedPaneKey, - result.id - ) - clearMigrationUnsupportedPtysForPaneKey(migrationUnsupportedPaneKey) - } else if (validatedPaneKey) { - if (!result.isReattach) { - clearMigrationUnsupportedPtysForPaneKey(validatedPaneKey) } - } - // Why: register local PTYs (connectionId falsy) with the memory - // collector so it can walk each PTY's process subtree and attribute - // memory back to its worktree. SSH PTYs execute remotely and their - // process tree is not visible to our local `ps`, so we skip them. - if (!args.connectionId) { - // Why: providers publish the OS pid on the spawn result (both - // LocalPtyProvider and DaemonPtyAdapter). Recording it once here keeps - // the memory module from reaching back into ipc/pty on a hot path, and - // works uniformly whether the PTY is hosted in-process or by the - // daemon subprocess. - const spawnedPid = result.pid ?? null - // Why: args.worktreeId and args.sessionId arrive as untrusted IPC - // payload strings — the static type is not enforced at the boundary. - // Narrow them to bounded strings here to match the paneKey defense - // above so malformed or oversized values cannot pollute registerPty's - // maps or downstream memory-attribution lookups. - registerPty({ - ptyId: result.id, - worktreeId: + assertPtySpawnHasNoRetainedShutdown(effectiveSessionAppId) + const existingPaneSpawn = reservationPaneKey + ? paneSpawnReservationsByPaneKey.get(reservationPaneKey) + : undefined + if (existingPaneSpawn) { + return await existingPaneSpawn.promise + } + const paneSpawnReservation = reservationPaneKey + ? reservePaneSpawn(reservationPaneKey) + : null + const initiallyHidden = args.initiallyHidden === true + // Why pre-spawn for daemon-host sessions (id minted up front): daemon + // PTYs can emit prompt bytes before spawn() resolves, and the hidden + // mark must beat the first byte so the gate + model responder own + // spawn-time queries (terminal-query-authority.md §races). Other + // providers cannot emit until spawn resolves; the post-spawn mark + // below is byte-zero-safe for them. + const preSpawnHiddenMarkId = + initiallyHidden && isDaemonHostSpawn && effectiveSessionAppId !== undefined + ? effectiveSessionAppId + : null + if (preSpawnHiddenMarkId !== null) { + markHiddenRendererPty(preSpawnHiddenMarkId) + } + let result: PtySpawnResult | undefined + let failedSpawnCleanupAttempted = false + try { + try { + if (preAllocatedHandle) { + trustedTerminalHandleEnv.add(preAllocatedHandle) + } + if (preSpawnStartupTerminalColorReplyPtyId && startupTerminalColorQueryReplyColors) { + // Why: Codex probes OSC 10/11 with a 100 ms timeout and daemon PTYs + // can emit that query before spawn() resolves to the renderer. + registerStartupTerminalColorQueryReplies( + preSpawnStartupTerminalColorReplyPtyId, + startupTerminalColorQueryReplyColors + ) + } + spawnTiming.mark('options') + result = await provider.spawn(spawnOptions) + spawnTiming.mark('provider_spawn') + } catch (err) { + // Why: a failed spawn must not leave a stale hidden mark on a session + // id a later visible attach may reuse. + if (preSpawnHiddenMarkId !== null) { + unmarkHiddenRendererPty(preSpawnHiddenMarkId) + } + const rawMessage = err instanceof Error ? err.message : String(err) + const spawnError = normalizeNodePtySpawnError(err) + const isIdentityMismatch = + isSshPtyIdentityMismatchError(spawnError) || + isSshPtyIdentityMismatchError(rawMessage) + if (preSpawnStartupTerminalColorReplyPtyId) { + clearStartupTerminalColorQueryReplies(preSpawnStartupTerminalColorReplyPtyId) + } + 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 means the relay has already dropped + // the backing PTY. Clear the durable lease so later session writes + // cannot restore the stale pane binding. + if (effectiveSessionAppId !== undefined && !isIdentityMismatch) { + clearProviderPtyState(effectiveSessionAppId) + deletePtyOwnership(effectiveSessionAppId) + store?.markSshRemotePtyLease(args.connectionId, effectiveSessionAppId, 'expired') + } + } + // Why: if buildPtyHostEnv materialized provider state for this minted + // id but provider.spawn failed, that state would otherwise leak. + if (isMintedSessionId && effectiveSessionId !== undefined) { + clearProviderPtyState(effectiveSessionId) + } + // Why: telemetry-plan.md§agent_error — when the renderer threaded + // agent_kind through args.telemetry, attribute the error to that agent. + // Otherwise fall back to sniffing the command for `claude` (the one + // agent the main process can identify on its own via the existing + // `isClaudeLaunchCommand` regex used for auth gating). Bare-shell + // catches and unknown-agent catches without renderer telemetry remain + // unattributed. The event still emits with a classified `error_class`; + // raw error messages are dropped at the telemetry 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) + } + } + spawnTiming.log(result.id, { + daemon: isDaemonHostSpawn, + reattach: result.isReattach ?? false + }) + ptyOwnership.set(result.id, args.connectionId ?? null) + if (initiallyHidden) { + // Why marked synchronously before any await below: local/SSH provider + // data events dispatch on later tasks, so this is still ahead of the + // first byte's delivery decision. Idempotent for daemon hosts already + // marked pre-spawn; the renderer's first visibility sync re-marks or + // unmarks (emitting the restore marker) through the Phase-4 path. + markHiddenRendererPty(result.id) + if (preSpawnHiddenMarkId !== null && preSpawnHiddenMarkId !== result.id) { + // Defense: never strand a mark on an id the provider renamed. + unmarkHiddenRendererPty(preSpawnHiddenMarkId) + } + // Why after ptyOwnership.set: the provider lookup routes by + // ownership, and a hidden-spawned agent should be paceable from its + // first flood, not from its first visibility transition. + syncPtyBackgroundedDelivery(result.id, 'spawn') + } + // Why: Phase-5 ConPTY DA1 — record the native-Windows-local-PTY + // determination from the spawn record before the headless seed below, + // so the runtime emulator's DA1 override exists from byte zero. + if ( + isNativeWindowsLocalPtySpawn({ + connectionId: args.connectionId, + cwd: args.cwd, + shellOverride: effectiveShellOverride + }) + ) { + markNativeWindowsConptyPty(result.id) + } + if (startupTerminalColorQueryReplyColors) { + if (result.isReattach) { + if (preSpawnStartupTerminalColorReplyPtyId) { + clearStartupTerminalColorQueryReplies(preSpawnStartupTerminalColorReplyPtyId) + } + } else if (preSpawnStartupTerminalColorReplyPtyId) { + moveStartupTerminalColorQueryReplies( + preSpawnStartupTerminalColorReplyPtyId, + result.id + ) + } else { + registerStartupTerminalColorQueryReplies( + result.id, + startupTerminalColorQueryReplyColors + ) + } + } + const relayResultId = getRelayPtyId(args.connectionId, result.id) + const relayInstanceId = parseAppSshPtyId(result.id)?.relayInstanceId + if (store && args.connectionId) { + // Why: remote PTYs live in the SSH relay grace window after Orca + // detaches. Persist their IDs immediately so reconnect can reattach + // instead of treating the tab as a fresh shell. + store.upsertSshRemotePtyLease({ + targetId: args.connectionId, + ptyId: relayResultId, + ...(relayInstanceId ? { relayInstanceId } : {}), + ...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}), + ...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}), + ...(validatedLeafId ? { leafId: validatedLeafId } : {}), + state: 'attached', + lastAttachedAt: Date.now() + }) + } + if (preAllocatedHandle) { + runtime?.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) + } + ptySizes.set(result.id, { cols: args.cols, rows: args.rows }) + // Why: closes the SIGKILL-between-spawn-and-persist race (Issue #217) + // for local daemon PTYs and the equivalent remote-relay race for SSH. + // The renderer's debounced session writer runs in parallel for every + // other field; patch the load-bearing (tab.ptyId, ptyIdsByLeafId) + // binding synchronously so a force-quit in the ~450 ms debounce window + // cannot orphan either daemon history or a remote relay PTY lease. + if ( + (isDaemonHostSpawn || args.connectionId) && + store && + typeof args.worktreeId === 'string' && + typeof args.tabId === 'string' && + validatedLeafId !== null + ) { + try { + store.persistPtyBinding({ + worktreeId: args.worktreeId, + tabId: args.tabId, + leafId: validatedLeafId, + ptyId: result.id, + ...(cwd ? { startupCwd: cwd } : {}) + }) + } catch (err) { + console.error('[pty] failed to persist PTY binding after spawn:', err) + if (!result.isReattach) { + failedSpawnCleanupAttempted = true + try { + await retainFailedSpawnCleanup(result.id, args.connectionId, provider, { + immediate: true, + ...(validatedPaneKey ? { expectedPaneKey: validatedPaneKey } : {}), + ...(typeof args.tabId === 'string' ? { expectedTabId: args.tabId } : {}) + }) + } catch (shutdownErr) { + console.warn( + '[pty] failed to clean up PTY after persistence failure:', + shutdownErr + ) + } + } + throw new Error(createTerminalSessionStateSaveFailureMessage()) + } + } + // Why: pre-signal cooperation gate — when the renderer has declared it + // will own the serializer for this paneKey, suppress the daemon-snapshot + // seed so the renderer's hydration path (maybeHydrateHeadlessFromRenderer) + // is the sole authority. The pre-signal is keyed on paneKey because at + // spawn time the renderer doesn't yet know the new ptyId. See + // docs/mobile-prefer-renderer-scrollback.md. + const rendererPreSignaled = validatedPaneKey + ? pendingByPaneKey.has(validatedPaneKey) + : false + const rendererAlreadyRegistered = rendererSerializerByPtyId.has(result.id) + // Why: capture the pending gen at spawn time so teardown for THIS PTY + // only settles its own generation. A remount that replaces the entry + // with a new gen must not be stomped by the old PTY's teardown. + if (validatedPaneKey && rendererPreSignaled) { + const pending = pendingByPaneKey.get(validatedPaneKey) + if (pending) { + ptyPendingGenByPtyId.set(result.id, pending.gen) + } + } + + // Why: hydrate the runtime's headless emulator with the adapter's + // restore data BEFORE registerPty so any live PTY data that arrives + // concurrently lands on top of the seed instead of replacing it. Mobile + // subscribers then see the same scrollback the desktop xterm received + // via coldRestore/snapshot. Without this, mobile snapshots after a + // daemon-restored attach contain only bytes emitted since the relaunch + // and the prior agent output silently disappears. + // + // Skip when the renderer is or will be authoritative for this PTY: + // its hydration path will seed the emulator from xterm's live buffer, + // which is richer than the daemon snapshot. + if (runtime && !rendererPreSignaled && !rendererAlreadyRegistered) { + const seedSize = + 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 string omits + // them by design (renderer kitty reset stays authoritative), but + // the re-seeded emulator must answer hidden `CSI ? u` with the + // flags the still-running app pushed (terminal-query-authority.md). + runtime.seedHeadlessTerminal( + result.id, + result.snapshot, + seedSize, + typeof result.snapshotKittyKeyboardFlags === 'number' + ? { kittyKeyboardFlags: result.snapshotKittyKeyboardFlags } + : {} + ) + } else if ( + result.coldRestore && + typeof result.coldRestore.scrollback === 'string' && + result.coldRestore.scrollback.length > 0 + ) { + runtime.seedHeadlessTerminal(result.id, result.coldRestore.scrollback, seedSize, { + cwd: result.coldRestore.cwd, + oscLinks: result.coldRestore.oscLinks + }) + } + } + if ( 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 `provider.spawn` resolved. The renderer threads - // `args.telemetry` through the spawn IPC for every launch we want to - // attribute; bare-shell tabs (no agent) leave the field undefined and - // do not produce an event. Each field is parsed against its closed - // enum here so a malformed renderer payload (or a spoofed IPC) does - // not poison the event — `safeParse` failure drops that field, and - // if any required field is missing we skip the event entirely. The - // main-side `track()` validator re-runs the schema on the full - // payload as a second defense-in-depth check. - if (args.telemetry) { - 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() - }) + ) { + runtime?.registerPty( + result.id, + args.worktreeId, + args.connectionId ?? null, + // Why: pass the validated pane identity so a mobile create waiting on + // this renderer tab can publish its surface main-side when graph-sync + // is throttled, instead of destroying the live PTY (#7587). Bound the + // untrusted tabId like the sibling metadataPaneKey/spawnOptions.tabId. + typeof args.tabId === 'string' && + isValidTerminalTabId(args.tabId) && + args.tabId.length <= 512 && + metadataLeafId !== null + ? { tabId: args.tabId, leafId: metadataLeafId } + : undefined, + !args.connectionId + ? shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd) + : undefined + ) + } + // Why: arms main's per-PTY Command Code output detector from the launch + // command (renderer startupCommand parity); banner detection covers + // PTYs spawned without one. + runtime?.noteTerminalSpawnCommand?.( + result.id, + typeof args.command === 'string' ? args.command : null + ) + if (isClaudeLaunch) { + markClaudePtySpawned(result.id) + } + // Why: renderer sets ORCA_PANE_KEY in `args.env` for every pane-owned + // spawn (see pty-connection.ts). Recording the mapping here lets + // clearProviderPtyState clear the agent-hooks server's per-paneKey + // caches when the PTY exits. + // Why: args.env arrives as untrusted JSON over IPC — the static + // Record type is not actually enforced at the boundary. + // Narrow to a bounded string so malformed or oversized values cannot + // pollute ptyPaneKey or the downstream clearPaneState call. + 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 local PTYs (connectionId falsy) with the memory + // collector so it can walk each PTY's process subtree and attribute + // memory back to its worktree. SSH PTYs execute remotely and their + // process tree is not visible to our local `ps`, so we skip them. + if (!args.connectionId) { + // Why: providers publish the OS pid on the spawn result (both + // LocalPtyProvider and DaemonPtyAdapter). Recording it once here keeps + // the memory module from reaching back into ipc/pty on a hot path, and + // works uniformly whether the PTY is hosted in-process or by the + // daemon subprocess. + const spawnedPid = result.pid ?? null + // Why: args.worktreeId and args.sessionId arrive as untrusted IPC + // payload strings — the static type is not enforced at the boundary. + // Narrow them to bounded strings here to match the paneKey defense + // above so malformed or oversized values cannot pollute registerPty's + // maps or downstream memory-attribution lookups. + 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 `provider.spawn` resolved. The renderer threads + // `args.telemetry` through the spawn IPC for every launch we want to + // attribute; bare-shell tabs (no agent) leave the field undefined and + // do not produce an event. Each field is parsed against its closed + // enum here so a malformed renderer payload (or a spoofed IPC) does + // not poison the event — `safeParse` failure drops that field, and + // if any required field is missing we skip the event entirely. The + // main-side `track()` validator re-runs the schema on the full + // payload as a second defense-in-depth check. + if (args.telemetry) { + 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, + ...(!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 } : {}) + } + return resolvePaneSpawnReservation(reservationPaneKey, paneSpawnReservation, response) + } catch (err) { + if (result?.isReattach) { + // Why: this request published only routing ownership for an existing + // provider session; failed setup must not kill that session. + deletePtyOwnership(result.id) + } else if (result && !failedSpawnCleanupAttempted) { + try { + await retainFailedSpawnCleanup(result.id, args.connectionId, provider, { + immediate: true, + ...(validatedPaneKey ? { expectedPaneKey: validatedPaneKey } : {}), + ...(typeof args.tabId === 'string' ? { expectedTabId: args.tabId } : {}) + }) + } catch (shutdownErr) { + console.warn('[pty] failed to clean up PTY after post-spawn failure:', shutdownErr) + } + } + // 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 paneSpawnReservationsByPaneKey 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(reservationPaneKey, paneSpawnReservation, err) + throw err } - } - const response = { - ...result, - ...(!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 } : {}) - } - return resolvePaneSpawnReservation(reservationPaneKey, paneSpawnReservation, response) - } catch (err) { - // 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 paneSpawnReservationsByPaneKey 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(reservationPaneKey, paneSpawnReservation, err) - throw err - } - } + }, + args.connectionId + ) ) const writePtyProviderInputWithinLimit = ( @@ -4916,46 +5684,53 @@ export function registerPtyHandlers( runtime?.clearHeadlessTerminalBuffer(args.id).catch(() => {}) }) - ipcMain.handle('pty:kill', async (_event, args: { id: string; keepHistory?: boolean }) => { - const ownedConnectionId = ptyOwnership.get(args.id) - const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null - const connectionId = ownedConnectionId ?? parsedSshId?.connectionId - 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. - finishPtyShutdown(args.id, connectionId, store) - runtime?.onPtyExit(args.id, -1) - 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 SSH shutdown can leave the remote process alive in - // the relay grace window; daemon failures have the same risk locally. - // Keep ownership/lease state so the user can retry. - throw err + ipcMain.handle( + 'pty:kill', + async ( + _event, + args: { + id: string + keepHistory?: boolean + expectedPaneKey?: string + expectedTabId?: string } - /* session already dead — cleanup below handles the rest */ + ) => { + if (typeof args?.id !== 'string' || !args.id || args.id.startsWith('remote:')) { + // Why: runtime terminal handles belong to terminal.close; allowing them + // to fall through unowned PTY routing could target the local provider. + throw new Error('Invalid PTY provider id') + } + const ownedConnectionId = ptyOwnership.get(args.id) + const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null + const connectionId = ownedConnectionId ?? parsedSshId?.connectionId + const shutdownOptions: PtyShutdownOptions = { + immediate: true, + keepHistory: args.keepHistory ?? false, + ...(args.expectedPaneKey ? { expectedPaneKey: args.expectedPaneKey } : {}), + ...(args.expectedTabId ? { expectedTabId: args.expectedTabId } : {}) + } + const provider = connectionId ? sshProviders.get(connectionId) : tryGetProviderForPty(args.id) + if (!provider && connectionId) { + // Why: a disconnected provider is not proof the relay process exited. + // Retain only an exact durable lease so reconnect can target it without + // treating a generationless id as authority over a replacement PTY. + retainSshPtyShutdown(args.id, connectionId, null, shutdownOptions) + throw new Error('SSH PTY provider unavailable') + } + const shutdownProvider = provider ?? getProviderForPty(args.id) + // Why: the first request and every retry need one generation-fenced owner; + // a separate direct path can accept a stale provider's late completion. + await shutdownPtyWithRetainedOwnership({ + id: args.id, + connectionId, + provider: shutdownProvider, + options: shutdownOptions, + // Why: main already owns the exact retry. A normal asynchronous exit + // must not reject into renderer diagnostics while proof is pending. + settleWhenShutdownAccepted: true + }) } - // Why: some shutdown paths do not emit onExit through the provider listener. - // Explicit cleanup is idempotent and covers already-dead PTYs. - finishPtyShutdown(args.id, connectionId, store) - if (!providerExitObserved) { - runtime?.onPtyExit(args.id, -1) - rememberSyntheticKillExit(args.id) - sendPtyExitToRenderer({ id: args.id, code: -1 }) - } - }) + ) ipcMain.handle( 'pty:listSessions', @@ -4990,11 +5765,20 @@ export function registerPtyHandlers( const provider = parsedSshId ? sshProviders.get(parsedSshId.connectionId) : tryGetProviderForPty(args.id) - if (!provider?.hasPty) { + if (!provider) { return null } try { - return provider.hasPty(args.id) + if (provider.hasPty) { + return provider.hasPty(args.id) + } + if (!parsedSshId && typeof ownedConnectionId !== 'string') { + return null + } + // Why: overflow recovery is a one-shot exceptional path. SSH has no + // synchronous hasPty API, so its generation-qualified inventory is the + // authoritative fallback instead of discarding a real pre-handler exit. + return (await provider.listProcesses()).some((session) => session.id === args.id) } catch { // Why: liveness is only allowed to close panes on an authoritative false. return null diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 08af6977efa..e3456d3dad4 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -466,7 +466,9 @@ describe('registerCoreHandlers', () => { expect(registerOpenCodeUsageHandlersMock).toHaveBeenCalledWith(openCodeUsage) expect(registerAppHandlersMock).toHaveBeenCalledWith(store, { onBeforeRelaunch }) expect(registerCodexAccountHandlersMock).toHaveBeenCalledWith(codexAccounts) - expect(registerAgentHookHandlersMock).toHaveBeenCalledWith(runtime) + expect(registerAgentHookHandlersMock).toHaveBeenCalledWith(runtime, { + getPtyIdForPaneKey: expect.any(Function) + }) expect(registerPetHandlersMock).toHaveBeenCalled() expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts) expect(registerMiniMaxCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits) diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index dc2c219025a..c827ba561df 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -53,6 +53,7 @@ import { registerSpeechHandlers } from './speech' import { registerOrcaProfileHandlers } from './orca-profiles' import { registerCodexAccountHandlers } from './codex-accounts' import { registerAgentHookHandlers } from './agent-hooks' +import { getPtyIdForPaneKey } from './pty' import { registerAgentTrustHandlers } from './agent-trust' import { registerClaudeAccountHandlers } from './claude-accounts' import { registerMiniMaxCredentialsHandlers } from './minimax-credentials' @@ -122,7 +123,7 @@ export function registerCoreHandlers( registerCodexUsageHandlers(codexUsage) registerOpenCodeUsageHandlers(openCodeUsage) registerCodexAccountHandlers(codexAccounts) - registerAgentHookHandlers(runtime) + registerAgentHookHandlers(runtime, { getPtyIdForPaneKey }) registerAgentTrustHandlers() registerClaudeAccountHandlers(claudeAccounts) registerMiniMaxCredentialsHandlers(rateLimits) diff --git a/src/main/ipc/repos-create.test.ts b/src/main/ipc/repos-create.test.ts index 040f9348250..51d8dbab5b6 100644 --- a/src/main/ipc/repos-create.test.ts +++ b/src/main/ipc/repos-create.test.ts @@ -32,6 +32,7 @@ const { getRepos: vi.fn().mockReturnValue([]), addRepo: vi.fn(), removeProject: vi.fn(), + removeProjectForHost: vi.fn(), getRepo: vi.fn(), updateRepo: vi.fn() }, @@ -132,6 +133,8 @@ describe('repos:create', () => { removeHandlerMock.mockReset() mockStore.getRepos.mockReset().mockReturnValue([]) mockStore.addRepo.mockReset() + mockStore.removeProject.mockReset() + mockStore.removeProjectForHost.mockReset() mockWindow.webContents.send.mockReset() invalidateAuthorizedRootsCacheMock.mockReset() prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined) @@ -164,6 +167,29 @@ describe('repos:create', () => { expect(removeHandlerMock).toHaveBeenCalledWith('repos:getDefaultCreateProjectParent') }) + it('routes desktop repo removal through the runtime teardown transaction', async () => { + const runtime = { removeProject: vi.fn().mockResolvedValue({ removed: true }) } + registerRepoHandlers(mockWindow as never, mockStore as never, runtime as never) + + await handlers.get('repos:remove')?.(null, { repoId: 'repo-1' }) + + expect(runtime.removeProject).toHaveBeenCalledWith('id:repo-1') + expect(mockStore.removeProject).not.toHaveBeenCalled() + }) + + it('routes host-scoped repo removal through the runtime teardown transaction', async () => { + const runtime = { removeProjectForHost: vi.fn().mockResolvedValue({ removed: true }) } + registerRepoHandlers(mockWindow as never, mockStore as never, runtime as never) + + await handlers.get('repos:removeForHost')?.(null, { + repoId: 'repo-1', + hostId: 'local' + }) + + expect(runtime.removeProjectForHost).toHaveBeenCalledWith('repo-1', 'local') + expect(mockStore.removeProjectForHost).not.toHaveBeenCalled() + }) + // ── input validation ────────────────────────────────────────────── it('rejects empty names', async () => { diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index 8b5cb94e504..18f73ca6f8f 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -155,10 +155,7 @@ vi.mock('./ssh', () => ({ })) import { registerRepoHandlers } from './repos' -import { - clearSubmodulePathsCacheForTests, - listSubmodulePaths -} from '../git/status' +import { clearSubmodulePathsCacheForTests, listSubmodulePaths } from '../git/status' beforeEach(() => { clearGitCapabilityStateForTests() diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index d1b1821081a..c4385ec4bbf 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto' import { homedir } from 'node:os' import { z } from 'zod' import type { Store } from '../persistence' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' import type { BaseRefSearchResult, Project, @@ -1114,7 +1115,11 @@ async function runNestedRepoScanForIpc( } } -export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): void { +export function registerRepoHandlers( + mainWindow: BrowserWindow, + store: Store, + runtime?: OrcaRuntimeService +): void { // Remove any previously registered handlers so we can re-register them // (e.g. when macOS re-activates the app and creates a new window). ipcMain.removeHandler('repos:list') @@ -1924,7 +1929,11 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ) ipcMain.handle('repos:remove', async (_event, args: { repoId: string }) => { - store.removeProject(args.repoId) + if (runtime) { + await runtime.removeProject(`id:${args.repoId}`) + } else { + store.removeProject(args.repoId) + } invalidateAuthorizedRootsCache() notifyReposChanged(mainWindow) }) @@ -1939,7 +1948,11 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v if (!hostId) { throw new Error(`Invalid host ID: ${args.hostId}`) } - store.removeProjectForHost(args.repoId, hostId) + if (runtime?.removeProjectForHost) { + await runtime.removeProjectForHost(args.repoId, hostId) + } else { + store.removeProjectForHost(args.repoId, hostId) + } invalidateAuthorizedRootsCache() notifyReposChanged(mainWindow) } diff --git a/src/main/ipc/runtime-environments.test.ts b/src/main/ipc/runtime-environments.test.ts index 3dbcac7a7ec..86904354fae 100644 --- a/src/main/ipc/runtime-environments.test.ts +++ b/src/main/ipc/runtime-environments.test.ts @@ -499,6 +499,181 @@ describe('registerRuntimeEnvironmentHandlers', () => { expect(sendRemoteRuntimeRequestMock).not.toHaveBeenCalled() }) + it('persists and retries exact terminal closes after renderer loss', async () => { + vi.useFakeTimers() + const upsertClose = vi.fn() + const removeClose = vi.fn() + Object.assign(store, { + getPendingRuntimeTerminalCloses: () => [], + upsertPendingRuntimeTerminalClose: upsertClose, + removePendingRuntimeTerminalClose: removeClose + }) + registerRuntimeEnvironmentHandlers(store as never) + let closeAttempt = 0 + sendRemoteRuntimeRequestMock.mockImplementation(async (_pairing, method) => { + if (method === 'status.get') { + return { + id: 'status', + ok: true, + result: { runtimeId: 'runtime-remote', capabilities: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + closeAttempt += 1 + if (closeAttempt < 3) { + return { + id: `close-${closeAttempt}`, + ok: false, + error: { message: closeAttempt === 1 ? 'offline' : 'still offline' } + } + } + return { + id: 'close-3', + ok: true, + result: { close: true }, + _meta: { runtimeId: 'runtime-remote' } + } + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + const added = await add(null, { name: 'desk', pairingCode: pairingCode() }) + const call = handler<{ selector: string; method: string; params?: unknown }, { ok: boolean }>( + 'runtimeEnvironments:call' + ) + await expect( + call(null, { selector: 'desk', method: 'terminal.close', params: { terminal: 't1' } }) + ).resolves.toMatchObject({ ok: false }) + + await vi.advanceTimersByTimeAsync(250) + await vi.advanceTimersByTimeAsync(500) + + expect(closeAttempt).toBe(3) + expect(upsertClose).toHaveBeenCalledWith({ + environmentId: added.environment.id, + handle: 't1', + requestedAt: expect.any(Number) + }) + expect(removeClose).toHaveBeenCalledWith(added.environment.id, 't1') + expect(vi.getTimerCount()).toBe(0) + vi.useRealTimers() + }) + + it('hydrates and drains persisted terminal close intent after app restart', async () => { + vi.useFakeTimers() + const environment = environmentStore.addEnvironmentFromPairingCode(userDataPath, { + name: 'desk', + pairingCode: pairingCode() + }) + const removePendingRuntimeTerminalClose = vi.fn() + Object.assign(store, { + getPendingRuntimeTerminalCloses: () => [ + { environmentId: environment.id, handle: 't1', requestedAt: 1 } + ], + upsertPendingRuntimeTerminalClose: vi.fn(), + removePendingRuntimeTerminalClose + }) + sendRemoteRuntimeRequestMock.mockImplementation(async (_pairing, method) => + method === 'status.get' + ? { + id: 'status', + ok: true, + result: { runtimeId: 'runtime-remote', capabilities: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + : { + id: 'close', + ok: true, + result: { close: true }, + _meta: { runtimeId: 'runtime-remote' } + } + ) + + registerRuntimeEnvironmentHandlers(store as never) + await vi.advanceTimersByTimeAsync(0) + + expect(sendRemoteRuntimeRequestMock.mock.calls.map((call) => call[1])).toEqual([ + 'status.get', + 'status.get', + 'terminal.close' + ]) + expect(removePendingRuntimeTerminalClose).toHaveBeenCalledWith(environment.id, 't1') + expect(vi.getTimerCount()).toBe(0) + vi.useRealTimers() + }) + + it('drops persisted close intent for a removed environment without an unhandled rejection', async () => { + vi.useFakeTimers() + const removePendingRuntimeTerminalClose = vi.fn() + const unhandledRejection = vi.fn() + Object.assign(store, { + getPendingRuntimeTerminalCloses: () => [ + { + environmentId: 'removed-environment', + handle: 't1', + runtimeId: 'runtime-old', + requestedAt: 1 + } + ], + upsertPendingRuntimeTerminalClose: vi.fn(), + removePendingRuntimeTerminalClose + }) + process.on('unhandledRejection', unhandledRejection) + + try { + registerRuntimeEnvironmentHandlers(store as never) + await vi.advanceTimersByTimeAsync(0) + await Promise.resolve() + + expect(sendRemoteRuntimeRequestMock).not.toHaveBeenCalled() + expect(removePendingRuntimeTerminalClose).toHaveBeenCalledWith('removed-environment', 't1') + expect(unhandledRejection).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + } finally { + process.off('unhandledRejection', unhandledRejection) + vi.useRealTimers() + } + }) + + it('does not apply persisted close intent to a replacement runtime generation', async () => { + vi.useFakeTimers() + const environment = environmentStore.addEnvironmentFromPairingCode(userDataPath, { + name: 'desk', + pairingCode: pairingCode() + }) + environmentStore.markEnvironmentUsed(userDataPath, environment.id, { runtimeId: 'runtime-a' }) + const removePendingRuntimeTerminalClose = vi.fn() + Object.assign(store, { + getPendingRuntimeTerminalCloses: () => [ + { + environmentId: environment.id, + handle: 't1', + runtimeId: 'runtime-a', + requestedAt: 1 + } + ], + upsertPendingRuntimeTerminalClose: vi.fn(), + removePendingRuntimeTerminalClose + }) + sendRemoteRuntimeRequestMock.mockResolvedValue({ + id: 'status', + ok: true, + result: { runtimeId: 'runtime-b', capabilities: [] }, + _meta: { runtimeId: 'runtime-b' } + }) + + registerRuntimeEnvironmentHandlers(store as never) + await vi.advanceTimersByTimeAsync(0) + + expect(sendRemoteRuntimeRequestMock.mock.calls.map((call) => call[1])).toEqual(['status.get']) + expect(removePendingRuntimeTerminalClose).toHaveBeenCalledWith(environment.id, 't1') + expect(vi.getTimerCount()).toBe(0) + vi.useRealTimers() + }) + it('keeps terminal hot path RPCs on the cached request connection when shared control is supported', async () => { registerRuntimeEnvironmentHandlers(store as never) sendRemoteRuntimeRequestMock.mockResolvedValue({ diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index 0c1aca30cd5..714eb6c9f07 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -23,6 +23,11 @@ import { resetSharedControlSupport, subscribeRuntimeEnvironment } from './runtime-environment-transport-routing' +import { + closeRuntimeTerminalWithRetryOwnership, + initializeRuntimeTerminalCloseRetryOwner, + releaseRuntimeTerminalClosesForEnvironment +} from './runtime-terminal-close-retry-owner' const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [ 'runtimeEnvironments:list', @@ -66,6 +71,7 @@ function listPublicRuntimeEnvironments(): PublicKnownRuntimeEnvironment[] { } export function registerRuntimeEnvironmentHandlers(store: Store): void { + initializeRuntimeTerminalCloseRetryOwner(store, getUserDataPath()) // Why: keep direct re-registration safe even though register-core-handlers // normally guards this path; otherwise the binary send listener can stack. resetSharedControlSupport() @@ -103,6 +109,8 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { } clearActiveRuntimeEnvironmentFocusIfMatches(store, removed.id) closeSubscriptionsForEnvironment(removed.id) + // Why: removal is authoritative; no future transport can reach these handles. + releaseRuntimeTerminalClosesForEnvironment(removed.id) return { removed: redactRuntimeEnvironment(removed) } } ) @@ -137,6 +145,19 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { _event, args: { selector: string; method: string; params?: unknown; timeoutMs?: number } ): Promise> => { + if ( + args.method === 'terminal.close' && + args.params && + typeof args.params === 'object' && + typeof (args.params as { terminal?: unknown }).terminal === 'string' + ) { + const environment = resolveEnvironment(getUserDataPath(), args.selector) + return closeRuntimeTerminalWithRetryOwnership( + environment.id, + (args.params as { terminal: string }).terminal, + environment.runtimeId + ) + } return callRuntimeEnvironment( getUserDataPath(), args.selector, diff --git a/src/main/ipc/runtime-terminal-close-retry-owner.test.ts b/src/main/ipc/runtime-terminal-close-retry-owner.test.ts new file mode 100644 index 00000000000..c786416c6f0 --- /dev/null +++ b/src/main/ipc/runtime-terminal-close-retry-owner.test.ts @@ -0,0 +1,312 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { callRuntimeEnvironmentMock, getRuntimeEnvironmentStatusMock } = vi.hoisted(() => ({ + callRuntimeEnvironmentMock: vi.fn(), + getRuntimeEnvironmentStatusMock: vi.fn() +})) + +vi.mock('./runtime-environment-transport-routing', () => ({ + callRuntimeEnvironment: callRuntimeEnvironmentMock, + getRuntimeEnvironmentStatus: getRuntimeEnvironmentStatusMock +})) + +import { + closeRuntimeTerminalWithRetryOwnership, + initializeRuntimeTerminalCloseRetryOwner, + releaseRuntimeTerminalClosesForEnvironment +} from './runtime-terminal-close-retry-owner' + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +describe('runtime terminal close retry ownership', () => { + beforeEach(() => { + callRuntimeEnvironmentMock.mockReset() + getRuntimeEnvironmentStatusMock.mockReset() + }) + + afterEach(() => { + initializeRuntimeTerminalCloseRetryOwner( + { getPendingRuntimeTerminalCloses: () => [] } as never, + '' + ) + vi.useRealTimers() + }) + + it('keeps a replacement runtime owner when the prior close settles late', async () => { + vi.useFakeTimers() + const closeA = deferred<{ + id: string + ok: true + result: { close: true } + _meta: { runtimeId: string } + }>() + const closeB = deferred<{ + id: string + ok: true + result: { close: true } + _meta: { runtimeId: string } + }>() + const upsert = vi.fn() + const remove = vi.fn() + initializeRuntimeTerminalCloseRetryOwner( + { + getPendingRuntimeTerminalCloses: () => [], + upsertPendingRuntimeTerminalClose: upsert, + removePendingRuntimeTerminalClose: remove + } as never, + '/tmp/orca-runtime-owner-test' + ) + getRuntimeEnvironmentStatusMock + .mockResolvedValueOnce({ + id: 'status-a', + ok: true, + result: { runtimeId: 'runtime-a' }, + _meta: { runtimeId: 'runtime-a' } + }) + .mockResolvedValueOnce({ + id: 'status-b', + ok: true, + result: { runtimeId: 'runtime-b' }, + _meta: { runtimeId: 'runtime-b' } + }) + callRuntimeEnvironmentMock.mockImplementation( + ( + _userDataPath: string, + _environmentId: string, + _method: string, + params: { expectedRuntimeId: string } + ) => (params.expectedRuntimeId === 'runtime-a' ? closeA.promise : closeB.promise) + ) + + const first = closeRuntimeTerminalWithRetryOwnership('environment-1', 'terminal-1', 'runtime-a') + await vi.waitFor(() => expect(callRuntimeEnvironmentMock).toHaveBeenCalledTimes(1)) + const replacement = closeRuntimeTerminalWithRetryOwnership( + 'environment-1', + 'terminal-1', + 'runtime-b' + ) + await vi.waitFor(() => expect(callRuntimeEnvironmentMock).toHaveBeenCalledTimes(2)) + + closeA.resolve({ + id: 'close-a', + ok: true, + result: { close: true }, + _meta: { runtimeId: 'runtime-a' } + }) + await expect(first).resolves.toMatchObject({ + ok: true, + result: { close: false, reason: 'retry_owner_replaced' } + }) + expect(remove).not.toHaveBeenCalled() + + closeB.resolve({ + id: 'close-b', + ok: true, + result: { close: true }, + _meta: { runtimeId: 'runtime-b' } + }) + await expect(replacement).resolves.toMatchObject({ ok: true, result: { close: true } }) + expect(upsert).toHaveBeenLastCalledWith({ + environmentId: 'environment-1', + handle: 'terminal-1', + runtimeId: 'runtime-b', + requestedAt: expect.any(Number) + }) + expect(remove).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + }) + + it('releases a same-runtime stale handle without another retry', async () => { + vi.useFakeTimers() + const remove = vi.fn() + initializeRuntimeTerminalCloseRetryOwner( + { + getPendingRuntimeTerminalCloses: () => [], + upsertPendingRuntimeTerminalClose: vi.fn(), + removePendingRuntimeTerminalClose: remove + } as never, + '/tmp/orca-runtime-owner-test' + ) + getRuntimeEnvironmentStatusMock.mockResolvedValue({ + id: 'status-a', + ok: true, + result: { runtimeId: 'runtime-a' }, + _meta: { runtimeId: 'runtime-a' } + }) + callRuntimeEnvironmentMock + .mockResolvedValueOnce({ + id: 'close-transient', + ok: false, + error: { code: 'runtime_unavailable', message: 'runtime unavailable' }, + _meta: { runtimeId: 'runtime-a' } + }) + .mockResolvedValueOnce({ + id: 'close-a', + ok: false, + error: { code: 'terminal_handle_stale', message: 'terminal_handle_stale' }, + _meta: { runtimeId: 'runtime-a' } + }) + + await expect( + closeRuntimeTerminalWithRetryOwnership('environment-1', 'terminal-1', 'runtime-a') + ).resolves.toMatchObject({ ok: false, error: { code: 'runtime_unavailable' } }) + expect(vi.getTimerCount()).toBe(1) + + await expect( + closeRuntimeTerminalWithRetryOwnership('environment-1', 'terminal-1', 'runtime-a') + ).resolves.toMatchObject({ + ok: true, + result: { close: false, reason: 'terminal_handle_stale' } + }) + expect(vi.getTimerCount()).toBe(0) + await vi.advanceTimersByTimeAsync(60_000) + + expect(callRuntimeEnvironmentMock).toHaveBeenCalledTimes(2) + expect(remove).toHaveBeenCalledWith('environment-1', 'terminal-1') + expect(vi.getTimerCount()).toBe(0) + }) + + it('attempts the close when durable intent persistence throws', async () => { + vi.useFakeTimers() + const remove = vi.fn() + initializeRuntimeTerminalCloseRetryOwner( + { + getPendingRuntimeTerminalCloses: () => [], + upsertPendingRuntimeTerminalClose: () => { + throw new Error('runtime intent disk full') + }, + removePendingRuntimeTerminalClose: remove + } as never, + '/tmp/orca-runtime-owner-test' + ) + getRuntimeEnvironmentStatusMock.mockResolvedValue({ + id: 'status-a', + ok: true, + result: { runtimeId: 'runtime-a' }, + _meta: { runtimeId: 'runtime-a' } + }) + callRuntimeEnvironmentMock.mockResolvedValue({ + id: 'close-a', + ok: true, + result: { close: true }, + _meta: { runtimeId: 'runtime-a' } + }) + + expect(() => + closeRuntimeTerminalWithRetryOwnership('environment-1', 'terminal-1', 'runtime-a') + ).toThrow('runtime intent disk full') + await vi.advanceTimersByTimeAsync(0) + + expect(callRuntimeEnvironmentMock).toHaveBeenCalledOnce() + expect(remove).toHaveBeenCalledWith('environment-1', 'terminal-1') + expect(vi.getTimerCount()).toBe(0) + }) + + it('hydrates persisted closes without rewriting their durable intent', async () => { + vi.useFakeTimers() + const upsert = vi.fn(() => { + throw new Error('startup replay must not rewrite durable intent') + }) + initializeRuntimeTerminalCloseRetryOwner( + { + getPendingRuntimeTerminalCloses: () => [ + { environmentId: 'environment-1', handle: 'terminal-1', runtimeId: 'runtime-a' } + ], + upsertPendingRuntimeTerminalClose: upsert, + removePendingRuntimeTerminalClose: vi.fn() + } as never, + '/tmp/orca-runtime-owner-test' + ) + getRuntimeEnvironmentStatusMock.mockResolvedValue({ + id: 'status-a', + ok: true, + result: { runtimeId: 'runtime-a' }, + _meta: { runtimeId: 'runtime-a' } + }) + callRuntimeEnvironmentMock.mockResolvedValue({ + id: 'close-a', + ok: true, + result: { close: true }, + _meta: { runtimeId: 'runtime-a' } + }) + + await vi.advanceTimersByTimeAsync(0) + + expect(upsert).not.toHaveBeenCalled() + expect(callRuntimeEnvironmentMock).toHaveBeenCalledOnce() + }) + + it('retries durable removal without repeating an accepted runtime close', async () => { + vi.useFakeTimers() + const remove = vi + .fn() + .mockImplementationOnce(() => { + throw new Error('runtime removal disk full') + }) + .mockReturnValue(undefined) + initializeRuntimeTerminalCloseRetryOwner( + { + getPendingRuntimeTerminalCloses: () => [], + upsertPendingRuntimeTerminalClose: vi.fn(), + removePendingRuntimeTerminalClose: remove + } as never, + '/tmp/orca-runtime-owner-test' + ) + getRuntimeEnvironmentStatusMock.mockResolvedValue({ + id: 'status-a', + ok: true, + result: { runtimeId: 'runtime-a' }, + _meta: { runtimeId: 'runtime-a' } + }) + callRuntimeEnvironmentMock.mockResolvedValue({ + id: 'close-a', + ok: true, + result: { close: true }, + _meta: { runtimeId: 'runtime-a' } + }) + + await expect( + closeRuntimeTerminalWithRetryOwnership('environment-1', 'terminal-1', 'runtime-a') + ).rejects.toThrow('runtime removal disk full') + expect(callRuntimeEnvironmentMock).toHaveBeenCalledOnce() + expect(remove).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(250) + + expect(callRuntimeEnvironmentMock).toHaveBeenCalledOnce() + expect(remove).toHaveBeenCalledTimes(2) + expect(vi.getTimerCount()).toBe(0) + }) + + it('retries only durable cleanup after authoritative environment removal', async () => { + vi.useFakeTimers() + const removeExact = vi.fn() + const removeEnvironment = vi.fn(() => { + throw new Error('environment intent removal disk full') + }) + initializeRuntimeTerminalCloseRetryOwner( + { + getPendingRuntimeTerminalCloses: () => [ + { environmentId: 'environment-1', handle: 'terminal-1', runtimeId: 'runtime-a' } + ], + removePendingRuntimeTerminalClose: removeExact, + removePendingRuntimeTerminalClosesForEnvironment: removeEnvironment + } as never, + '/tmp/orca-runtime-owner-test' + ) + + expect(() => releaseRuntimeTerminalClosesForEnvironment('environment-1')).not.toThrow() + expect(removeEnvironment).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(0) + + expect(getRuntimeEnvironmentStatusMock).not.toHaveBeenCalled() + expect(callRuntimeEnvironmentMock).not.toHaveBeenCalled() + expect(removeExact).toHaveBeenCalledWith('environment-1', 'terminal-1') + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/main/ipc/runtime-terminal-close-retry-owner.ts b/src/main/ipc/runtime-terminal-close-retry-owner.ts new file mode 100644 index 00000000000..2da3507c919 --- /dev/null +++ b/src/main/ipc/runtime-terminal-close-retry-owner.ts @@ -0,0 +1,307 @@ +import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import { RuntimeEnvironmentStoreError } from '../../shared/runtime-environment-store' +import type { Store } from '../persistence' +import { + callRuntimeEnvironment, + getRuntimeEnvironmentStatus +} from './runtime-environment-transport-routing' + +type RetainedTerminalClose = { + generation: number + environmentId: string + handle: string + runtimeId: string | null + attempts: number + closeComplete: boolean + nextRetryAt: number + inFlight: Promise> | null +} + +const retainedCloses = new Map() +const MAX_BACKOFF_MS = 30_000 +let retryTimer: ReturnType | null = null +let store: Store | null = null +let userDataPath = '' +let ownerGeneration = 0 + +function key(environmentId: string, handle: string): string { + return `${environmentId}\0${handle}` +} + +function isCurrentOwner(close: RetainedTerminalClose): boolean { + return ( + close.generation === ownerGeneration && + retainedCloses.get(key(close.environmentId, close.handle)) === close + ) +} + +function release(close: RetainedTerminalClose): void { + if (!isCurrentOwner(close)) { + return + } + close.closeComplete = true + store?.removePendingRuntimeTerminalClose?.(close.environmentId, close.handle) + retainedCloses.delete(key(close.environmentId, close.handle)) + schedule() +} + +function schedule(): void { + if (retryTimer) { + clearTimeout(retryTimer) + } + retryTimer = null + let nextRetryAt = Number.POSITIVE_INFINITY + for (const close of retainedCloses.values()) { + if (!close.inFlight) { + nextRetryAt = Math.min(nextRetryAt, close.nextRetryAt) + } + } + if (!Number.isFinite(nextRetryAt)) { + return + } + retryTimer = setTimeout( + () => { + retryTimer = null + const now = Date.now() + for (const close of retainedCloses.values()) { + if (!close.inFlight && close.nextRetryAt <= now) { + // Why: timer-owned retries have no caller to observe transport rejection. + void attempt(close).catch(() => {}) + } + } + }, + Math.max(0, nextRetryAt - Date.now()) + ) +} + +function attempt(close: RetainedTerminalClose): Promise> { + if (close.inFlight) { + return close.inFlight + } + const noOpResponse = ( + runtimeId?: string | null, + reason: + | 'retry_owner_replaced' + | 'environment_removed' + | 'terminal_handle_stale' = 'retry_owner_replaced' + ) => ({ + id: 'terminal.close', + ok: true as const, + result: { close: false, reason }, + _meta: { runtimeId: runtimeId ?? close.runtimeId ?? 'unknown-runtime' } + }) + const closeAttempt = close.closeComplete + ? Promise.resolve(noOpResponse(close.runtimeId)) + : getRuntimeEnvironmentStatus(userDataPath, close.environmentId).then((status) => { + if (!isCurrentOwner(close)) { + return noOpResponse(status._meta?.runtimeId) + } + if (!status.ok) { + throw Object.assign(new Error(status.error.message), { response: status }) + } + const currentRuntimeId = status._meta.runtimeId + if (close.runtimeId && close.runtimeId !== currentRuntimeId) { + release(close) + return { + id: 'terminal.close', + ok: true as const, + result: { close: false, reason: 'runtime_replaced' }, + _meta: { runtimeId: currentRuntimeId } + } + } + if (!close.runtimeId) { + close.runtimeId = currentRuntimeId + store?.upsertPendingRuntimeTerminalClose?.({ + environmentId: close.environmentId, + handle: close.handle, + runtimeId: currentRuntimeId, + requestedAt: Date.now() + }) + } + return callRuntimeEnvironment(userDataPath, close.environmentId, 'terminal.close', { + terminal: close.handle, + expectedRuntimeId: close.runtimeId + }) + }) + const request = closeAttempt + .then((response) => { + if (!isCurrentOwner(close)) { + return noOpResponse(response._meta?.runtimeId) + } + if (!response.ok) { + const responseRuntimeId = response._meta?.runtimeId + if (close.runtimeId && responseRuntimeId && responseRuntimeId !== close.runtimeId) { + release(close) + return { + id: 'terminal.close', + ok: true as const, + result: { close: false, reason: 'runtime_replaced' }, + _meta: { runtimeId: responseRuntimeId } + } + } + if ( + response.error.code === 'terminal_handle_stale' && + close.runtimeId && + responseRuntimeId === close.runtimeId + ) { + // Why: the same runtime generation is authoritative for its handle. + // A stale handle proves this exact retained resource no longer exists. + release(close) + return noOpResponse(responseRuntimeId, 'terminal_handle_stale') + } + throw Object.assign(new Error(response.error.message), { response }) + } + release(close) + return response + }) + .catch((error: unknown) => { + if (!isCurrentOwner(close)) { + return noOpResponse() + } + if (error instanceof RuntimeEnvironmentStoreError && error.code === 'invalid_argument') { + // Why: persisted ownership for a removed environment can never become reachable again. + release(close) + return noOpResponse(undefined, 'environment_removed') + } + close.attempts += 1 + close.nextRetryAt = + Date.now() + Math.min(MAX_BACKOFF_MS, 250 * 2 ** Math.min(close.attempts - 1, 7)) + if (close.attempts <= 2 || close.attempts === 8) { + console.warn('[runtime-environments] retained terminal close failed:', error) + } + const response = (error as { response?: RuntimeRpcResponse }).response + if (response) { + return response + } + throw error + }) + .finally(() => { + if (!isCurrentOwner(close)) { + return + } + if (close.inFlight === request) { + close.inFlight = null + } + schedule() + }) + close.inFlight = request + return request +} + +function retain( + environmentId: string, + handle: string, + runtimeId?: string | null, + persist = true +): RetainedTerminalClose { + const closeKey = key(environmentId, handle) + const incomingRuntimeId = runtimeId ?? null + let close = retainedCloses.get(closeKey) + const replacesRuntime = Boolean( + close?.runtimeId && incomingRuntimeId && close.runtimeId !== incomingRuntimeId + ) + if (!close || replacesRuntime) { + // Why: handles can be reused after a remote runtime restart. A late + // completion from the prior runtime must not settle the replacement owner. + close = { + generation: ownerGeneration, + environmentId, + handle, + runtimeId: incomingRuntimeId, + attempts: 0, + closeComplete: false, + nextRetryAt: 0, + inFlight: null + } + retainedCloses.set(closeKey, close) + if (persist && typeof store?.upsertPendingRuntimeTerminalClose === 'function') { + try { + store.upsertPendingRuntimeTerminalClose({ + environmentId, + handle, + ...(runtimeId ? { runtimeId } : {}), + requestedAt: Date.now() + }) + } catch (error) { + // Why: a failed durable write cannot prevent the in-memory owner from + // attempting the close while this process remains alive. + schedule() + throw error + } + } + } else if (!close.runtimeId && incomingRuntimeId) { + close.runtimeId = incomingRuntimeId + try { + store?.upsertPendingRuntimeTerminalClose?.({ + environmentId, + handle, + runtimeId: incomingRuntimeId, + requestedAt: Date.now() + }) + } catch (error) { + schedule() + throw error + } + } + return close +} + +export function initializeRuntimeTerminalCloseRetryOwner( + nextStore: Store, + nextUserDataPath: string +): void { + ownerGeneration += 1 + if (retryTimer) { + clearTimeout(retryTimer) + retryTimer = null + } + retainedCloses.clear() + store = nextStore + userDataPath = nextUserDataPath + const persisted = + typeof store.getPendingRuntimeTerminalCloses === 'function' + ? store.getPendingRuntimeTerminalCloses() + : [] + for (const close of persisted) { + retain(close.environmentId, close.handle, close.runtimeId, false) + } + schedule() +} + +export function closeRuntimeTerminalWithRetryOwnership( + environmentId: string, + handle: string, + runtimeId?: string | null +): Promise> { + return attempt(retain(environmentId, handle, runtimeId)) +} + +export function releaseRuntimeTerminalClosesForEnvironment(environmentId: string): void { + const ownedCloses: RetainedTerminalClose[] = [] + for (const close of retainedCloses.values()) { + if (close.environmentId === environmentId) { + // Why: environment deletion proves transport close is unnecessary, but + // durable cleanup must succeed before the retry owner can be discarded. + close.closeComplete = true + close.nextRetryAt = 0 + ownedCloses.push(close) + } + } + if (typeof store?.removePendingRuntimeTerminalClosesForEnvironment === 'function') { + try { + store.removePendingRuntimeTerminalClosesForEnvironment(environmentId) + } catch (error) { + if (ownedCloses.length === 0) { + throw error + } + schedule() + return + } + } + for (const close of ownedCloses) { + if (isCurrentOwner(close)) { + retainedCloses.delete(key(close.environmentId, close.handle)) + } + } + schedule() +} diff --git a/src/main/ipc/runtime-watcher-process-pool.test.ts b/src/main/ipc/runtime-watcher-process-pool.test.ts new file mode 100644 index 00000000000..5cd624589b2 --- /dev/null +++ b/src/main/ipc/runtime-watcher-process-pool.test.ts @@ -0,0 +1,328 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { WatcherProcessFailure } from './parcel-watcher-process-failure' +import type { + WatcherProcessCallback, + WatcherProcessHooks, + WatcherProcessSubscription +} from './parcel-watcher-process-subscription' +import { RuntimeWatcherProcessPool } from './runtime-watcher-process-pool' + +type InstalledSubscription = { + dir: string + hooks: WatcherProcessHooks + unsubscribe: ReturnType Promise>> +} + +class FakeSupervisor { + readonly subscriptions: InstalledSubscription[] = [] + readonly dispose = vi.fn() + subscribeError?: Error + + async subscribe( + dir: string, + _callback: WatcherProcessCallback, + _opts: object, + hooks: WatcherProcessHooks + ): Promise { + if (this.subscribeError) { + throw this.subscribeError + } + const unsubscribe = vi.fn(async () => undefined) + this.subscriptions.push({ dir, hooks, unsubscribe }) + return { unsubscribe } + } +} + +describe('RuntimeWatcherProcessPool', () => { + let supervisors: FakeSupervisor[] + let pool: RuntimeWatcherProcessPool + + beforeEach(() => { + supervisors = [] + pool = new RuntimeWatcherProcessPool({ + maxSharedSupervisors: 4, + createSupervisor: () => { + const supervisor = new FakeSupervisor() + supervisors.push(supervisor) + return supervisor + } + }) + }) + + it('spreads roots across a bounded healthy pool before sharing children', async () => { + for (const dir of ['/a', '/b', '/c', '/d', '/e']) { + await pool.subscribe(dir, vi.fn(), {}, {}) + } + + expect(supervisors).toHaveLength(4) + expect(supervisors.map((supervisor) => supervisor.subscriptions.map(({ dir }) => dir))).toEqual( + [['/a', '/e'], ['/b'], ['/c'], ['/d']] + ) + }) + + it('keeps a newer same-root lease assigned while an older lease tears down', async () => { + const first = await pool.subscribe('/same', vi.fn(), {}, {}) + await pool.subscribe('/same', vi.fn(), {}, {}) + + await first.unsubscribe() + await pool.subscribe('/same', vi.fn(), {}, {}) + + expect(supervisors).toHaveLength(1) + expect(supervisors[0].subscriptions.map(({ dir }) => dir)).toEqual(['/same', '/same', '/same']) + }) + + it('isolates every root from a failed shard when callers recover', async () => { + pool = new RuntimeWatcherProcessPool({ + maxSharedSupervisors: 1, + createSupervisor: () => { + const supervisor = new FakeSupervisor() + supervisors.push(supervisor) + return supervisor + } + }) + const firstError = vi.fn() + const secondError = vi.fn() + await pool.subscribe('/a', vi.fn(), {}, { onTerminalError: firstError }) + await pool.subscribe('/b', vi.fn(), {}, { onTerminalError: secondError }) + const failedShard = supervisors[0] + const failure = new WatcherProcessFailure( + 'file watcher process crashed repeatedly', + 'supervisor', + 'supervisor_crash_fuse' + ) + + for (const installed of failedShard.subscriptions) { + installed.hooks.onTerminalError?.(failure) + } + await pool.subscribe('/a', vi.fn(), {}, {}) + await pool.subscribe('/b', vi.fn(), {}, {}) + + expect(firstError).toHaveBeenCalledWith(failure) + expect(secondError).toHaveBeenCalledWith(failure) + expect(supervisors).toHaveLength(3) + expect(supervisors[1].subscriptions.map(({ dir }) => dir)).toEqual(['/a']) + expect(supervisors[2].subscriptions.map(({ dir }) => dir)).toEqual(['/b']) + }) + + it('bounds quarantine supervisors when a fused shard contains many roots', async () => { + pool = new RuntimeWatcherProcessPool({ + maxSharedSupervisors: 1, + createSupervisor: () => { + const supervisor = new FakeSupervisor() + supervisors.push(supervisor) + return supervisor + } + }) + const roots = Array.from({ length: 12 }, (_, index) => `/root-${index}`) + for (const root of roots) { + await pool.subscribe(root, vi.fn(), {}, {}) + } + const failedShard = supervisors[0] + const failure = new WatcherProcessFailure( + 'file watcher process crashed repeatedly', + 'supervisor', + 'supervisor_crash_fuse' + ) + + for (const installed of failedShard.subscriptions) { + installed.hooks.onTerminalError?.(failure) + } + for (const root of roots) { + await pool.subscribe(root, vi.fn(), {}, {}) + } + + expect(supervisors).toHaveLength(5) + expect(supervisors.slice(1).map(({ subscriptions }) => subscriptions.length)).toEqual([ + 3, 3, 3, 3 + ]) + }) + + it('moves an explicitly timed-out root into quarantine for its retry', async () => { + const timeout = new WatcherProcessFailure( + 'file watcher subscription timed out', + 'subscription', + 'subscribe_timeout' + ) + pool = new RuntimeWatcherProcessPool({ + maxSharedSupervisors: 1, + createSupervisor: () => { + const supervisor = new FakeSupervisor() + if (supervisors.length === 0) { + supervisor.subscribeError = timeout + } + supervisors.push(supervisor) + return supervisor + } + }) + + await expect(pool.subscribe('/slow', vi.fn(), {}, {})).rejects.toBe(timeout) + await expect(pool.subscribe('/slow', vi.fn(), {}, {})).resolves.toBeDefined() + + expect(supervisors).toHaveLength(2) + expect(supervisors[1].subscriptions.map(({ dir }) => dir)).toEqual(['/slow']) + }) + + it('allows only one quarantine generation when isolated setup also times out', async () => { + const timeout = new WatcherProcessFailure( + 'file watcher subscription timed out', + 'subscription', + 'subscribe_timeout' + ) + pool = new RuntimeWatcherProcessPool({ + maxSharedSupervisors: 1, + createSupervisor: () => { + const supervisor = new FakeSupervisor() + supervisor.subscribeError = timeout + supervisors.push(supervisor) + return supervisor + } + }) + + await expect(pool.subscribe('/slow', vi.fn(), {}, {})).rejects.toBe(timeout) + await expect(pool.subscribe('/slow', vi.fn(), {}, {})).rejects.toBe(timeout) + await expect(pool.subscribe('/slow', vi.fn(), {}, {})).rejects.toMatchObject({ + code: 'supervisor_crash_fuse' + }) + expect(supervisors).toHaveLength(2) + }) + + it('moves a live root into quarantine when crash resubscription times out', async () => { + const timeout = new WatcherProcessFailure( + 'file watcher resubscription timed out', + 'subscription', + 'subscribe_timeout' + ) + const onTerminalError = vi.fn() + await pool.subscribe('/slow-recovery', vi.fn(), {}, { onTerminalError }) + + supervisors[0].subscriptions[0].hooks.onTerminalError?.(timeout) + await pool.subscribe('/slow-recovery', vi.fn(), {}, {}) + + expect(onTerminalError).toHaveBeenCalledWith(timeout) + expect(supervisors).toHaveLength(2) + expect(supervisors[1].subscriptions.map(({ dir }) => dir)).toEqual(['/slow-recovery']) + }) + + it('does not replace an isolated live root after its resubscription times out', async () => { + pool = new RuntimeWatcherProcessPool({ + maxSharedSupervisors: 1, + createSupervisor: () => { + const supervisor = new FakeSupervisor() + supervisors.push(supervisor) + return supervisor + } + }) + const fused = new WatcherProcessFailure( + 'file watcher process crashed repeatedly', + 'supervisor', + 'supervisor_crash_fuse' + ) + const timeout = new WatcherProcessFailure( + 'file watcher resubscription timed out', + 'subscription', + 'subscribe_timeout' + ) + await pool.subscribe('/slow-recovery', vi.fn(), {}, {}) + supervisors[0].subscriptions[0].hooks.onTerminalError?.(fused) + await pool.subscribe('/slow-recovery', vi.fn(), {}, {}) + + supervisors[1].subscriptions[0].hooks.onTerminalError?.(timeout) + + await expect(pool.subscribe('/slow-recovery', vi.fn(), {}, {})).rejects.toMatchObject({ + code: 'supervisor_crash_fuse' + }) + expect(supervisors).toHaveLength(2) + }) + + it('keeps healthy shard assignments after a root-specific failure', async () => { + pool = new RuntimeWatcherProcessPool({ + maxSharedSupervisors: 1, + createSupervisor: () => { + const supervisor = new FakeSupervisor() + supervisors.push(supervisor) + return supervisor + } + }) + await pool.subscribe('/healthy', vi.fn(), {}, {}) + const onTerminalError = vi.fn() + await pool.subscribe('/gone', vi.fn(), {}, { onTerminalError }) + const shared = supervisors[0] + const failed = shared.subscriptions.find(({ dir }) => dir === '/gone') + const failure = new WatcherProcessFailure( + 'root unavailable', + 'subscription', + 'subscribe_failed' + ) + + failed?.hooks.onTerminalError?.(failure) + await pool.subscribe('/gone', vi.fn(), {}, {}) + + expect(onTerminalError).toHaveBeenCalledWith(failure) + expect(supervisors).toHaveLength(1) + expect(shared.subscriptions.map(({ dir }) => dir)).toEqual(['/healthy', '/gone', '/gone']) + expect(shared.dispose).not.toHaveBeenCalled() + }) + + it('stops replacing a root after its bounded quarantine shard also fuses', async () => { + pool = new RuntimeWatcherProcessPool({ + maxSharedSupervisors: 1, + createSupervisor: () => { + const supervisor = new FakeSupervisor() + supervisors.push(supervisor) + return supervisor + } + }) + const failure = new WatcherProcessFailure( + 'file watcher process crashed repeatedly', + 'supervisor', + 'supervisor_crash_fuse' + ) + await pool.subscribe('/unstable', vi.fn(), {}, {}) + supervisors[0].subscriptions[0].hooks.onTerminalError?.(failure) + await pool.subscribe('/unstable', vi.fn(), {}, {}) + supervisors[1].subscriptions[0].hooks.onTerminalError?.(failure) + + await expect(pool.subscribe('/unstable', vi.fn(), {}, {})).rejects.toThrow( + 'failed again in quarantine' + ) + expect(supervisors).toHaveLength(2) + }) + + it('clears fused quarantine history when forgetRoot runs without an assignment', async () => { + pool = new RuntimeWatcherProcessPool({ + maxSharedSupervisors: 1, + createSupervisor: () => { + const supervisor = new FakeSupervisor() + supervisors.push(supervisor) + return supervisor + } + }) + const failure = new WatcherProcessFailure( + 'file watcher process crashed repeatedly', + 'supervisor', + 'supervisor_crash_fuse' + ) + await pool.subscribe('/unstable', vi.fn(), {}, {}) + supervisors[0].subscriptions[0].hooks.onTerminalError?.(failure) + await pool.subscribe('/unstable', vi.fn(), {}, {}) + supervisors[1].subscriptions[0].hooks.onTerminalError?.(failure) + + await expect(pool.subscribe('/unstable', vi.fn(), {}, {})).rejects.toThrow( + 'failed again in quarantine' + ) + + pool.forgetRoot('/unstable') + + await expect(pool.subscribe('/unstable', vi.fn(), {}, {})).resolves.toBeDefined() + expect(supervisors).toHaveLength(3) + }) + + it('disposes every supervisor and clears isolation on reset', async () => { + await pool.subscribe('/a', vi.fn(), {}, {}) + await pool.subscribe('/b', vi.fn(), {}, {}) + + pool.resetForTest() + + expect(supervisors.every((supervisor) => supervisor.dispose.mock.calls.length === 1)).toBe(true) + }) +}) diff --git a/src/main/ipc/runtime-watcher-process-pool.ts b/src/main/ipc/runtime-watcher-process-pool.ts new file mode 100644 index 00000000000..b4e46cb3d2c --- /dev/null +++ b/src/main/ipc/runtime-watcher-process-pool.ts @@ -0,0 +1,263 @@ +import { isWatcherProcessFailure, WatcherProcessFailure } from './parcel-watcher-process-failure' +import { WatcherProcessSupervisor } from './parcel-watcher-process-supervisor' +import type { WatcherProcessSubscribeOptions } from './parcel-watcher-process-protocol' +import type { + WatcherProcessCallback, + WatcherProcessHooks, + WatcherProcessSubscription +} from './parcel-watcher-process-subscription' + +const DEFAULT_MAX_SHARED_SUPERVISORS = 4 +const DEFAULT_MAX_QUARANTINE_SUPERVISORS = 4 + +type WatcherSupervisor = Pick + +type RuntimeWatcherPoolSlot = { + supervisor: WatcherSupervisor + roots: Set + isolated: boolean + retired: boolean + disposed: boolean +} + +type RuntimeWatcherPoolAssignment = { + slot: RuntimeWatcherPoolSlot + leases: number +} + +export type RuntimeWatcherProcessPoolOptions = { + maxSharedSupervisors?: number + maxQuarantineSupervisors?: number + createSupervisor?: () => WatcherSupervisor +} + +/** + * Share a bounded set of watcher children while healthy, then move roots from + * a failed shard into a separate bounded quarantine pool. + */ +export class RuntimeWatcherProcessPool { + private readonly maxSharedSupervisors: number + private readonly maxQuarantineSupervisors: number + private readonly createSupervisor: () => WatcherSupervisor + private readonly activeSlots = new Set() + private readonly allSlots = new Set() + private readonly assignments = new Map() + private readonly isolatedRoots = new Set() + private readonly failedQuarantineRoots = new Set() + + constructor(options: RuntimeWatcherProcessPoolOptions = {}) { + this.maxSharedSupervisors = Math.max( + 1, + options.maxSharedSupervisors ?? DEFAULT_MAX_SHARED_SUPERVISORS + ) + this.maxQuarantineSupervisors = Math.max( + 1, + options.maxQuarantineSupervisors ?? DEFAULT_MAX_QUARANTINE_SUPERVISORS + ) + this.createSupervisor = options.createSupervisor ?? (() => new WatcherProcessSupervisor()) + } + + async subscribe( + dir: string, + callback: WatcherProcessCallback, + opts: WatcherProcessSubscribeOptions, + hooks: WatcherProcessHooks = {} + ): Promise { + const assignment = this.assignmentForRoot(dir) + const { slot } = assignment + let assignmentReleased = false + const releaseAssignment = (): void => { + if (assignmentReleased) { + return + } + assignmentReleased = true + this.releaseRoot(assignment, dir) + } + const onTerminalError = (error: Error): void => { + if (isWatcherProcessFailure(error) && error.scope === 'supervisor') { + this.retireSlot(slot) + } else { + this.releaseFailedRoot(assignment, dir, error, releaseAssignment) + } + hooks.onTerminalError?.(error) + } + let subscription: WatcherProcessSubscription + try { + subscription = await slot.supervisor.subscribe(dir, callback, opts, { + ...hooks, + onTerminalError + }) + } catch (error) { + if (isWatcherProcessFailure(error) && error.scope === 'supervisor') { + this.retireSlot(slot) + } else { + this.releaseFailedRoot(assignment, dir, error, releaseAssignment) + } + throw error + } + + let unsubscribePromise: Promise | undefined + return { + unsubscribe: (): Promise => { + unsubscribePromise ??= subscription.unsubscribe().finally(() => { + releaseAssignment() + }) + return unsubscribePromise + } + } + } + + /** Kill every pooled watcher child (production shutdown or test reset). */ + dispose(): void { + // disposeSlot deletes the current slot from allSlots; deleting the + // in-progress element during Set iteration is safe, so no snapshot needed. + for (const slot of this.allSlots) { + this.disposeSlot(slot) + } + this.activeSlots.clear() + this.allSlots.clear() + this.assignments.clear() + this.isolatedRoots.clear() + this.failedQuarantineRoots.clear() + } + + resetForTest(): void { + this.dispose() + } + + forgetRoot(dir: string): void { + // Physical subscriptions release their assignment through unsubscribe or + // terminal callbacks. This clears fault history after setup gives up. + if (!this.assignments.has(dir)) { + this.isolatedRoots.delete(dir) + this.failedQuarantineRoots.delete(dir) + } + } + + private assignmentForRoot(dir: string): RuntimeWatcherPoolAssignment { + if (this.failedQuarantineRoots.has(dir)) { + throw new WatcherProcessFailure( + 'file watcher process failed again in quarantine', + 'supervisor', + 'supervisor_crash_fuse' + ) + } + const assigned = this.assignments.get(dir) + if (assigned && !assigned.slot.retired) { + assigned.leases++ + return assigned + } + const slot = this.isolatedRoots.has(dir) ? this.quarantineSlot() : this.sharedSlot() + const assignment = { slot, leases: 1 } + slot.roots.add(dir) + this.assignments.set(dir, assignment) + return assignment + } + + private sharedSlot(): RuntimeWatcherPoolSlot { + const sharedSlots = Array.from(this.activeSlots).filter( + (slot) => !slot.isolated && !slot.retired + ) + if (sharedSlots.length < this.maxSharedSupervisors) { + return this.createSlot(false) + } + return sharedSlots.reduce((leastLoaded, candidate) => + candidate.roots.size < leastLoaded.roots.size ? candidate : leastLoaded + ) + } + + private quarantineSlot(): RuntimeWatcherPoolSlot { + const quarantineSlots = Array.from(this.activeSlots).filter( + (slot) => slot.isolated && !slot.retired + ) + if (quarantineSlots.length < this.maxQuarantineSupervisors) { + return this.createSlot(true) + } + return quarantineSlots.reduce((leastLoaded, candidate) => + candidate.roots.size < leastLoaded.roots.size ? candidate : leastLoaded + ) + } + + private createSlot(isolated: boolean): RuntimeWatcherPoolSlot { + const slot: RuntimeWatcherPoolSlot = { + supervisor: this.createSupervisor(), + roots: new Set(), + isolated, + retired: false, + disposed: false + } + this.activeSlots.add(slot) + this.allSlots.add(slot) + return slot + } + + private retireSlot(slot: RuntimeWatcherPoolSlot): void { + if (slot.retired) { + return + } + slot.retired = true + this.activeSlots.delete(slot) + for (const root of slot.roots) { + if (this.assignments.get(root)?.slot === slot) { + this.assignments.delete(root) + } + this.quarantineOrFuse(root, slot.isolated) + } + slot.roots.clear() + // Why: failAllSubscriptions is still iterating callbacks; defer disposal + // so every logical root receives the supervisor failure first. + queueMicrotask(() => this.disposeSlot(slot)) + } + + private releaseRoot(assignment: RuntimeWatcherPoolAssignment, dir: string): void { + if (this.assignments.get(dir) !== assignment) { + return + } + assignment.leases-- + if (assignment.leases > 0) { + return + } + const { slot } = assignment + this.assignments.delete(dir) + slot.roots.delete(dir) + this.isolatedRoots.delete(dir) + if (slot.isolated && slot.roots.size === 0 && !slot.retired) { + this.activeSlots.delete(slot) + this.disposeSlot(slot) + } + } + + private releaseFailedRoot( + assignment: RuntimeWatcherPoolAssignment, + dir: string, + error: unknown, + releaseAssignment: () => void + ): void { + releaseAssignment() + if (!isWatcherProcessFailure(error) || error.code !== 'subscribe_timeout') { + return + } + this.quarantineOrFuse(dir, assignment.slot.isolated) + } + + // Why: one bounded quarantine attempt is the recovery budget per watch + // lifetime; an already-isolated root that fails again is fused, not re-isolated, + // so it cannot spawn another child generation. + private quarantineOrFuse(dir: string, isolated: boolean): void { + if (isolated) { + this.isolatedRoots.delete(dir) + this.failedQuarantineRoots.add(dir) + return + } + this.isolatedRoots.add(dir) + } + + private disposeSlot(slot: RuntimeWatcherPoolSlot): void { + if (slot.disposed) { + return + } + slot.disposed = true + slot.supervisor.dispose() + this.allSlots.delete(slot) + } +} diff --git a/src/main/ipc/settings.test.ts b/src/main/ipc/settings.test.ts index 7af303c72a3..08b0abbe98d 100644 --- a/src/main/ipc/settings.test.ts +++ b/src/main/ipc/settings.test.ts @@ -108,6 +108,27 @@ describe('registerSettingsHandlers', () => { expect(event.returnValue).toEqual({ terminalMainSideEffectAuthority: false }) }) + it('applies bot-author deltas against the authoritative settings snapshot', () => { + store.getSettings + .mockReturnValueOnce({ prBotAuthorOverrides: ['alice'] }) + .mockReturnValueOnce({ prBotAuthorOverrides: ['alice', 'bob'] }) + registerSettingsHandlers(store as never) + const handler = handleMock.mock.calls.find( + (call) => call[0] === 'settings:update-pr-bot-author-override' + )?.[1] as ( + event: typeof settingsInvokeEvent, + args: { author: string; isBot: boolean } + ) => unknown + + const result = handler(settingsInvokeEvent, { author: ' Bob ', isBot: true }) + + expect(store.updateSettings).toHaveBeenCalledWith( + { prBotAuthorOverrides: ['alice', 'bob'] }, + { notifyListeners: true, originWebContentsId: 1 } + ) + expect(result).toEqual({ prBotAuthorOverrides: ['alice', 'bob'] }) + }) + it('registers settings:previewWarpThemeImport handler', () => { registerSettingsHandlers(store as never) const channels = handleMock.mock.calls.map((call) => call[0]) diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index b59bf6add2e..1ba9a015b66 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -21,6 +21,7 @@ import { normalizeDesktopTerminalScrollbackRows } from '../../shared/terminal-sc import { normalizeTerminalLineHeight } from '../../shared/terminal-line-height-settings' import { prepareLocalWorktreeRootsForRepos } from '../worktree-root-preparation' import { scheduleCurrentWorktreeBaseDirectoryWatcherSync } from './worktree-base-directory-watcher' +import { applyPRBotAuthorOverride } from '../../shared/pr-bot-author-overrides' // Why: the whitelist is the source-of-truth for which keys we emit on. Casting // to a Set once at module load lets the IPC handler's per-key membership @@ -67,6 +68,19 @@ export function registerSettingsHandlers( return store.getSettings() }) + ipcMain.handle( + 'settings:update-pr-bot-author-override', + (event, args: { author: string; isBot: boolean }) => { + const current = store.getSettings().prBotAuthorOverrides + const next = applyPRBotAuthorOverride(current, args.author, args.isBot) + store.updateSettings( + { prBotAuthorOverrides: next }, + { notifyListeners: true, originWebContentsId: event.sender.id } + ) + return store.getSettings() + } + ) + // Why: terminal panes can bind PTYs before async settings hydration // completes. The side-effect authority kill switch is consulted once at // transport creation, so the renderer needs the persisted value diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index 51801ecdef5..0a9393c83ea 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -18,7 +18,8 @@ const { mockPortForwardManager, mockPortScannerCallbacks, mockNextConnectionManagers, - mockNextPortForwardManagers + mockNextPortForwardManagers, + shutdownPtyWithRetainedOwnershipMock } = vi.hoisted(() => ({ handleMock: vi.fn(), powerMonitorOffMock: vi.fn(), @@ -81,7 +82,8 @@ const { }, mockPortScannerCallbacks: new Map(), mockNextConnectionManagers: [] as unknown[], - mockNextPortForwardManagers: [] as unknown[] + mockNextPortForwardManagers: [] as unknown[], + shutdownPtyWithRetainedOwnershipMock: vi.fn() })) vi.mock('electron', () => ({ @@ -157,6 +159,7 @@ vi.mock('../providers/ssh-filesystem-provider', () => ({ vi.mock('./pty', () => ({ registerSshPtyProvider: vi.fn(), unregisterSshPtyProvider: vi.fn(), + releasePendingSshShutdownsForTarget: vi.fn(), clearPtyOwnershipForConnection: vi.fn(), clearProviderPtyState: vi.fn(), deletePtyOwnership: vi.fn(), @@ -164,7 +167,9 @@ vi.mock('./pty', () => ({ answerStartupTerminalColorQueriesForPty: vi.fn((_id: string, data: string) => data), getSshPtyProvider: vi.fn(), getPtyIdsForConnection: vi.fn().mockReturnValue([]), - isRendererPtyOutputPaused: vi.fn().mockReturnValue(false) + hasPendingSshShutdown: vi.fn().mockReturnValue(false), + isRendererPtyOutputPaused: vi.fn().mockReturnValue(false), + shutdownPtyWithRetainedOwnership: shutdownPtyWithRetainedOwnershipMock })) vi.mock('../providers/ssh-filesystem-dispatch', () => ({ @@ -219,7 +224,9 @@ import { clearProviderPtyState, deletePtyOwnership, getSshPtyProvider, - getPtyIdsForConnection + getPtyIdsForConnection, + releasePendingSshShutdownsForTarget, + shutdownPtyWithRetainedOwnership } from './pty' describe('SSH IPC handlers', () => { @@ -342,8 +349,19 @@ describe('SSH IPC handlers', () => { powerMonitorOffMock.mockReset() vi.mocked(getSshPtyProvider).mockReset() vi.mocked(getPtyIdsForConnection).mockReset().mockReturnValue([]) + vi.mocked(releasePendingSshShutdownsForTarget).mockReset() vi.mocked(clearProviderPtyState).mockReset() vi.mocked(deletePtyOwnership).mockReset() + vi.mocked(shutdownPtyWithRetainedOwnership) + .mockReset() + .mockImplementation(async (args) => { + await args.provider.shutdown(args.id, args.options) + vi.mocked(clearProviderPtyState)(args.id) + vi.mocked(deletePtyOwnership)(args.id) + if (args.connectionId) { + mockStore.markSshRemotePtyLease(args.connectionId, args.id, 'terminated') + } + }) registerSshHandlers(mockStore as never, () => mockWindow as never) }) @@ -437,7 +455,9 @@ describe('SSH IPC handlers', () => { username: 'deploy' } mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) + const connection = {} + mockConnectionManager.connect.mockResolvedValue(connection) + mockConnectionManager.getConnection.mockReturnValue(connection) mockConnectionManager.getState.mockReturnValue({ targetId: 'ssh-1', status: 'connected', @@ -454,10 +474,48 @@ describe('SSH IPC handlers', () => { expect(mockMux.dispose).toHaveBeenCalledWith('shutdown') expect(mockStore.markSshRemotePtyLeases).toHaveBeenCalledWith('ssh-1', 'terminated') expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') + expect(mockForceStopRelayForTarget).toHaveBeenCalledWith(connection, 'ssh-1') expect(mockStore.removeSshRemotePtyLeases).toHaveBeenCalledWith('ssh-1') expect(mockSshStore.removeTarget).toHaveBeenCalledWith('ssh-1') }) + it('ssh:removeTarget preserves target and relay ownership when PTY exit proof fails', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + const connection = {} + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue(connection) + mockConnectionManager.getConnection.mockReturnValue(connection) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + mockStore.getSshRemotePtyLeases.mockReturnValue([ + { targetId: 'ssh-1', ptyId: 'pty-1', state: 'detached' } + ]) + vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) + vi.mocked(shutdownPtyWithRetainedOwnership).mockRejectedValueOnce( + new Error('PTY shutdown still pending') + ) + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + + await expect(handlers.get('ssh:removeTarget')!(null, { id: 'ssh-1' })).rejects.toThrow( + 'Failed to terminate SSH host sessions' + ) + + expect(mockForceStopRelayForTarget).not.toHaveBeenCalled() + expect(mockMux.dispose).not.toHaveBeenCalledWith('shutdown') + expect(mockStore.removeSshRemotePtyLeases).not.toHaveBeenCalled() + expect(mockSshStore.removeTarget).not.toHaveBeenCalled() + }) + it('ssh:importConfig returns imported targets', async () => { const imported: SshTarget[] = [ { id: 'ssh-imp', label: 'staging', host: 'staging.com', port: 22, username: '' } @@ -1196,8 +1254,16 @@ describe('SSH IPC handlers', () => { error: null, reconnectAttempt: 0 }) + const leaseLeafId = '11111111-1111-4111-8111-111111111111' mockStore.getSshRemotePtyLeases.mockReturnValue([ - { targetId: 'ssh-1', ptyId: 'pty-lease', state: 'detached' } + { + targetId: 'ssh-1', + ptyId: 'pty-lease', + relayInstanceId: 'boot-a', + tabId: 'tab-lease', + leafId: leaseLeafId, + state: 'detached' + } ]) vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) vi.mocked(getPtyIdsForConnection).mockReturnValue(['ssh:ssh-1@@pty-live']) @@ -1210,16 +1276,26 @@ describe('SSH IPC handlers', () => { immediate: true, keepHistory: false }) - expect(mockPtyProvider.shutdown).toHaveBeenCalledWith('ssh:ssh-1@@pty-lease', { + expect(mockPtyProvider.shutdown).toHaveBeenCalledWith('ssh:ssh-1@@boot-a@@pty-lease', { immediate: true, - keepHistory: false + keepHistory: false, + expectedPaneKey: `tab-lease:${leaseLeafId}`, + expectedTabId: 'tab-lease' }) expect(clearProviderPtyState).toHaveBeenCalledWith('ssh:ssh-1@@pty-live') - expect(clearProviderPtyState).toHaveBeenCalledWith('ssh:ssh-1@@pty-lease') + expect(clearProviderPtyState).toHaveBeenCalledWith('ssh:ssh-1@@boot-a@@pty-lease') expect(deletePtyOwnership).toHaveBeenCalledWith('ssh:ssh-1@@pty-live') - expect(deletePtyOwnership).toHaveBeenCalledWith('ssh:ssh-1@@pty-lease') - expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'pty-live', 'terminated') - expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'pty-lease', 'terminated') + expect(deletePtyOwnership).toHaveBeenCalledWith('ssh:ssh-1@@boot-a@@pty-lease') + expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith( + 'ssh-1', + 'ssh:ssh-1@@pty-live', + 'terminated' + ) + expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith( + 'ssh-1', + 'ssh:ssh-1@@boot-a@@pty-lease', + 'terminated' + ) }) it('ssh:terminateSessions ignores expired leases when disconnected', async () => { @@ -1259,16 +1335,50 @@ describe('SSH IPC handlers', () => { expect(mockConnectionManager.connect).toHaveBeenCalledWith(target) expect(mockForceStopRelayForTarget).toHaveBeenCalledWith(conn, 'ssh-1') - expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'pty-1', 'expired') + expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith( + 'ssh-1', + 'ssh:ssh-1@@pty-1', + 'expired' + ) expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( 'ssh-1', 'pty-expired', 'expired' ) + expect(releasePendingSshShutdownsForTarget).toHaveBeenCalledWith('ssh-1') expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') }) - it('ssh:resetRelay clears scoped live PTYs while expiring raw leases', async () => { + it('ssh:resetRelay preserves retained shutdown owners when relay stop is unconfirmed', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + const conn = {} + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue(conn) + mockConnectionManager.getConnection.mockReturnValue(undefined) + mockForceStopRelayForTarget.mockRejectedValueOnce(new Error('relay stop unconfirmed')) + mockStore.getSshRemotePtyLeases.mockReturnValue([ + { targetId: 'ssh-1', ptyId: 'pty-lease', relayInstanceId: 'boot-a', state: 'detached' } + ]) + vi.mocked(getPtyIdsForConnection).mockReturnValue(['ssh:ssh-1@@boot-a@@pty-live']) + + await expect(handlers.get('ssh:resetRelay')!(null, { targetId: 'ssh-1' })).rejects.toThrow( + 'relay stop unconfirmed' + ) + + expect(releasePendingSshShutdownsForTarget).not.toHaveBeenCalled() + expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalled() + expect(clearProviderPtyState).not.toHaveBeenCalled() + expect(deletePtyOwnership).not.toHaveBeenCalled() + expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') + }) + + it('ssh:resetRelay clears scoped live PTYs and exact generation-qualified leases', async () => { const target: SshTarget = { id: 'ssh-1', label: 'Server', @@ -1281,17 +1391,21 @@ describe('SSH IPC handlers', () => { mockConnectionManager.connect.mockResolvedValue(conn) mockConnectionManager.getConnection.mockReturnValue(undefined) mockStore.getSshRemotePtyLeases.mockReturnValue([ - { targetId: 'ssh-1', ptyId: 'pty-lease', state: 'detached' } + { targetId: 'ssh-1', ptyId: 'pty-lease', relayInstanceId: 'boot-a', state: 'detached' } ]) vi.mocked(getPtyIdsForConnection).mockReturnValue(['ssh:ssh-1@@pty-live']) await handlers.get('ssh:resetRelay')!(null, { targetId: 'ssh-1' }) - expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'pty-lease', 'expired') + expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith( + 'ssh-1', + 'ssh:ssh-1@@boot-a@@pty-lease', + 'expired' + ) expect(clearProviderPtyState).toHaveBeenCalledWith('ssh:ssh-1@@pty-live') - expect(clearProviderPtyState).toHaveBeenCalledWith('ssh:ssh-1@@pty-lease') + expect(clearProviderPtyState).toHaveBeenCalledWith('ssh:ssh-1@@boot-a@@pty-lease') expect(deletePtyOwnership).toHaveBeenCalledWith('ssh:ssh-1@@pty-live') - expect(deletePtyOwnership).toHaveBeenCalledWith('ssh:ssh-1@@pty-lease') + expect(deletePtyOwnership).toHaveBeenCalledWith('ssh:ssh-1@@boot-a@@pty-lease') expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') }) @@ -1594,7 +1708,11 @@ describe('SSH IPC handlers', () => { expect(mockStore.markSshRemotePtyLeases).not.toHaveBeenCalledWith('ssh-1', 'terminated') expect(mockStore.markSshRemotePtyLeases).toHaveBeenCalledWith('ssh-1', 'detached') - expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'pty-1', 'expired') + expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith( + 'ssh-1', + 'ssh:ssh-1@@pty-1', + 'expired' + ) expect(mockForceStopRelayForTarget).toHaveBeenCalledWith(conn, 'ssh-1') }) diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index affb1aba004..f72e9f0d6c3 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -14,13 +14,13 @@ import type { SshRepoReadoption, SshTarget, SshConnectionStatus, - SshConnectionState + SshConnectionState, + SshRemotePtyLease } from '../../shared/ssh-types' import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants' import { isRuntimeOwnedSshTargetId } from '../../shared/execution-host' import { isAuthError } from '../ssh/ssh-connection-utils' import { forceStopRelayForTarget } from '../ssh/ssh-relay-reset' -import { isSshPtyNotFoundError } from '../providers/ssh-pty-provider' import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' import { registerSshBrowseHandler } from './ssh-browse' import { @@ -35,9 +35,12 @@ import { clearProviderPtyState, deletePtyOwnership, getPtyIdsForConnection, - getSshPtyProvider + getSshPtyProvider, + releasePendingSshShutdownsForTarget, + shutdownPtyWithRetainedOwnership } from './pty' import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import { isTerminalLeafId, makePaneKey } from '../../shared/stable-pane-id' let sshStore: SshConnectionStore | null = null let connectionManager: SshConnectionManager | null = null @@ -113,10 +116,22 @@ export async function removeRegisteredSshTarget(targetId: string): Promise if (!sshStore) { return } - // Why: removing a target is destructive — dispose() (not detach()) so the - // relay shuts down and remote PTY leases are terminated rather than preserved - // for a reattach to a target that will no longer exist. + await terminateRegisteredSshTargetPtys(targetId) + const session = activeSessions.get(targetId) + const connection = connectionManager?.getConnection(targetId) + if (session && !connection) { + throw new Error( + `${SSH_TERMINATE_RECONNECT_REQUIRED}: SSH relay is not connected; reconnect before removing this target.` + ) + } + // Why: dispose the local mux with an intentional shutdown reason before the + // remote kill, so its close cannot enter the relay-lost reconnect loop. await disposeActiveSshSession(targetId) + if (connection) { + // Why: relay grace 0 means “until reset.” After exit-proofing every PTY, + // stop that detached process before deleting the only target metadata. + await forceStopRelayForTarget(connection, targetId) + } try { await connectionManager?.disconnect(targetId) } catch (err) { @@ -127,9 +142,102 @@ export async function removeRegisteredSshTarget(targetId: string): Promise ) } persistedStore?.removeSshRemotePtyLeases(targetId) + releasePendingSshShutdownsForTarget(targetId) sshStore.removeTarget(targetId) } +type RegisteredSshTargetPty = { + relayPtyId: string + appPtyId: string + options: { + immediate: true + keepHistory: false + expectedPaneKey?: string + expectedTabId?: string + } +} + +function getRegisteredSshTargetPtyIds(targetId: string): RegisteredSshTargetPty[] { + const ptysByAppId = new Map() + for (const ptyId of getPtyIdsForConnection(targetId)) { + const relayPtyId = toRelaySshPtyId(targetId, ptyId) + const appPtyId = toAppSshPtyId(targetId, ptyId) + ptysByAppId.set(appPtyId, { + relayPtyId, + appPtyId, + options: { immediate: true, keepHistory: false } + }) + } + for (const lease of persistedStore?.getSshRemotePtyLeases(targetId) ?? []) { + if (lease.state === 'terminated' || lease.state === 'expired') { + continue + } + const relayPtyId = toRelaySshPtyId(targetId, lease.ptyId) + const appPtyId = toAppSshPtyId(targetId, lease.ptyId, lease.relayInstanceId) + const options = sshLeaseShutdownOptions(lease) + ptysByAppId.set(appPtyId, { + relayPtyId, + appPtyId, + options: { ...ptysByAppId.get(appPtyId)?.options, ...options } + }) + } + return [...ptysByAppId.values()] +} + +function sshLeaseShutdownOptions(lease: SshRemotePtyLease): { + immediate: true + keepHistory: false + expectedPaneKey?: string + expectedTabId?: string +} { + const expectedTabId = lease.tabId && !lease.tabId.includes(':') ? lease.tabId : undefined + const expectedPaneKey = + expectedTabId && lease.leafId && isTerminalLeafId(lease.leafId) + ? makePaneKey(expectedTabId, lease.leafId) + : undefined + // Why: persisted identity must survive bulk teardown so a delayed retry + // cannot cross into a replacement pane that reused the same relay id. + return { + immediate: true, + keepHistory: false, + ...(expectedPaneKey ? { expectedPaneKey } : {}), + ...(expectedTabId ? { expectedTabId } : {}) + } +} + +async function terminateRegisteredSshTargetPtys(targetId: string): Promise { + const provider = getSshPtyProvider(targetId) + const ptyIds = getRegisteredSshTargetPtyIds(targetId) + if (ptyIds.length > 0 && !provider) { + throw new Error( + `${SSH_TERMINATE_RECONNECT_REQUIRED}: SSH relay is not connected; reconnect before terminating remote sessions.` + ) + } + if (!provider) { + return + } + const results = await Promise.allSettled( + ptyIds.map(({ appPtyId, options }) => + shutdownPtyWithRetainedOwnership({ + id: appPtyId, + connectionId: targetId, + provider, + options + }) + ) + ) + const failures = results.flatMap((result, index) => + result.status === 'rejected' + ? [ + `${ptyIds[index].relayPtyId}: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}` + ] + : [] + ) + if (failures.length > 0) { + throw new Error(`Failed to terminate SSH host sessions: ${failures.join('; ')}`) + } +} + // Why: one session per SSH target encapsulates the entire relay lifecycle // (multiplexer, providers, abort controller, state machine). Eliminates the // scattered Maps/Sets that previously tracked this state independently. @@ -957,58 +1065,7 @@ export function registerSshHandlers( ipcMain.handle('ssh:terminateSessions', async (_event, args: { targetId: string }) => { const session = activeSessions.get(args.targetId) - const provider = getSshPtyProvider(args.targetId) - const leasedIds = persistedStore! - .getSshRemotePtyLeases(args.targetId) - .filter((lease) => lease.state !== 'terminated' && lease.state !== 'expired') - .map((lease) => lease.ptyId) - const ptyIdsByRelayId = new Map() - for (const ptyId of getPtyIdsForConnection(args.targetId)) { - const relayPtyId = toRelaySshPtyId(args.targetId, ptyId) - ptyIdsByRelayId.set(relayPtyId, toAppSshPtyId(args.targetId, ptyId)) - } - for (const ptyId of leasedIds) { - const relayPtyId = toRelaySshPtyId(args.targetId, ptyId) - ptyIdsByRelayId.set( - relayPtyId, - ptyIdsByRelayId.get(relayPtyId) ?? toAppSshPtyId(args.targetId, ptyId) - ) - } - const ptyIds = Array.from(ptyIdsByRelayId, ([relayPtyId, appPtyId]) => ({ - relayPtyId, - appPtyId - })) - - if (ptyIds.length > 0 && !provider) { - throw new Error( - `${SSH_TERMINATE_RECONNECT_REQUIRED}: SSH relay is not connected; reconnect before terminating remote sessions.` - ) - } - const shutdownResults = provider - ? await Promise.allSettled( - ptyIds.map(({ appPtyId }) => - provider.shutdown(appPtyId, { immediate: true, keepHistory: false }) - ) - ) - : [] - const shutdownFailures: string[] = [] - for (const [index, result] of shutdownResults.entries()) { - const { appPtyId, relayPtyId } = ptyIds[index] - if (result.status !== 'fulfilled' && !isSshPtyNotFoundError(result.reason)) { - shutdownFailures.push( - `${relayPtyId}: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}` - ) - continue - } - clearProviderPtyState(appPtyId) - deletePtyOwnership(appPtyId) - persistedStore!.markSshRemotePtyLease(args.targetId, relayPtyId, 'terminated') - } - if (shutdownFailures.length > 0) { - // Why: a failed relay shutdown can leave the remote process alive in the - // grace window. Keep the lease/session intact so the user can retry. - throw new Error(`Failed to terminate SSH host sessions: ${shutdownFailures.join('; ')}`) - } + await terminateRegisteredSshTargetPtys(args.targetId) if (session) { await portForwardManager!.removeAllForwards(args.targetId) session.dispose() @@ -1043,23 +1100,30 @@ export function registerSshHandlers( const existingConn = connectionManager!.getConnection(targetId) const conn = existingConn ?? (await connectionManager!.connect(target)) + let relayStopConfirmed = false try { await forceStopRelayForTarget(conn, targetId) + relayStopConfirmed = true } finally { - const ptyIds = new Set(getPtyIdsForConnection(targetId)) - for (const lease of persistedStore!.getSshRemotePtyLeases(targetId)) { - if (lease.state !== 'terminated' && lease.state !== 'expired') { - ptyIds.add(lease.ptyId) - persistedStore!.markSshRemotePtyLease(targetId, lease.ptyId, 'expired') + // Why: a confirmed daemon stop makes every retained raw or generated + // retry unreachable. A pre-accept failure must preserve retry ownership. + if (relayStopConfirmed) { + releasePendingSshShutdownsForTarget(targetId) + const ptyIds = new Set(getPtyIdsForConnection(targetId)) + for (const lease of persistedStore!.getSshRemotePtyLeases(targetId)) { + if (lease.state !== 'terminated' && lease.state !== 'expired') { + const appPtyId = toAppSshPtyId(targetId, lease.ptyId, lease.relayInstanceId) + ptyIds.add(appPtyId) + persistedStore!.markSshRemotePtyLease(targetId, appPtyId, 'expired') + } + } + // Why: confirmed reset force-killed the remote relay daemon, so every + // local PTY handle owned by that relay is stale. + for (const ptyId of ptyIds) { + const appPtyId = toAppSshPtyId(targetId, ptyId) + clearProviderPtyState(appPtyId) + deletePtyOwnership(appPtyId) } - } - // Why: reset force-kills the remote relay daemon, so every local PTY - // handle owned by that relay is stale even if the reset command failed - // after the remote process accepted SIGTERM. - for (const ptyId of ptyIds) { - const appPtyId = toAppSshPtyId(targetId, ptyId) - clearProviderPtyState(appPtyId) - deletePtyOwnership(appPtyId) } // Why: reset's connect() can trip onCredentialRequest, which adds to // credentialRequestedForTarget. Without this delete, a later doConnect diff --git a/src/main/ipc/worktree-base-directory-change-collector.ts b/src/main/ipc/worktree-base-directory-change-collector.ts new file mode 100644 index 00000000000..168b32b48ad --- /dev/null +++ b/src/main/ipc/worktree-base-directory-change-collector.ts @@ -0,0 +1,98 @@ +import type { FsChangeEvent } from '../../shared/types' +import { + classifyWorktreeBaseChange, + type WorktreeBaseWatchTarget +} from './worktree-base-directory-event-filter' + +type WorktreeBaseWatcherEvent = { + type: 'create' | 'update' | 'delete' + path: string +} + +export type WorktreeBaseCollectedChanges = { + overflow: boolean + structureRepoIds: string[] + gitStatusRepoIds: string[] + headIdentityRepoIds: string[] +} + +type ChangeBuckets = { + structureRepoIds: Set + gitStatusRepoIds: Set + headIdentityRepoIds: Set +} + +function emptyBuckets(): ChangeBuckets { + return { + structureRepoIds: new Set(), + gitStatusRepoIds: new Set(), + headIdentityRepoIds: new Set() + } +} + +function emptyChanges(): WorktreeBaseCollectedChanges { + return { + overflow: false, + structureRepoIds: [], + gitStatusRepoIds: [], + headIdentityRepoIds: [] + } +} + +function addMatchingChange( + target: WorktreeBaseWatchTarget, + event: WorktreeBaseWatcherEvent, + buckets: ChangeBuckets +): void { + const change = classifyWorktreeBaseChange(target, event) + for (const repoId of change.structureRepoIds) { + buckets.structureRepoIds.add(repoId) + } + for (const repoId of change.gitStatusRepoIds) { + buckets.gitStatusRepoIds.add(repoId) + } + for (const repoId of change.headIdentityRepoIds) { + buckets.headIdentityRepoIds.add(repoId) + } +} + +function toCollectedChanges(buckets: ChangeBuckets): WorktreeBaseCollectedChanges { + return { + overflow: false, + structureRepoIds: [...buckets.structureRepoIds], + gitStatusRepoIds: [...buckets.gitStatusRepoIds], + headIdentityRepoIds: [...buckets.headIdentityRepoIds] + } +} + +export function collectLocalWorktreeBaseChanges( + target: WorktreeBaseWatchTarget, + events: WorktreeBaseWatcherEvent[] +): WorktreeBaseCollectedChanges { + const buckets = emptyBuckets() + for (const event of events) { + addMatchingChange(target, event, buckets) + } + return toCollectedChanges(buckets) +} + +export function collectRemoteWorktreeBaseChanges( + target: WorktreeBaseWatchTarget, + events: FsChangeEvent[] +): WorktreeBaseCollectedChanges { + const buckets = emptyBuckets() + for (const event of events) { + if (event.kind === 'overflow') { + return { ...emptyChanges(), overflow: true } + } + if (event.kind === 'rename') { + if (event.oldAbsolutePath) { + addMatchingChange(target, { type: 'delete', path: event.oldAbsolutePath }, buckets) + } + addMatchingChange(target, { type: 'create', path: event.absolutePath }, buckets) + continue + } + addMatchingChange(target, { type: event.kind, path: event.absolutePath }, buckets) + } + return toCollectedChanges(buckets) +} diff --git a/src/main/ipc/worktree-base-directory-event-filter.test.ts b/src/main/ipc/worktree-base-directory-event-filter.test.ts index ad66359d965..330be8b925c 100644 --- a/src/main/ipc/worktree-base-directory-event-filter.test.ts +++ b/src/main/ipc/worktree-base-directory-event-filter.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' -import { join } from 'node:path' +import { join, win32 } from 'node:path' import { + classifyWorktreeBaseChange, matchingWorktreeBaseRepoIds, type WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' @@ -17,29 +18,183 @@ function makeGitCommonTarget(): WorktreeBaseWatchTarget { } describe('matchingWorktreeBaseRepoIds (git-common)', () => { - it('matches linked-worktree metadata under worktrees/', () => { + it('classifies linked-worktree structural metadata under worktrees/', () => { const target = makeGitCommonTarget() expect( - matchingWorktreeBaseRepoIds(target, { + classifyWorktreeBaseChange(target, { type: 'update', path: join(COMMON_DIR, 'worktrees', 'wt-a', 'HEAD') }) - ).toEqual(['repo-1']) + ).toEqual({ + structureRepoIds: ['repo-1'], + gitStatusRepoIds: [], + headIdentityRepoIds: [] + }) + expect( + classifyWorktreeBaseChange(target, { + type: 'create', + path: join(COMMON_DIR, 'worktrees', 'wt-b') + }) + ).toEqual({ + structureRepoIds: ['repo-1'], + gitStatusRepoIds: [], + headIdentityRepoIds: [] + }) expect( matchingWorktreeBaseRepoIds(target, { - type: 'create', + type: 'delete', path: join(COMMON_DIR, 'worktrees', 'wt-b') }) ).toEqual(['repo-1']) }) - it('matches primary-checkout branch/index metadata at the common-dir top level', () => { + it('classifies primary-checkout branch metadata as structural and index as status-only', () => { const target = makeGitCommonTarget() - for (const file of ['HEAD', 'packed-refs', 'index']) { + for (const file of ['HEAD', 'packed-refs']) { expect( - matchingWorktreeBaseRepoIds(target, { type: 'update', path: join(COMMON_DIR, file) }) - ).toEqual(['repo-1']) + classifyWorktreeBaseChange(target, { + type: 'update', + path: join(COMMON_DIR, file) + }) + ).toEqual({ + structureRepoIds: ['repo-1'], + gitStatusRepoIds: [], + headIdentityRepoIds: [] + }) } + expect( + classifyWorktreeBaseChange(target, { + type: 'update', + path: join(COMMON_DIR, 'index') + }) + ).toEqual({ + structureRepoIds: [], + gitStatusRepoIds: ['repo-1'], + headIdentityRepoIds: [] + }) + }) + + it('classifies linked-worktree index as status-only', () => { + const target = makeGitCommonTarget() + expect( + classifyWorktreeBaseChange(target, { + type: 'update', + path: join(COMMON_DIR, 'worktrees', 'wt-a', 'index') + }) + ).toEqual({ + structureRepoIds: [], + gitStatusRepoIds: ['repo-1'], + headIdentityRepoIds: [] + }) + expect( + classifyWorktreeBaseChange(target, { + type: 'update', + path: join(COMMON_DIR, 'worktrees', 'wt-a', 'index.lock') + }) + ).toEqual({ + structureRepoIds: [], + gitStatusRepoIds: [], + headIdentityRepoIds: [] + }) + }) + + it('classifies HEAD reflog appends as head-identity triggers for linked and primary checkouts', () => { + const target = makeGitCommonTarget() + // commit --amend / reset --soft move HEAD without touching index or HEAD. + expect( + classifyWorktreeBaseChange(target, { + type: 'update', + path: join(COMMON_DIR, 'worktrees', 'wt-a', 'logs', 'HEAD') + }) + ).toEqual({ + structureRepoIds: [], + gitStatusRepoIds: [], + headIdentityRepoIds: ['repo-1'] + }) + expect( + classifyWorktreeBaseChange(target, { + type: 'update', + path: join(COMMON_DIR, 'logs', 'HEAD') + }) + ).toEqual({ + structureRepoIds: [], + gitStatusRepoIds: [], + headIdentityRepoIds: ['repo-1'] + }) + // Per-ref reflogs churn on fetches and stay ignored. + expect( + classifyWorktreeBaseChange(target, { + type: 'update', + path: join(COMMON_DIR, 'logs', 'refs', 'heads', 'main') + }) + ).toEqual({ + structureRepoIds: [], + gitStatusRepoIds: [], + headIdentityRepoIds: [] + }) + expect( + classifyWorktreeBaseChange(target, { + type: 'update', + path: join(COMMON_DIR, 'worktrees', 'wt-a', 'logs', 'refs', 'heads', 'main') + }) + ).toEqual({ + structureRepoIds: [], + gitStatusRepoIds: [], + headIdentityRepoIds: [] + }) + }) + + it('classifies worktree-scoped config as structural for sparse-flag freshness', () => { + const target = makeGitCommonTarget() + expect( + classifyWorktreeBaseChange(target, { + type: 'update', + path: join(COMMON_DIR, 'worktrees', 'wt-a', 'config.worktree') + }) + ).toEqual({ + structureRepoIds: ['repo-1'], + gitStatusRepoIds: [], + headIdentityRepoIds: [] + }) + expect( + classifyWorktreeBaseChange(target, { + type: 'create', + path: join(COMMON_DIR, 'config.worktree') + }) + ).toEqual({ + structureRepoIds: ['repo-1'], + gitStatusRepoIds: [], + headIdentityRepoIds: [] + }) + }) + + it('classifies Windows-shaped linked metadata paths', () => { + const commonDir = win32.join('C:\\', 'repos', 'project', '.git') + const target: WorktreeBaseWatchTarget = { + ...makeGitCommonTarget(), + key: `git-common:local:${commonDir}`, + path: commonDir + } + expect( + classifyWorktreeBaseChange(target, { + type: 'update', + path: win32.join(commonDir, 'worktrees', 'wt a', 'gitdir') + }) + ).toEqual({ + structureRepoIds: ['repo-1'], + gitStatusRepoIds: [], + headIdentityRepoIds: [] + }) + expect( + classifyWorktreeBaseChange(target, { + type: 'update', + path: win32.join(commonDir, 'worktrees', 'wt a', 'index') + }) + ).toEqual({ + structureRepoIds: [], + gitStatusRepoIds: ['repo-1'], + headIdentityRepoIds: [] + }) }) it('ignores non-status common-dir churn', () => { diff --git a/src/main/ipc/worktree-base-directory-event-filter.ts b/src/main/ipc/worktree-base-directory-event-filter.ts index c12da5d216b..551af5a6568 100644 --- a/src/main/ipc/worktree-base-directory-event-filter.ts +++ b/src/main/ipc/worktree-base-directory-event-filter.ts @@ -8,6 +8,15 @@ type WorktreeBaseWatcherEvent = { path: string } +export type WorktreeBaseChangeClass = { + structureRepoIds: string[] + gitStatusRepoIds: string[] + // logs/HEAD and other true head-move triggers: notify Source Control like + // status churn, but distinct so only these re-read head identities. An index + // rewrite cannot move HEAD, so it must never land here. + headIdentityRepoIds: string[] +} + export type WorktreeBaseWatchKind = 'base' | 'git-common' export type WorktreeBaseRepoWatchConfig = { @@ -85,29 +94,121 @@ function matchingBaseRepoIds( // Why: branch switches and commits in the primary checkout rewrite these // top-level common-dir files; matching them keeps root-checkout branch/status // as fresh as linked worktrees. Deeper churn (objects, refs, logs) is ignored. -const GIT_COMMON_PRIMARY_METADATA_FILES = new Set(['HEAD', 'packed-refs', 'index']) +// `config.worktree` is structural because it is the only file whose write +// flips `git worktree list`'s sparse flag, and no status/commit path touches +// it — so it cannot re-open the index-churn fanout this classifier closes. +const GIT_COMMON_PRIMARY_STRUCTURAL_FILES = new Set(['HEAD', 'packed-refs', 'config.worktree']) +const GIT_COMMON_PRIMARY_STATUS_FILES = new Set(['index']) +const GIT_COMMON_LINKED_STRUCTURAL_FILES = new Set(['HEAD', 'gitdir', 'locked', 'config.worktree']) +const GIT_COMMON_LINKED_STATUS_FILES = new Set(['index']) + +// `logs/HEAD` is the head-identity trigger for head moves that rewrite no +// watched leaf (commit --amend, reset --soft): every ref update through a +// checkout appends there, while `git status` churn never touches it. It is +// kept separate from index churn so only these events re-read head identities. +function isHeadLogParts(parts: string[], offset: number): boolean { + return parts.length === offset + 2 && parts[offset] === 'logs' && parts[offset + 1] === 'HEAD' +} + +function allRepoIds(target: WorktreeBaseWatchTarget): string[] { + return [...target.repos.keys()] +} + +const NO_CHANGE: WorktreeBaseChangeClass = { + structureRepoIds: [], + gitStatusRepoIds: [], + headIdentityRepoIds: [] +} + +function structuralChange(repoIds: string[]): WorktreeBaseChangeClass { + return { + structureRepoIds: repoIds, + gitStatusRepoIds: [], + headIdentityRepoIds: [] + } +} + +function gitStatusChange(repoIds: string[]): WorktreeBaseChangeClass { + return { + structureRepoIds: [], + gitStatusRepoIds: repoIds, + headIdentityRepoIds: [] + } +} + +function headIdentityChange(repoIds: string[]): WorktreeBaseChangeClass { + return { + structureRepoIds: [], + gitStatusRepoIds: [], + headIdentityRepoIds: repoIds + } +} // Why: Git records linked worktrees under the common dir's `worktrees` // metadata, which is lower churn than watching checkout contents. -function matchingGitCommonRepoIds(target: WorktreeBaseWatchTarget, eventPath: string): string[] { - const parts = pathRelativeToWorktreeWatchRoot(target.path, eventPath) +function classifyGitCommonEvent( + target: WorktreeBaseWatchTarget, + event: WorktreeBaseWatcherEvent +): WorktreeBaseChangeClass { + const parts = pathRelativeToWorktreeWatchRoot(target.path, event.path) if (!parts) { - return [] + return NO_CHANGE } - if ( - parts[0] !== 'worktrees' && - !(parts.length === 1 && GIT_COMMON_PRIMARY_METADATA_FILES.has(parts[0])) - ) { - return [] + const repoIds = allRepoIds(target) + if (parts.length === 1) { + if (parts[0] === 'worktrees') { + return structuralChange(repoIds) + } + if (GIT_COMMON_PRIMARY_STRUCTURAL_FILES.has(parts[0])) { + return structuralChange(repoIds) + } + if (GIT_COMMON_PRIMARY_STATUS_FILES.has(parts[0])) { + return gitStatusChange(repoIds) + } + return NO_CHANGE } - return [...target.repos.keys()] + if (parts[0] !== 'worktrees') { + if (isHeadLogParts(parts, 0)) { + return headIdentityChange(repoIds) + } + return NO_CHANGE + } + if (parts.length === 2) { + return event.type === 'update' ? NO_CHANGE : structuralChange(repoIds) + } + if (parts.length === 3) { + if (GIT_COMMON_LINKED_STRUCTURAL_FILES.has(parts[2])) { + return structuralChange(repoIds) + } + if (GIT_COMMON_LINKED_STATUS_FILES.has(parts[2])) { + return gitStatusChange(repoIds) + } + } + if (isHeadLogParts(parts, 2)) { + return headIdentityChange(repoIds) + } + return NO_CHANGE +} + +function classifyBaseEvent( + target: WorktreeBaseWatchTarget, + event: WorktreeBaseWatcherEvent +): WorktreeBaseChangeClass { + return structuralChange(matchingBaseRepoIds(target, event.path, event.type)) +} + +export function classifyWorktreeBaseChange( + target: WorktreeBaseWatchTarget, + event: WorktreeBaseWatcherEvent +): WorktreeBaseChangeClass { + return target.kind === 'git-common' + ? classifyGitCommonEvent(target, event) + : classifyBaseEvent(target, event) } export function matchingWorktreeBaseRepoIds( target: WorktreeBaseWatchTarget, event: WorktreeBaseWatcherEvent ): string[] { - return target.kind === 'git-common' - ? matchingGitCommonRepoIds(target, event.path) - : matchingBaseRepoIds(target, event.path, event.type) + return classifyWorktreeBaseChange(target, event).structureRepoIds } diff --git a/src/main/ipc/worktree-base-directory-poller.test.ts b/src/main/ipc/worktree-base-directory-poller.test.ts index 292530f0814..d42ed105948 100644 --- a/src/main/ipc/worktree-base-directory-poller.test.ts +++ b/src/main/ipc/worktree-base-directory-poller.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, realpath, rm, stat, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { @@ -171,7 +171,7 @@ describe('worktree base directory poller', () => { expect(fullScans.length).toBeGreaterThan(0) }) - it('reports git-common worktrees metadata adds, updates, and removals via polling', async () => { + it('reports git-common entry creates, allowlisted leaf updates, and removals via polling', async () => { const commonDir = await makeRoot() const received: WorktreeBasePollEvent[][] = [] const target = makeTarget('git-common', commonDir) @@ -190,12 +190,16 @@ describe('worktree base directory poller', () => { flat.some((event) => event.type === 'create' && event.path === entry) ) - // HEAD/gitdir metadata writes land as new files in the entry dir, which - // bumps the entry dir mtime the poller compares. await new Promise((resolve) => setTimeout(resolve, 10)) await writeFile(join(entry, 'HEAD'), 'ref: refs/heads/main') await waitForEvents(received, (flat) => - flat.some((event) => event.type === 'update' && event.path === entry) + flat.some((event) => event.type === 'create' && event.path === join(entry, 'HEAD')) + ) + + await new Promise((resolve) => setTimeout(resolve, 10)) + await writeFile(join(entry, 'index'), 'status') + await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'create' && event.path === join(entry, 'index')) ) await rm(entry, { recursive: true }) @@ -204,6 +208,106 @@ describe('worktree base directory poller', () => { ) }) + it('detects linked HEAD rewrites even when the entry directory mtime is restored', async () => { + const commonDir = await makeRoot() + const entry = join(commonDir, 'worktrees', 'external-head') + await mkdir(entry, { recursive: true }) + await writeFile(join(entry, 'HEAD'), 'ref: refs/heads/main') + + const received: WorktreeBasePollEvent[][] = [] + const target = makeTarget('git-common', commonDir) + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + (events) => received.push(events), + { pollIntervalMs: POLL_MS, platform: 'linux' } + ) + cleanups.push(() => poller.unsubscribe()) + + const before = await stat(entry) + await new Promise((resolve) => setTimeout(resolve, 10)) + await writeFile(join(entry, 'HEAD'), 'ref: refs/heads/next') + await utimes(entry, before.atime, before.mtime) + + await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'update' && event.path === join(entry, 'HEAD')) + ) + }) + + it('reports linked HEAD reflog appends that bump no watched leaf or entry dir', async () => { + const commonDir = await makeRoot() + const entry = join(commonDir, 'worktrees', 'external-amend') + await mkdir(join(entry, 'logs'), { recursive: true }) + await writeFile(join(entry, 'logs', 'HEAD'), 'baseline\n') + + const received: WorktreeBasePollEvent[][] = [] + const target = makeTarget('git-common', commonDir) + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + (events) => received.push(events), + { pollIntervalMs: POLL_MS, platform: 'linux' } + ) + cleanups.push(() => poller.unsubscribe()) + + // commit --amend appends to the reflog inside logs/ — the entry dir mtime + // never moves, so the leaf must be stat'd directly. + await new Promise((resolve) => setTimeout(resolve, 10)) + await writeFile(join(entry, 'logs', 'HEAD'), 'baseline\namended\n') + await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'update' && event.path === join(entry, 'logs', 'HEAD')) + ) + }) + + it('surfaces in-place index rewrites through the backstop re-stat', async () => { + const commonDir = await makeRoot() + const entry = join(commonDir, 'worktrees', 'external-inplace') + await mkdir(entry, { recursive: true }) + await writeFile(join(entry, 'index'), 'index-v1') + + const received: WorktreeBasePollEvent[][] = [] + const target = makeTarget('git-common', commonDir) + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + (events) => received.push(events), + { pollIntervalMs: POLL_MS, platform: 'linux' } + ) + cleanups.push(() => poller.unsubscribe()) + + // An in-place rewrite leaves the entry-dir signature untouched, so only + // the periodic ungated re-stat can observe it. + await new Promise((resolve) => setTimeout(resolve, 10)) + await writeFile(join(entry, 'index'), 'index-v2-longer') + await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'update' && event.path === join(entry, 'index')) + ) + }) + + it('reports primary-checkout HEAD reflog appends via polling', async () => { + const commonDir = await makeRoot() + await mkdir(join(commonDir, 'logs'), { recursive: true }) + await writeFile(join(commonDir, 'logs', 'HEAD'), 'baseline\n') + + const received: WorktreeBasePollEvent[][] = [] + const target = makeTarget('git-common', commonDir) + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + (events) => received.push(events), + { pollIntervalMs: POLL_MS, platform: 'linux' } + ) + cleanups.push(() => poller.unsubscribe()) + + await new Promise((resolve) => setTimeout(resolve, 10)) + await writeFile(join(commonDir, 'logs', 'HEAD'), 'baseline\namended\n') + await waitForEvents(received, (flat) => + flat.some( + (event) => event.type === 'update' && event.path === join(commonDir, 'logs', 'HEAD') + ) + ) + }) + it('reports primary-checkout HEAD changes via polling', async () => { const commonDir = await makeRoot() const received: WorktreeBasePollEvent[][] = [] diff --git a/src/main/ipc/worktree-base-directory-watcher.test.ts b/src/main/ipc/worktree-base-directory-watcher.test.ts index d312c8ab431..c560b5e51aa 100644 --- a/src/main/ipc/worktree-base-directory-watcher.test.ts +++ b/src/main/ipc/worktree-base-directory-watcher.test.ts @@ -14,6 +14,8 @@ vi.mock('./worktree-base-directory-poller', () => ({ })) vi.mock('./worktree-remote', () => ({ + notifyWorktreeGitStatusMetadataChanged: vi.fn(), + notifyWorktreeHeadIdentitiesChanged: vi.fn(), notifyWorktreesChanged: vi.fn() })) @@ -21,8 +23,17 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ getSshFilesystemProvider: vi.fn() })) +vi.mock('./worktree-head-identity-reader', () => ({ + readGitCommonHeadIdentities: vi.fn(async () => []) +})) + import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import { notifyWorktreesChanged } from './worktree-remote' +import { + notifyWorktreeGitStatusMetadataChanged, + notifyWorktreeHeadIdentitiesChanged, + notifyWorktreesChanged +} from './worktree-remote' +import { readGitCommonHeadIdentities } from './worktree-head-identity-reader' import { startWorktreeBaseDirectoryPoller } from './worktree-base-directory-poller' import { disposeWorktreeBaseDirectoryWatchers, @@ -83,6 +94,7 @@ describe('worktree base directory watcher', () => { watcherCallbacks.clear() unsubscribeMocks.clear() vi.mocked(getSshFilesystemProvider).mockReturnValue(undefined) + vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([]) vi.mocked(startWorktreeBaseDirectoryPoller).mockImplementation( async (target, _getRepos, onEvents) => { const unsubscribe = vi.fn(async () => {}) @@ -104,7 +116,10 @@ describe('worktree base directory watcher', () => { emit(WORKTREE_ROOT, [ { type: 'create', path: join(WORKTREE_ROOT, 'project', 'external-5104') }, - { type: 'create', path: join(WORKTREE_ROOT, 'project', 'external-5104', '.git') } + { + type: 'create', + path: join(WORKTREE_ROOT, 'project', 'external-5104', '.git') + } ]) await vi.advanceTimersByTimeAsync(300) @@ -121,7 +136,10 @@ describe('worktree base directory watcher', () => { ) emit(WORKTREE_ROOT, [ - { type: 'create', path: join(WORKTREE_ROOT, 'project', 'external-5104', '.git') } + { + type: 'create', + path: join(WORKTREE_ROOT, 'project', 'external-5104', '.git') + } ]) destroyed = true await vi.advanceTimersByTimeAsync(300) @@ -133,7 +151,10 @@ describe('worktree base directory watcher', () => { await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) emit(WORKTREE_ROOT, [ - { type: 'update', path: join(WORKTREE_ROOT, 'project', 'existing', 'src', 'file.ts') } + { + type: 'update', + path: join(WORKTREE_ROOT, 'project', 'existing', 'src', 'file.ts') + } ]) await vi.advanceTimersByTimeAsync(300) @@ -144,13 +165,304 @@ describe('worktree base directory watcher', () => { await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) emit(PROJECT_GIT_COMMON_DIR, [ - { type: 'create', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'gitdir') } + { + type: 'create', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'gitdir') + } ]) await vi.advanceTimersByTimeAsync(300) expect(notifyWorktreesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1') }) + it('debounces linked index bursts as status-only without structural notification', async () => { + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + + emit(PROJECT_GIT_COMMON_DIR, [ + { + type: 'create', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') + }, + { + type: 'update', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') + }, + { + type: 'delete', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') + } + ]) + await vi.advanceTimersByTimeAsync(300) + + expect(notifyWorktreesChanged).not.toHaveBeenCalled() + expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledTimes(1) + expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1') + }) + + it('keeps linked HEAD and lock metadata structural', async () => { + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + + emit(PROJECT_GIT_COMMON_DIR, [ + { + type: 'update', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'HEAD') + }, + { + type: 'create', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'locked') + } + ]) + await vi.advanceTimersByTimeAsync(300) + + expect(notifyWorktreesChanged).toHaveBeenCalledTimes(1) + expect(notifyWorktreesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1') + expect(notifyWorktreeGitStatusMetadataChanged).not.toHaveBeenCalled() + }) + + it('emits head identities for a linked reflog head move without structural fanout', async () => { + const linkedWorktree = absolutePath('workspace', 'worktrees', 'project', 'external-5104') + vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ + { + worktreePath: linkedWorktree, + head: 'aaa111', + branch: 'refs/heads/feature' + } + ]) + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + await vi.advanceTimersByTimeAsync(0) + + // External commit --amend: only logs/HEAD moves, no index write. + vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ + { + worktreePath: linkedWorktree, + head: 'bbb222', + branch: 'refs/heads/feature' + } + ]) + emit(PROJECT_GIT_COMMON_DIR, [ + { + type: 'update', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'logs', 'HEAD') + } + ]) + await vi.advanceTimersByTimeAsync(300) + await vi.advanceTimersByTimeAsync(0) + + expect(notifyWorktreesChanged).not.toHaveBeenCalled() + expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledTimes(1) + expect(notifyWorktreeHeadIdentitiesChanged).toHaveBeenCalledTimes(1) + expect(notifyWorktreeHeadIdentitiesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1', [ + { + worktreePath: linkedWorktree, + head: 'bbb222', + branch: 'refs/heads/feature' + } + ]) + }) + + it('makes zero head-identity reads on an index-only burst across linked and primary checkouts', async () => { + const linkedWorktree = absolutePath('workspace', 'worktrees', 'project', 'external-5104') + vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ + { + worktreePath: linkedWorktree, + head: 'aaa111', + branch: 'refs/heads/feature' + } + ]) + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + await vi.advanceTimersByTimeAsync(0) + // Subscribe baselines heads once; the index burst must add no further reads. + vi.mocked(readGitCommonHeadIdentities).mockClear() + + emit(PROJECT_GIT_COMMON_DIR, [ + { + type: 'create', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') + }, + { + type: 'update', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') + }, + { type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'index') } + ]) + await vi.advanceTimersByTimeAsync(300) + await vi.advanceTimersByTimeAsync(0) + + // An index rewrite cannot move HEAD, so the linked-worktree scan is skipped. + expect(readGitCommonHeadIdentities).not.toHaveBeenCalled() + expect(notifyWorktreeHeadIdentitiesChanged).not.toHaveBeenCalled() + // Source Control still refreshes for the status churn, coalesced to one call. + expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledTimes(1) + expect(notifyWorktreesChanged).not.toHaveBeenCalled() + }) + + it('emits head identities for a primary-checkout reflog head move', async () => { + vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ + { worktreePath: PROJECT_ROOT, head: 'aaa111', branch: 'refs/heads/main' } + ]) + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + await vi.advanceTimersByTimeAsync(0) + + vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ + { worktreePath: PROJECT_ROOT, head: 'bbb222', branch: 'refs/heads/main' } + ]) + emit(PROJECT_GIT_COMMON_DIR, [ + { type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'logs', 'HEAD') } + ]) + await vi.advanceTimersByTimeAsync(300) + await vi.advanceTimersByTimeAsync(0) + + expect(notifyWorktreesChanged).not.toHaveBeenCalled() + expect(notifyWorktreeHeadIdentitiesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1', [ + { + worktreePath: PROJECT_ROOT, + head: 'bbb222', + branch: 'refs/heads/main' + } + ]) + }) + + it('coalesces an index and reflog burst into one head read and one status refresh', async () => { + const linkedWorktree = absolutePath('workspace', 'worktrees', 'project', 'external-5104') + vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ + { + worktreePath: linkedWorktree, + head: 'aaa111', + branch: 'refs/heads/feature' + } + ]) + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + await vi.advanceTimersByTimeAsync(0) + vi.mocked(readGitCommonHeadIdentities).mockClear() + + // reset --soft rewrites the index and appends logs/HEAD in the same burst. + vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ + { + worktreePath: linkedWorktree, + head: 'bbb222', + branch: 'refs/heads/feature' + } + ]) + emit(PROJECT_GIT_COMMON_DIR, [ + { + type: 'update', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') + }, + { + type: 'update', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'logs', 'HEAD') + } + ]) + await vi.advanceTimersByTimeAsync(300) + await vi.advanceTimersByTimeAsync(0) + + expect(readGitCommonHeadIdentities).toHaveBeenCalledTimes(1) + expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledTimes(1) + expect(notifyWorktreeHeadIdentitiesChanged).toHaveBeenCalledTimes(1) + expect(notifyWorktreesChanged).not.toHaveBeenCalled() + }) + + it('debounces successive reflog events into a single head read', async () => { + const linkedWorktree = absolutePath('workspace', 'worktrees', 'project', 'external-5104') + vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ + { + worktreePath: linkedWorktree, + head: 'aaa111', + branch: 'refs/heads/feature' + } + ]) + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + await vi.advanceTimersByTimeAsync(0) + vi.mocked(readGitCommonHeadIdentities).mockClear() + + const reflog = { + type: 'update' as const, + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'logs', 'HEAD') + } + emit(PROJECT_GIT_COMMON_DIR, [reflog]) + // Second burst arrives before the debounce window elapses and resets it. + await vi.advanceTimersByTimeAsync(100) + emit(PROJECT_GIT_COMMON_DIR, [reflog]) + await vi.advanceTimersByTimeAsync(300) + await vi.advanceTimersByTimeAsync(0) + + expect(readGitCommonHeadIdentities).toHaveBeenCalledTimes(1) + expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledTimes(1) + }) + + it('re-baselines head identities silently on structural notifications', async () => { + const linkedWorktree = absolutePath('workspace', 'worktrees', 'project', 'external-5104') + vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ + { + worktreePath: linkedWorktree, + head: 'aaa111', + branch: 'refs/heads/feature' + } + ]) + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + await vi.advanceTimersByTimeAsync(0) + + // Branch switch: structural path owns the refresh via the full listing. + vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ + { + worktreePath: linkedWorktree, + head: 'ccc333', + branch: 'refs/heads/other' + } + ]) + emit(PROJECT_GIT_COMMON_DIR, [ + { + type: 'update', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'HEAD') + } + ]) + await vi.advanceTimersByTimeAsync(300) + await vi.advanceTimersByTimeAsync(0) + expect(notifyWorktreeHeadIdentitiesChanged).not.toHaveBeenCalled() + + // A later reflog event diffs against the re-baselined heads, so an + // unchanged head is not re-reported. + emit(PROJECT_GIT_COMMON_DIR, [ + { + type: 'update', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'logs', 'HEAD') + } + ]) + await vi.advanceTimersByTimeAsync(300) + await vi.advanceTimersByTimeAsync(0) + expect(notifyWorktreeHeadIdentitiesChanged).not.toHaveBeenCalled() + }) + + it('never reads head identities for SSH watches', async () => { + const remoteCallbacks = new Map void>() + const remoteWatch = vi.fn(async (root: string, callback: (events: never[]) => void) => { + remoteCallbacks.set(root, callback) + return vi.fn() + }) + vi.mocked(getSshFilesystemProvider).mockReturnValue({ + stat: vi.fn(async () => ({ type: 'directory', size: 0, mtime: 0 })), + realpath: vi.fn(async (path: string) => path), + readFile: vi.fn(async () => ({ content: '', isBinary: false })), + watch: remoteWatch + } as never) + + await syncWorktreeBaseDirectoryWatchers( + makeStore([makeRepo({ connectionId: 'ssh-1', path: '/home/alice/project' })]) as never, + makeWindow() as never + ) + remoteCallbacks.get('/home/alice/project/.git')?.([ + { + kind: 'update', + absolutePath: '/home/alice/project/.git/worktrees/wt/logs/HEAD' + } + ] as never[]) + await vi.advanceTimersByTimeAsync(300) + + expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledTimes(1) + expect(readGitCommonHeadIdentities).not.toHaveBeenCalled() + expect(notifyWorktreeHeadIdentitiesChanged).not.toHaveBeenCalled() + }) + it('does not install local desktop watchers for runtime or folder repos', async () => { await syncWorktreeBaseDirectoryWatchers( makeStore([ @@ -197,6 +509,42 @@ describe('worktree base directory watcher', () => { expect(remoteUnwatch).toHaveBeenCalled() }) + it('treats remote index renames as status-only and overflow as structural', async () => { + const remoteCallbacks = new Map void>() + const remoteWatch = vi.fn(async (root: string, callback: (events: never[]) => void) => { + remoteCallbacks.set(root, callback) + return vi.fn() + }) + vi.mocked(getSshFilesystemProvider).mockReturnValue({ + stat: vi.fn(async () => ({ type: 'directory', size: 0, mtime: 0 })), + realpath: vi.fn(async (path: string) => path), + readFile: vi.fn(async () => ({ content: '', isBinary: false })), + watch: remoteWatch + } as never) + + await syncWorktreeBaseDirectoryWatchers( + makeStore([makeRepo({ connectionId: 'ssh-1', path: '/home/alice/project' })]) as never, + makeWindow() as never + ) + + remoteCallbacks.get('/home/alice/project/.git')?.([ + { + kind: 'rename', + oldAbsolutePath: '/home/alice/project/.git/worktrees/wt/index.lock', + absolutePath: '/home/alice/project/.git/worktrees/wt/index' + } + ] as never[]) + await vi.advanceTimersByTimeAsync(300) + expect(notifyWorktreesChanged).not.toHaveBeenCalled() + expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledTimes(1) + + vi.mocked(notifyWorktreeGitStatusMetadataChanged).mockClear() + remoteCallbacks.get('/home/alice/project/.git')?.([{ kind: 'overflow' }] as never[]) + await vi.advanceTimersByTimeAsync(300) + expect(notifyWorktreesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1') + expect(notifyWorktreeGitStatusMetadataChanged).not.toHaveBeenCalled() + }) + it('unsubscribes roots that disappear after repo settings change', async () => { const repo = makeRepo() const store = makeStore([repo]) diff --git a/src/main/ipc/worktree-base-directory-watcher.ts b/src/main/ipc/worktree-base-directory-watcher.ts index db71349a7dd..23352d688f1 100644 --- a/src/main/ipc/worktree-base-directory-watcher.ts +++ b/src/main/ipc/worktree-base-directory-watcher.ts @@ -1,12 +1,18 @@ import type { BrowserWindow } from 'electron' -import type { FsChangeEvent } from '../../shared/types' import type { Store } from '../persistence' -import { notifyWorktreesChanged } from './worktree-remote' +import { notifyWorktreeGitStatusMetadataChanged, notifyWorktreesChanged } from './worktree-remote' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { - matchingWorktreeBaseRepoIds, - type WorktreeBaseWatchTarget -} from './worktree-base-directory-event-filter' + createWorktreeHeadIdentityRefreshState, + refreshWorktreeHeadIdentities, + type WorktreeHeadIdentityRefreshState +} from './worktree-head-identity-refresh' +import { + collectLocalWorktreeBaseChanges, + collectRemoteWorktreeBaseChanges, + type WorktreeBaseCollectedChanges +} from './worktree-base-directory-change-collector' +import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' import { buildWorktreeBaseDirectoryWatchTargets, clearWorktreeBaseDirectoryWatchTargetWarnings @@ -17,7 +23,10 @@ type ActiveWatch = WorktreeBaseWatchTarget & { mainWindow: BrowserWindow subscription: { unsubscribe: () => Promise } notifyTimer: ReturnType | null - pendingRepoIds: Set + pendingStructureRepoIds: Set + pendingGitStatusRepoIds: Set + pendingHeadIdentityRepoIds: Set + headIdentityRefresh: WorktreeHeadIdentityRefreshState disposed: boolean } @@ -27,13 +36,27 @@ let syncGeneration = 0 let scheduledSync: ReturnType | null = null let latestSyncContext: { mainWindow: BrowserWindow; store: Store } | null = null -function scheduleNotification(watch: ActiveWatch, repoIds: readonly string[]): void { +function clearPendingRepoIds(watch: ActiveWatch): void { + watch.pendingStructureRepoIds.clear() + watch.pendingGitStatusRepoIds.clear() + watch.pendingHeadIdentityRepoIds.clear() +} + +type PendingNotificationInput = Partial> + +function scheduleNotification(watch: ActiveWatch, changes: PendingNotificationInput): void { if (watch.disposed || watch.mainWindow.isDestroyed()) { - watch.pendingRepoIds.clear() + clearPendingRepoIds(watch) return } - for (const repoId of repoIds) { - watch.pendingRepoIds.add(repoId) + for (const repoId of changes.structureRepoIds ?? []) { + watch.pendingStructureRepoIds.add(repoId) + } + for (const repoId of changes.gitStatusRepoIds ?? []) { + watch.pendingGitStatusRepoIds.add(repoId) + } + for (const repoId of changes.headIdentityRepoIds ?? []) { + watch.pendingHeadIdentityRepoIds.add(repoId) } if (watch.notifyTimer) { clearTimeout(watch.notifyTimer) @@ -41,26 +64,46 @@ function scheduleNotification(watch: ActiveWatch, repoIds: readonly string[]): v watch.notifyTimer = setTimeout(() => { watch.notifyTimer = null if (watch.disposed || watch.mainWindow.isDestroyed()) { - watch.pendingRepoIds.clear() + clearPendingRepoIds(watch) return } - const pending = [...watch.pendingRepoIds] - watch.pendingRepoIds.clear() - for (const repoId of pending) { + const pendingStructure = [...watch.pendingStructureRepoIds] + const hasHeadIdentity = watch.pendingHeadIdentityRepoIds.size > 0 + // Source Control refreshes on both index churn and head moves; structural + // repos already refresh via the authoritative listing, so drop them here. + const sourceControlRepoIds = new Set( + [...watch.pendingGitStatusRepoIds, ...watch.pendingHeadIdentityRepoIds].filter( + (repoId) => !watch.pendingStructureRepoIds.has(repoId) + ) + ) + // Structural ticks refresh silently (emit=false): the authoritative listing + // already reported them, so this only re-baselines ahead of later head diffs. + const emitHeadIdentities = pendingStructure.length === 0 + clearPendingRepoIds(watch) + for (const repoId of pendingStructure) { notifyWorktreesChanged(watch.mainWindow, repoId) } + for (const repoId of sourceControlRepoIds) { + notifyWorktreeGitStatusMetadataChanged(watch.mainWindow, repoId) + } + // Only re-read head identities for true head triggers: an index rewrite + // cannot move HEAD, so status-only bursts skip the linked-worktree scan. + if (supportsHeadIdentityRefresh(watch) && (pendingStructure.length > 0 || hasHeadIdentity)) { + void refreshWorktreeHeadIdentities(watch, watch.headIdentityRefresh, emitHeadIdentities) + } }, WATCH_DEBOUNCE_MS) } -function collectMatchingRepoIds( - watch: ActiveWatch, - eventType: 'create' | 'update' | 'delete', - eventPath: string, - repoIds: Set -): void { - for (const repoId of matchingWorktreeBaseRepoIds(watch, { type: eventType, path: eventPath })) { - repoIds.add(repoId) - } +// Why: SSH common dirs would need per-signal network reads to diff heads; +// remote background freshness stays on the structural path for now. +function supportsHeadIdentityRefresh(watch: ActiveWatch): boolean { + return watch.kind === 'git-common' && !watch.connectionId +} + +function hasCollectedChanges(changes: WorktreeBaseCollectedChanges): boolean { + return [changes.structureRepoIds, changes.gitStatusRepoIds, changes.headIdentityRepoIds].some( + (ids) => ids.length > 0 + ) } function handleLocalWatchEvents( @@ -73,39 +116,47 @@ function handleLocalWatchEvents( } if (error) { console.warn(`[worktree-base-watcher] watcher failed for ${watch.path}:`, error) - scheduleNotification(watch, [...watch.repos.keys()]) + scheduleNotification(watch, { structureRepoIds: [...watch.repos.keys()] }) return } - const repoIds = new Set() - for (const event of events) { - collectMatchingRepoIds(watch, event.type, event.path, repoIds) - } - if (repoIds.size > 0) { - scheduleNotification(watch, [...repoIds]) + const changes = collectLocalWorktreeBaseChanges(watch, events) + if (hasCollectedChanges(changes)) { + scheduleNotification(watch, changes) } } -function handleRemoteWatchEvents(watch: ActiveWatch, events: FsChangeEvent[]): void { +function handleRemoteWatchEvents( + watch: ActiveWatch, + events: Parameters[1] +): void { if (watch.disposed || watch.mainWindow.isDestroyed()) { return } - const repoIds = new Set() - for (const event of events) { - if (event.kind === 'overflow') { - scheduleNotification(watch, [...watch.repos.keys()]) - return - } - if (event.kind === 'rename') { - if (event.oldAbsolutePath) { - collectMatchingRepoIds(watch, 'delete', event.oldAbsolutePath, repoIds) - } - collectMatchingRepoIds(watch, 'create', event.absolutePath, repoIds) - continue - } - collectMatchingRepoIds(watch, event.kind, event.absolutePath, repoIds) + const changes = collectRemoteWorktreeBaseChanges(watch, events) + if (changes.overflow) { + scheduleNotification(watch, { structureRepoIds: [...watch.repos.keys()] }) + return } - if (repoIds.size > 0) { - scheduleNotification(watch, [...repoIds]) + if (hasCollectedChanges(changes)) { + scheduleNotification(watch, changes) + } +} + +function createActiveWatch( + target: WorktreeBaseWatchTarget, + mainWindow: BrowserWindow, + subscription: ActiveWatch['subscription'] +): ActiveWatch { + return { + ...target, + mainWindow, + subscription, + notifyTimer: null, + pendingStructureRepoIds: new Set(), + pendingGitStatusRepoIds: new Set(), + pendingHeadIdentityRepoIds: new Set(), + headIdentityRefresh: createWorktreeHeadIdentityRefreshState(), + disposed: false } } @@ -126,14 +177,9 @@ async function subscribeTarget( } handleRemoteWatchEvents(currentWatch, events) }) - activeWatch = { - ...target, - mainWindow, - subscription: { unsubscribe: async () => unwatch() }, - notifyTimer: null, - pendingRepoIds: new Set(), - disposed: false - } + activeWatch = createActiveWatch(target, mainWindow, { + unsubscribe: async () => unwatch() + }) return activeWatch } @@ -152,13 +198,12 @@ async function subscribeTarget( handleLocalWatchEvents(currentWatch, null, events) } ) - activeWatch = { - ...target, - mainWindow, - subscription, - notifyTimer: null, - pendingRepoIds: new Set(), - disposed: false + activeWatch = createActiveWatch(target, mainWindow, subscription) + if (supportsHeadIdentityRefresh(activeWatch)) { + // Baseline eagerly so the first status-only signal — possibly hours after + // subscribe — diffs against subscribe-time heads instead of silently + // re-baselining past an external commit. + void refreshWorktreeHeadIdentities(activeWatch, activeWatch.headIdentityRefresh, false) } return activeWatch } @@ -199,6 +244,7 @@ async function removeWatch(key: string): Promise { if (watch.notifyTimer) { clearTimeout(watch.notifyTimer) } + clearPendingRepoIds(watch) await watch.subscription.unsubscribe().catch((error) => { console.warn(`[worktree-base-watcher] failed to unwatch ${watch.path}:`, error) }) diff --git a/src/main/ipc/worktree-folder-rename-target.ts b/src/main/ipc/worktree-folder-rename-target.ts index 9e52fc15fa7..5725b4efd75 100644 --- a/src/main/ipc/worktree-folder-rename-target.ts +++ b/src/main/ipc/worktree-folder-rename-target.ts @@ -26,6 +26,7 @@ export function planWorktreeFolderRename(args: { repoId: string repoPath: string oldWorktreePath: string + worktreeIdSuffix?: string newLeaf: string settings: WorktreePathSettings platform: NodeJS.Platform @@ -49,6 +50,8 @@ export function planWorktreeFolderRename(args: { return { oldPath: args.oldWorktreePath, newPath, - newWorktreeId: `${args.repoId}${WORKTREE_ID_SEPARATOR}${newPath}` + // Why: folder workspaces can have multiple live instances backed by the + // same folder path, so preserve any synthetic instance suffix when re-keying. + newWorktreeId: `${args.repoId}${WORKTREE_ID_SEPARATOR}${newPath}${args.worktreeIdSuffix ?? ''}` } } diff --git a/src/main/ipc/worktree-git-common-polling.ts b/src/main/ipc/worktree-git-common-polling.ts new file mode 100644 index 00000000000..8996c7b00c1 --- /dev/null +++ b/src/main/ipc/worktree-git-common-polling.ts @@ -0,0 +1,273 @@ +import { readdir, stat } from 'node:fs/promises' +import { join } from 'node:path' +import type { + WorktreeBasePollEvent, + WorktreeBaseSubscription +} from './worktree-base-directory-poller' + +// Shared with the darwin primary-metadata poll so the platforms cannot drift +// on which shallow leaves count as watchable metadata. `logs/HEAD` catches +// head moves that rewrite no other watched leaf (commit --amend, reset +// --soft); `config.worktree` carries the sparse flag. +export const PRIMARY_CHECKOUT_METADATA_FILES = [ + 'HEAD', + 'packed-refs', + 'index', + 'config.worktree', + 'logs/HEAD' +] +const LINKED_WORKTREE_STRUCTURAL_METADATA_FILES = ['HEAD', 'gitdir', 'locked', 'config.worktree'] +const LINKED_WORKTREE_INDEX_FILE = 'index' +const LINKED_WORKTREE_HEAD_LOG_FILE = join('logs', 'HEAD') +// Why: the entry-dir signature gate can miss same-granule index rewrites on +// coarse-mtime filesystems; a periodic ungated re-stat bounds that miss the +// same way the base poller's backstop rescan does. +const INDEX_BACKSTOP_TICKS = 15 + +function statSignature(s: { mtimeMs: number; ctimeMs: number; ino: number; size: number }): string { + return `${s.mtimeMs}:${s.ctimeMs}:${s.ino}:${s.size}` +} + +async function fileSignature(path: string): Promise { + try { + const s = await stat(path) + return s.isFile() ? statSignature(s) : null + } catch { + return null + } +} + +async function pathSignature(path: string): Promise { + try { + const s = await stat(path) + // Why: omitting ctime keeps unrelated metadata churn from re-opening the + // index gate, which would make the HEAD regression test vacuous. The gate + // is load-bearing for index-event emission between backstop ticks; the + // renderer's status poll is the ultimate freshness net. + return `${s.mtimeMs}:${s.ino}:${s.size}` + } catch { + return null + } +} + +type GitCommonEntrySnapshot = { + dirSignature: string | null + structuralSignatures: Map + indexSignature: string | null + headLogSignature: string | null +} + +type GitCommonSnapshot = { + worktreesDirSignature: string | null + entries: Map + primarySignatures: Map +} + +async function snapshotGitCommonEntry( + entryPath: string, + previous: GitCommonEntrySnapshot | undefined, + forceIndexRead: boolean +): Promise { + const dirSignature = await pathSignature(entryPath) + const structuralSignatures = new Map() + await Promise.all( + LINKED_WORKTREE_STRUCTURAL_METADATA_FILES.map(async (name) => { + const signature = await fileSignature(join(entryPath, name)) + if (signature !== null) { + structuralSignatures.set(name, signature) + } + }) + ) + // `logs/HEAD` lives in a subdirectory, so appends never bump the entry-dir + // mtime — it must be stat'd every tick rather than gated like `index`. + const headLogSignature = await fileSignature(join(entryPath, LINKED_WORKTREE_HEAD_LOG_FILE)) + const shouldReadIndex = forceIndexRead || !previous || previous.dirSignature !== dirSignature + const indexSignature = shouldReadIndex + ? await fileSignature(join(entryPath, LINKED_WORKTREE_INDEX_FILE)) + : previous.indexSignature + return { dirSignature, structuralSignatures, indexSignature, headLogSignature } +} + +async function snapshotPrimaryCheckoutSignatures( + commonDirPath: string +): Promise> { + const signatures = new Map() + await Promise.all( + PRIMARY_CHECKOUT_METADATA_FILES.map(async (name) => { + const signature = await fileSignature(join(commonDirPath, name)) + if (signature !== null) { + signatures.set(name, signature) + } + }) + ) + return signatures +} + +async function snapshotGitCommon( + commonDirPath: string, + previous?: GitCommonSnapshot, + includePrimary = true, + forceIndexRead = false +): Promise { + const entriesByPath = new Map() + const worktreesDir = join(commonDirPath, 'worktrees') + const worktreesDirSignature = await pathSignature(worktreesDir) + const primarySignatures = includePrimary + ? await snapshotPrimaryCheckoutSignatures(commonDirPath) + : new Map() + let entries + try { + entries = await readdir(worktreesDir, { withFileTypes: true }) + } catch { + // Missing worktrees dir is normal for repos without linked worktrees. + return { + worktreesDirSignature, + entries: entriesByPath, + primarySignatures + } + } + await Promise.all( + entries.map(async (entry) => { + if (!entry.isDirectory()) { + return + } + const entryPath = join(worktreesDir, entry.name) + entriesByPath.set( + entryPath, + await snapshotGitCommonEntry(entryPath, previous?.entries.get(entryPath), forceIndexRead) + ) + }) + ) + return { + worktreesDirSignature, + entries: entriesByPath, + primarySignatures + } +} + +function classifySignatureDiff( + prevSignature: string | null | undefined, + nextSignature: string | null | undefined +): 'create' | 'update' | 'delete' | null { + if (prevSignature == null && nextSignature == null) { + return null + } + if (prevSignature == null) { + return 'create' + } + if (nextSignature == null) { + return 'delete' + } + return prevSignature === nextSignature ? null : 'update' +} + +function diffSignatureMaps( + prev: Map, + next: Map, + resolvePath: (name: string) => string +): WorktreeBasePollEvent[] { + const events: WorktreeBasePollEvent[] = [] + const names = new Set([...prev.keys(), ...next.keys()]) + for (const name of names) { + const type = classifySignatureDiff(prev.get(name), next.get(name)) + if (type) { + events.push({ type, path: resolvePath(name) }) + } + } + return events +} + +function diffGitCommon( + commonDirPath: string, + prev: GitCommonSnapshot, + next: GitCommonSnapshot +): WorktreeBasePollEvent[] { + const events: WorktreeBasePollEvent[] = [] + const worktreesDir = join(commonDirPath, 'worktrees') + const worktreesDirDiff = classifySignatureDiff( + prev.worktreesDirSignature, + next.worktreesDirSignature + ) + if (worktreesDirDiff) { + events.push({ type: worktreesDirDiff, path: worktreesDir }) + } + for (const [entryPath, entry] of next.entries) { + const prevEntry = prev.entries.get(entryPath) + if (!prevEntry) { + events.push({ type: 'create', path: entryPath }) + continue + } + events.push( + ...diffSignatureMaps(prevEntry.structuralSignatures, entry.structuralSignatures, (name) => + join(entryPath, name) + ) + ) + const indexDiff = classifySignatureDiff(prevEntry.indexSignature, entry.indexSignature) + if (indexDiff) { + events.push({ type: indexDiff, path: join(entryPath, LINKED_WORKTREE_INDEX_FILE) }) + } + const headLogDiff = classifySignatureDiff(prevEntry.headLogSignature, entry.headLogSignature) + if (headLogDiff) { + events.push({ type: headLogDiff, path: join(entryPath, LINKED_WORKTREE_HEAD_LOG_FILE) }) + } + } + for (const entryPath of prev.entries.keys()) { + if (!next.entries.has(entryPath)) { + events.push({ type: 'delete', path: entryPath }) + } + } + events.push( + ...diffSignatureMaps(prev.primarySignatures, next.primarySignatures, (name) => + join(commonDirPath, name) + ) + ) + return events +} + +export async function startGitCommonPolling( + commonDirPath: string, + onEvents: (events: WorktreeBasePollEvent[]) => void, + pollIntervalMs: number, + onFullScan?: () => void, + includePrimary = true +): Promise { + let disposed = false + let ticking = false + let tickCount = 0 + let snapshot = await snapshotGitCommon(commonDirPath, undefined, includePrimary) + + const timer = setInterval(() => { + if (disposed || ticking) { + return + } + ticking = true + tickCount++ + const forceIndexRead = tickCount % INDEX_BACKSTOP_TICKS === 0 + onFullScan?.() + void snapshotGitCommon(commonDirPath, snapshot, includePrimary, forceIndexRead) + .then((next) => { + if (disposed) { + return + } + const events = diffGitCommon(commonDirPath, snapshot, next) + snapshot = next + if (events.length > 0) { + onEvents(events) + } + }) + .catch(() => { + // Transient fs error: keep the previous snapshot and retry next tick. + }) + .finally(() => { + ticking = false + }) + }, pollIntervalMs) + timer.unref?.() + + return { + unsubscribe: async () => { + disposed = true + clearInterval(timer) + } + } +} diff --git a/src/main/ipc/worktree-git-common-watch.ts b/src/main/ipc/worktree-git-common-watch.ts index 1529f9de1a4..39899b1e149 100644 --- a/src/main/ipc/worktree-git-common-watch.ts +++ b/src/main/ipc/worktree-git-common-watch.ts @@ -1,10 +1,14 @@ -import { readdir, stat } from 'node:fs/promises' +import { stat } from 'node:fs/promises' import { join } from 'node:path' import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' import type { WorktreeBasePollEvent, WorktreeBaseSubscription } from './worktree-base-directory-poller' +import { + PRIMARY_CHECKOUT_METADATA_FILES, + startGitCommonPolling +} from './worktree-git-common-polling' // Watches a repo's `/.git/worktrees` metadata plus the primary // checkout's shallow branch/index files — the only paths the git-common event @@ -20,8 +24,6 @@ import type { // Why: branch switches and commits made in the primary checkout rewrite these // top-level files (linked-worktree equivalents live under `worktrees/`). // Deliberately excludes FETCH_HEAD-style churn that carries no status change. -const PRIMARY_CHECKOUT_METADATA_FILES = ['HEAD', 'packed-refs', 'index'] - async function snapshotPrimaryCheckoutMetadata( commonDirPath: string ): Promise> { @@ -37,46 +39,22 @@ async function snapshotPrimaryCheckoutMetadata( return mtimes } -async function snapshotGitCommon(commonDirPath: string): Promise> { - const mtimes = new Map() - const worktreesDir = join(commonDirPath, 'worktrees') - let entries - try { - entries = await readdir(worktreesDir, { withFileTypes: true }) - } catch { - // Missing worktrees dir is normal for repos without linked worktrees. - return mtimes - } - for (const entry of entries) { - const entryPath = join(worktreesDir, entry.name) - try { - // Entry-dir mtime covers the metadata writes the old recursive watcher - // reacted to (HEAD/gitdir/locked are written via rename into the entry - // dir, which bumps its mtime). - mtimes.set(entryPath, (await stat(entryPath)).mtimeMs) - } catch { - // Entry removed between readdir and stat. - } - } - return mtimes -} - -function diffGitCommon( +function diffMtimeMap( prev: Map, next: Map ): WorktreeBasePollEvent[] { const events: WorktreeBasePollEvent[] = [] - for (const [entryPath, mtime] of next) { - const prevMtime = prev.get(entryPath) + for (const [path, mtime] of next) { + const prevMtime = prev.get(path) if (prevMtime === undefined) { - events.push({ type: 'create', path: entryPath }) + events.push({ type: 'create', path }) } else if (prevMtime !== mtime) { - events.push({ type: 'update', path: entryPath }) + events.push({ type: 'update', path }) } } - for (const entryPath of prev.keys()) { - if (!next.has(entryPath)) { - events.push({ type: 'delete', path: entryPath }) + for (const path of prev.keys()) { + if (!next.has(path)) { + events.push({ type: 'delete', path }) } } return events @@ -103,7 +81,7 @@ async function startSnapshotDiffPoller( if (disposed) { return } - const events = diffGitCommon(snapshot, next) + const events = diffMtimeMap(snapshot, next) snapshot = next if (events.length > 0) { onEvents(events) @@ -126,19 +104,6 @@ async function startSnapshotDiffPoller( } } -async function snapshotGitCommonAndPrimaryMetadata( - commonDirPath: string -): Promise> { - const [worktrees, primary] = await Promise.all([ - snapshotGitCommon(commonDirPath), - snapshotPrimaryCheckoutMetadata(commonDirPath) - ]) - for (const [path, mtime] of primary) { - worktrees.set(path, mtime) - } - return worktrees -} - async function startGitCommonNarrowWatch( target: WorktreeBaseWatchTarget, onEvents: (events: WorktreeBasePollEvent[]) => void, @@ -239,6 +204,8 @@ async function startGitCommonNarrowWatch( } if (!(await trySubscribe())) { + // Why: repos commonly start without linked worktrees; retrying the narrow + // subscription lets macOS upgrade to native events when the directory appears. armExistencePoll() } @@ -278,10 +245,5 @@ export async function startGitCommonWatch( } } } - return startSnapshotDiffPoller( - () => snapshotGitCommonAndPrimaryMetadata(target.path), - onEvents, - pollIntervalMs, - onFullScan - ) + return startGitCommonPolling(target.path, onEvents, pollIntervalMs, onFullScan) } diff --git a/src/main/ipc/worktree-head-identity-reader.test.ts b/src/main/ipc/worktree-head-identity-reader.test.ts new file mode 100644 index 00000000000..c722d171ee0 --- /dev/null +++ b/src/main/ipc/worktree-head-identity-reader.test.ts @@ -0,0 +1,152 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { readGitCommonHeadIdentities } from './worktree-head-identity-reader' + +const OID_A = 'a'.repeat(40) +const OID_B = 'b'.repeat(40) +const OID_C = 'c'.repeat(40) + +describe('readGitCommonHeadIdentities', () => { + const roots: string[] = [] + + afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) + }) + + async function makeCommonDir(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-head-reader-')) + roots.push(root) + const commonDir = join(root, 'checkout', '.git') + await mkdir(commonDir, { recursive: true }) + return commonDir + } + + async function writeLooseRef(commonDir: string, ref: string, oid: string): Promise { + const refPath = join(commonDir, ...ref.split('/')) + await mkdir(dirname(refPath), { recursive: true }) + await writeFile(refPath, `${oid}\n`) + } + + async function addLinkedWorktree( + commonDir: string, + name: string, + worktreePath: string, + headContent: string + ): Promise { + const entry = join(commonDir, 'worktrees', name) + await mkdir(entry, { recursive: true }) + await writeFile(join(entry, 'HEAD'), `${headContent}\n`) + await writeFile(join(entry, 'gitdir'), `${join(worktreePath, '.git')}\n`) + } + + it('resolves the primary checkout and linked worktrees from loose refs', async () => { + const commonDir = await makeCommonDir() + await writeFile(join(commonDir, 'HEAD'), 'ref: refs/heads/main\n') + await writeLooseRef(commonDir, 'refs/heads/main', OID_A) + await writeLooseRef(commonDir, 'refs/heads/feature', OID_B) + const linkedPath = join(dirname(commonDir), '..', 'linked-wt') + await addLinkedWorktree(commonDir, 'linked-wt', linkedPath, 'ref: refs/heads/feature') + + const identities = await readGitCommonHeadIdentities(commonDir) + + expect(identities).toContainEqual({ + worktreePath: dirname(commonDir), + head: OID_A, + branch: 'refs/heads/main' + }) + expect(identities).toContainEqual({ + worktreePath: linkedPath, + head: OID_B, + branch: 'refs/heads/feature' + }) + }) + + it('falls back to packed-refs when the loose ref is absent', async () => { + const commonDir = await makeCommonDir() + await writeFile(join(commonDir, 'HEAD'), 'ref: refs/heads/main\n') + await writeFile( + join(commonDir, 'packed-refs'), + `# pack-refs with: peeled fully-peeled sorted\n${OID_C} refs/heads/main\n^${OID_A}\n` + ) + + const identities = await readGitCommonHeadIdentities(commonDir) + + expect(identities).toEqual([ + { worktreePath: dirname(commonDir), head: OID_C, branch: 'refs/heads/main' } + ]) + }) + + it('reports detached HEAD as a raw oid with a null branch', async () => { + const commonDir = await makeCommonDir() + await writeFile(join(commonDir, 'HEAD'), `${OID_B}\n`) + + const identities = await readGitCommonHeadIdentities(commonDir) + + expect(identities).toEqual([{ worktreePath: dirname(commonDir), head: OID_B, branch: null }]) + }) + + it('skips unborn branches instead of emitting partial identities', async () => { + const commonDir = await makeCommonDir() + await writeFile(join(commonDir, 'HEAD'), 'ref: refs/heads/unborn\n') + + expect(await readGitCommonHeadIdentities(commonDir)).toEqual([]) + }) + + it('resolves relative gitdir entries against the metadata dir', async () => { + const commonDir = await makeCommonDir() + await writeLooseRef(commonDir, 'refs/heads/feature', OID_B) + const entry = join(commonDir, 'worktrees', 'rel-wt') + await mkdir(entry, { recursive: true }) + await writeFile(join(entry, 'HEAD'), 'ref: refs/heads/feature\n') + await writeFile(join(entry, 'gitdir'), `${join('..', '..', '..', '..', 'rel-wt', '.git')}\n`) + + const identities = await readGitCommonHeadIdentities(commonDir) + + expect(identities).toEqual([ + { + worktreePath: join(dirname(commonDir), '..', 'rel-wt'), + head: OID_B, + branch: 'refs/heads/feature' + } + ]) + }) + + it('rejects traversal-shaped symrefs instead of reading outside the common dir', async () => { + const commonDir = await makeCommonDir() + // A crafted repo can put arbitrary content in HEAD; backslash segments + // would traverse on Windows where join treats them as separators too. + await writeFile(join(dirname(commonDir), '..', 'outside.txt'), 'secret\n') + for (const ref of [ + 'refs/heads/../../../outside.txt', + 'refs\\..\\..\\..\\outside.txt', + 'refs/heads/evil:name', + 'refs//heads' + ]) { + await writeFile(join(commonDir, 'HEAD'), `ref: ${ref}\n`) + expect(await readGitCommonHeadIdentities(commonDir)).toEqual([]) + } + }) + + it('never emits resolved content that is not a hex object id', async () => { + const commonDir = await makeCommonDir() + await writeFile(join(commonDir, 'HEAD'), 'ref: refs/heads/main\n') + await writeLooseRef(commonDir, 'refs/heads/main', 'not-an-object-id') + expect(await readGitCommonHeadIdentities(commonDir)).toEqual([]) + + await writeFile(join(commonDir, 'HEAD'), 'this is not a detached oid\n') + expect(await readGitCommonHeadIdentities(commonDir)).toEqual([]) + }) + + it('omits the primary row for non-standard common dir layouts', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-head-reader-')) + roots.push(root) + const commonDir = join(root, 'bare-repo') + await mkdir(commonDir, { recursive: true }) + await writeFile(join(commonDir, 'HEAD'), 'ref: refs/heads/main\n') + await writeLooseRef(commonDir, 'refs/heads/main', OID_A) + + expect(await readGitCommonHeadIdentities(commonDir)).toEqual([]) + }) +}) diff --git a/src/main/ipc/worktree-head-identity-reader.ts b/src/main/ipc/worktree-head-identity-reader.ts new file mode 100644 index 00000000000..e99de115480 --- /dev/null +++ b/src/main/ipc/worktree-head-identity-reader.ts @@ -0,0 +1,161 @@ +import { readdir, readFile } from 'node:fs/promises' +import { basename, dirname, isAbsolute, join } from 'node:path' +import type { WorktreeHeadIdentity } from '../../shared/types' + +// Why: the whole point of this reader is replacing `git worktree list` fanout +// with bounded metadata-file reads, so head freshness never re-creates the +// spawn pressure that stalled terminal input. Keep it spawn-free. + +const MAX_SYMREF_DEPTH = 5 + +async function readTrimmedFile(path: string): Promise { + try { + return (await readFile(path, 'utf8')).trim() + } catch { + return null + } +} + +// packed-refs lines are ` `; `#` headers and `^` peel lines skipped. +async function readPackedRefs(commonDirPath: string): Promise> { + const refs = new Map() + const content = await readTrimmedFile(join(commonDirPath, 'packed-refs')) + if (content === null) { + return refs + } + for (const line of content.split('\n')) { + if (!line || line.startsWith('#') || line.startsWith('^')) { + continue + } + const separator = line.indexOf(' ') + if (separator <= 0) { + continue + } + refs.set(line.slice(separator + 1).trim(), line.slice(0, separator)) + } + return refs +} + +// Why: ref content comes from repo files an attacker can craft. Git forbids +// `\` and `:` in ref names, and on Windows `join` also treats `\` as a +// separator — both must be rejected before splicing the ref into a file path. +function isSafeRefName(ref: string): boolean { + if (ref.length === 0 || ref.includes('\\') || ref.includes(':')) { + return false + } + return !ref.split('/').some((part) => part === '..' || part === '') +} + +// SHA-1 (40) or SHA-256 (64) object id. Anything else read from disk is not a +// head and must never be emitted — this also caps what any path escape could leak. +const OBJECT_ID_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/ + +function asObjectId(value: string | null | undefined): string | null { + return value != null && OBJECT_ID_PATTERN.test(value) ? value : null +} + +async function resolveRefToOid( + commonDirPath: string, + ref: string, + packedRefs: () => Promise> +): Promise { + let current = ref + for (let depth = 0; depth < MAX_SYMREF_DEPTH; depth++) { + if (!isSafeRefName(current)) { + return null + } + // Branch refs are shared repo state, so loose files live in the common dir. + const loose = await readTrimmedFile(join(commonDirPath, ...current.split('/'))) + if (loose === null) { + return asObjectId((await packedRefs()).get(current)) + } + if (loose.startsWith('ref: ')) { + current = loose.slice('ref: '.length).trim() + continue + } + return asObjectId(loose) + } + return null +} + +async function readHeadIdentity( + commonDirPath: string, + headFilePath: string, + worktreePath: string, + packedRefs: () => Promise> +): Promise { + const head = await readTrimmedFile(headFilePath) + if (!head) { + return null + } + if (head.startsWith('ref: ')) { + const ref = head.slice('ref: '.length).trim() + const oid = await resolveRefToOid(commonDirPath, ref, packedRefs) + // Unborn branches (no commit yet) stay covered by the structural listing. + if (!oid) { + return null + } + return { worktreePath, head: oid, branch: ref } + } + const detachedOid = asObjectId(head) + return detachedOid ? { worktreePath, head: detachedOid, branch: null } : null +} + +/** Reads head/branch for the primary checkout and every linked worktree of a + * Git common dir using only metadata-file reads (HEAD, gitdir, loose refs, + * packed-refs) — no Git subprocess. Unresolvable entries are skipped so + * callers never overwrite store state with partial reads. */ +export async function readGitCommonHeadIdentities( + commonDirPath: string +): Promise { + let packedRefsPromise: Promise> | null = null + const packedRefs = (): Promise> => + (packedRefsPromise ??= readPackedRefs(commonDirPath)) + + const identities: WorktreeHeadIdentity[] = [] + // Only the standard `/.git` layout maps a common dir back to its + // primary checkout path; bare/custom GIT_DIR layouts have no primary row. + if (basename(commonDirPath) === '.git') { + const primary = await readHeadIdentity( + commonDirPath, + join(commonDirPath, 'HEAD'), + dirname(commonDirPath), + packedRefs + ) + if (primary) { + identities.push(primary) + } + } + + let entries + try { + entries = await readdir(join(commonDirPath, 'worktrees'), { withFileTypes: true }) + } catch { + return identities + } + for (const entry of entries) { + if (!entry.isDirectory()) { + continue + } + const entryPath = join(commonDirPath, 'worktrees', entry.name) + const gitdirContent = await readTrimmedFile(join(entryPath, 'gitdir')) + if (!gitdirContent) { + continue + } + // `gitdir` holds `/.git`, absolute or (with relative-path + // worktrees) relative to the entry dir. + const gitdirAbsolute = isAbsolute(gitdirContent) + ? gitdirContent + : join(entryPath, gitdirContent) + const identity = await readHeadIdentity( + commonDirPath, + join(entryPath, 'HEAD'), + dirname(gitdirAbsolute), + packedRefs + ) + if (identity) { + identities.push(identity) + } + } + return identities +} diff --git a/src/main/ipc/worktree-head-identity-refresh.ts b/src/main/ipc/worktree-head-identity-refresh.ts new file mode 100644 index 00000000000..a5c1e7d846f --- /dev/null +++ b/src/main/ipc/worktree-head-identity-refresh.ts @@ -0,0 +1,79 @@ +import type { BrowserWindow } from 'electron' +import { notifyWorktreeHeadIdentitiesChanged } from './worktree-remote' +import { readGitCommonHeadIdentities } from './worktree-head-identity-reader' + +type HeadIdentityWatchHost = { + path: string + repos: ReadonlyMap + mainWindow: BrowserWindow + disposed: boolean +} + +export type WorktreeHeadIdentityRefreshState = { + /** worktreePath → `${head} ${branch}` from the last metadata-file read. */ + baseline: Map | null + inFlight: boolean + queued: boolean + queuedEmit: boolean +} + +export function createWorktreeHeadIdentityRefreshState(): WorktreeHeadIdentityRefreshState { + return { baseline: null, inFlight: false, queued: false, queuedEmit: false } +} + +function headIdentitySignature(identity: { head: string; branch: string | null }): string { + return `${identity.head} ${identity.branch ?? ''}` +} + +/** Diffs metadata-file head reads against the previous baseline and notifies + * only actual head moves, so status-only churn (index rewrites from external + * `git status`) stays silent and never re-enters structural fanout. Passing + * `emit: false` re-baselines without notifying — structural ticks already + * run the authoritative worktree listing. */ +export async function refreshWorktreeHeadIdentities( + host: HeadIdentityWatchHost, + state: WorktreeHeadIdentityRefreshState, + emit: boolean +): Promise { + if (host.disposed || host.mainWindow.isDestroyed()) { + return + } + if (state.inFlight) { + state.queued = true + state.queuedEmit ||= emit + return + } + state.inFlight = true + try { + const identities = await readGitCommonHeadIdentities(host.path) + if (host.disposed || host.mainWindow.isDestroyed()) { + return + } + const baseline = state.baseline + state.baseline = new Map( + identities.map((identity) => [identity.worktreePath, headIdentitySignature(identity)]) + ) + if (!baseline || !emit) { + return + } + const changed = identities.filter( + (identity) => baseline.get(identity.worktreePath) !== headIdentitySignature(identity) + ) + if (changed.length === 0) { + return + } + for (const repoId of host.repos.keys()) { + notifyWorktreeHeadIdentitiesChanged(host.mainWindow, repoId, changed) + } + } catch (error) { + console.warn(`[worktree-base-watcher] head identity read failed for ${host.path}:`, error) + } finally { + state.inFlight = false + if (state.queued && !host.disposed) { + const queuedEmit = state.queuedEmit + state.queued = false + state.queuedEmit = false + void refreshWorktreeHeadIdentities(host, state, queuedEmit) + } + } +} diff --git a/src/main/ipc/worktree-path-comparison.ts b/src/main/ipc/worktree-path-comparison.ts index 38bb0aef7f9..71b4055752e 100644 --- a/src/main/ipc/worktree-path-comparison.ts +++ b/src/main/ipc/worktree-path-comparison.ts @@ -33,6 +33,16 @@ export function areWorktreePathsEqual( return left === right } +export function worktreePathComparisonKey(pathValue: string, platform = process.platform): string { + if (looksLikePosixAbsolutePath(pathValue)) { + return `posix:${normalizePosixWorktreePathForComparison(pathValue, platform)}` + } + if (platform === 'win32' || isWindowsAbsolutePathLike(pathValue)) { + return `windows:${normalizeWindowsWorktreePathForComparison(pathValue)}` + } + return `posix:${normalizePosixWorktreePathForComparison(pathValue, platform)}` +} + export function dedupeWorktreesByPath( worktrees: readonly T[], platform = process.platform diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index e0fd8ee8fbc..8c8d4580d24 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -22,6 +22,7 @@ import type { LocalBaseRefUpdateSuggestion, Repo, Worktree, + WorktreeHeadIdentity, WorktreeMeta } from '../../shared/types' import { getPRForBranch } from '../github/client' @@ -1516,6 +1517,30 @@ export function notifyWorktreesChanged(mainWindow: BrowserWindow, repoId: string } } +export function notifyWorktreeGitStatusMetadataChanged( + mainWindow: BrowserWindow, + repoId: string +): void { + // Why: index churn is a Source Control freshness hint, not a worktree graph + // mutation; keep structural caches and runtime/mobile events untouched. + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('worktrees:gitStatusMetadataChanged', { repoId }) + } +} + +export function notifyWorktreeHeadIdentitiesChanged( + mainWindow: BrowserWindow, + repoId: string, + identities: WorktreeHeadIdentity[] +): void { + // Why: background worktrees have no active-scoped status refresh, so head + // moves detected from metadata files ride this targeted desktop event + // instead of re-entering the structural fanout or runtime/mobile events. + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('worktrees:headIdentitiesChanged', { repoId, identities }) + } +} + // Why: two-phase spinner. Main process fires `'fetching'` before waiting on // pre-create fetch work and `'creating'` immediately before `git worktree add`. // Renderer swaps its spinner label in response; fallback is the static @@ -1903,8 +1928,8 @@ export async function createRemoteWorktree( }) const workspaceLineage = recordWorkspaceLineageForCreatedWorktree(store, args, worktree, now) - // Why: `experimentalWorktreeSymlinks` is intentionally not wired up for - // remote (SSH) worktrees. Creating symlinks on the remote host would + // Why: shared paths are intentionally not wired up for remote (SSH) + // worktrees. Creating symlinks on the remote host would // require a new relay method and authorization surface; the feature is // local-only until that protocol work is in scope. Remote repos with // `symlinkPaths` configured have them silently ignored here. @@ -2536,10 +2561,8 @@ export async function createLocalWorktree( // Why: materialize user-configured paths from the primary checkout into the // new worktree before any setup script runs, so scripts that reuse shared // state (e.g. `node_modules`, `.env`) see those paths already in place. - // Gated on the experimental flag so disabling the feature globally skips - // the work even when a repo still has paths configured. const symlinkPaths = repo.symlinkPaths ?? [] - if (settings.experimentalWorktreeSymlinks && symlinkPaths.length > 0) { + if (symlinkPaths.length > 0) { await timing.time('create_symlinks', async () => { await createWorktreeLinkedPaths(repo.path, created.path, symlinkPaths) }) diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index db5bf3b1a4a..b3be97e39d7 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -246,7 +246,11 @@ import { notifyWorktreesChanged } from './worktree-remote' import { invalidateAuthorizedRootsCache, resolveRegisteredWorktreePath } from './filesystem-auth' -import { __resetDetectedWorktreeScanCacheForTests, registerWorktreeHandlers } from './worktrees' +import { + __getDetectedWorktreeScanCacheStatsForTests, + __resetDetectedWorktreeScanCacheForTests, + registerWorktreeHandlers +} from './worktrees' type HandlerMap = Record unknown> @@ -286,6 +290,9 @@ describe('registerWorktreeHandlers', () => { createTerminal: ReturnType splitTerminal: ReturnType notifyWorktreesChangedForRemoteClients: ReturnType + runWithWorktreeCreateAdmission: ReturnType + runWithWorktreeRemovalAdmission: ReturnType + verifyTerminalsStoppedForRemoval: ReturnType } beforeEach(() => { @@ -497,7 +504,14 @@ describe('registerWorktreeHandlers', () => { tabId: 'tab-startup', paneRuntimeId: -1 }), - notifyWorktreesChangedForRemoteClients: vi.fn() + notifyWorktreesChangedForRemoteClients: vi.fn(), + runWithWorktreeCreateAdmission: vi.fn( + async (_repoId: string, action: () => Promise) => action() + ), + runWithWorktreeRemovalAdmission: vi.fn( + async (_worktreeId: string, action: () => Promise) => action() + ), + verifyTerminalsStoppedForRemoval: vi.fn().mockResolvedValue(undefined) } registerWorktreeHandlers(mainWindow as never, store as never, runtimeStub as never) }) @@ -507,6 +521,47 @@ describe('registerWorktreeHandlers', () => { expect(handlers['worktrees:resolveMrBase']).toBeDefined() }) + it('clears the branch rename failure-output handler before re-registering IPC handlers', () => { + expect(removeHandlerMock).toHaveBeenCalledWith('worktrees:getBranchRenameFailureOutput') + expect(handlers['worktrees:getBranchRenameFailureOutput']).toBeDefined() + }) + + it('qualifies duplicate repo create admission with the requested owner host', async () => { + const admissionError = new Error('project removal in progress') + store.getRepos.mockReturnValue([ + { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'local', + badgeColor: '#000', + addedAt: 0 + }, + { + id: 'repo-1', + path: '/remote/repo', + displayName: 'remote', + badgeColor: '#000', + addedAt: 1, + connectionId: 'target-1' + } + ]) + runtimeStub.runWithWorktreeCreateAdmission.mockRejectedValueOnce(admissionError) + + await expect( + handlers['worktrees:create'](null, { + repoId: 'repo-1', + hostId: 'ssh:target-1', + name: 'feature' + }) + ).rejects.toBe(admissionError) + + expect(runtimeStub.runWithWorktreeCreateAdmission).toHaveBeenCalledWith( + 'repo-1', + expect.any(Function), + 'ssh:target-1' + ) + }) + it('prefetches the local default create base through the runtime refresh cache', async () => { const repo = { id: 'repo-1', @@ -612,6 +667,11 @@ describe('registerWorktreeHandlers', () => { { cwd: '/workspace/repo' } ) expect(runtimeStub.fetchRemoteWithCache).not.toHaveBeenCalled() + expect(runtimeStub.runWithWorktreeCreateAdmission).toHaveBeenCalledWith( + 'repo-1', + expect.any(Function), + 'local' + ) expect(addWorktreeMock).toHaveBeenCalledWith( '/workspace/repo', '/workspace/pr-title', @@ -2633,6 +2693,199 @@ describe('registerWorktreeHandlers', () => { expect(listWorktreesMock).toHaveBeenCalledTimes(1) }) + it('does not retain invalidated detected scans after they settle', async () => { + let resolveScan: (worktrees: GitWorktreeInfo[]) => void = () => {} + listWorktreesMock.mockImplementation( + () => + new Promise((resolve) => { + resolveScan = resolve as (worktrees: GitWorktreeInfo[]) => void + }) + ) + + const pendingList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + await Promise.resolve() + + expect(__getDetectedWorktreeScanCacheStatsForTests()).toMatchObject({ + cacheSize: 0, + inFlightSize: 1 + }) + + notifyWorktreesChanged(mainWindow as never, 'repo-1') + + expect(__getDetectedWorktreeScanCacheStatsForTests()).toMatchObject({ + cacheSize: 0, + inFlightSize: 0 + }) + + resolveScan([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + await pendingList + + expect(__getDetectedWorktreeScanCacheStatsForTests()).toMatchObject({ + cacheSize: 0, + inFlightSize: 0 + }) + }) + + it('does not accumulate scan bookkeeping across prolonged repository churn', async () => { + store.getRepo.mockImplementation((repoId: string) => ({ + id: repoId, + path: `/workspace/${repoId}`, + displayName: repoId, + badgeColor: '#000', + addedAt: 0, + worktreeBaseRef: null + })) + listWorktreesMock.mockImplementation(async (repoPath: string) => [ + { + path: repoPath, + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + + for (let index = 0; index < 128; index += 1) { + const repoId = `repo-${index}` + await handlers['worktrees:listDetected'](null, { repoId }) + notifyWorktreesChanged(mainWindow as never, repoId) + } + + expect(__getDetectedWorktreeScanCacheStatsForTests()).toEqual({ + cacheSize: 0, + inFlightSize: 0 + }) + expect(listWorktreesMock).toHaveBeenCalledTimes(128) + }) + + it('keeps a replacement scan current after an older scan settles first', async () => { + const resolvers: ((worktrees: GitWorktreeInfo[]) => void)[] = [] + listWorktreesMock.mockImplementation( + () => + new Promise((resolve) => { + resolvers.push(resolve as (worktrees: GitWorktreeInfo[]) => void) + }) + ) + const result = [ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ] + + const staleList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + await Promise.resolve() + notifyWorktreesChanged(mainWindow as never, 'repo-1') + const replacementList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + await Promise.resolve() + + resolvers[0](result) + await staleList + expect(__getDetectedWorktreeScanCacheStatsForTests()).toEqual({ + cacheSize: 0, + inFlightSize: 1 + }) + + resolvers[1](result) + await replacementList + expect(__getDetectedWorktreeScanCacheStatsForTests()).toEqual({ + cacheSize: 1, + inFlightSize: 0 + }) + }) + + it('does not let an older scan overwrite a replacement that settles first', async () => { + const resolvers: ((worktrees: GitWorktreeInfo[]) => void)[] = [] + listWorktreesMock.mockImplementation( + () => + new Promise((resolve) => { + resolvers.push(resolve as (worktrees: GitWorktreeInfo[]) => void) + }) + ) + store.getAllWorktreeLineage.mockReturnValue({ + 'repo-1::/workspace/fresh-worktree': { + worktreeId: 'repo-1::/workspace/fresh-worktree', + worktreeInstanceId: 'child-instance', + parentWorktreeId: 'repo-1::/workspace/repo', + parentWorktreeInstanceId: 'parent-instance', + origin: 'manual', + capture: { + source: 'manual-action', + confidence: 'explicit' + }, + createdAt: 0 + } + }) + const mainWorktree: GitWorktreeInfo = { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + + const staleList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + await Promise.resolve() + notifyWorktreesChanged(mainWindow as never, 'repo-1') + const replacementList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + await Promise.resolve() + + resolvers[1]([ + mainWorktree, + { + path: '/workspace/fresh-worktree', + head: 'fresh-head', + branch: 'refs/heads/fresh-worktree', + isBare: false, + isMainWorktree: false + } + ]) + await replacementList + + resolvers[0]([ + mainWorktree, + { + path: '/workspace/stale-worktree', + head: 'stale-head', + branch: 'refs/heads/stale-worktree', + isBare: false, + isMainWorktree: false + } + ]) + await staleList + + const cached = (await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + })) as { worktrees: Worktree[] } + expect(cached.worktrees.map((worktree) => worktree.path)).toEqual([ + '/workspace/repo', + '/workspace/fresh-worktree' + ]) + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + await expect( + resolveRegisteredWorktreePath('/workspace/fresh-worktree', store as never) + ).resolves.toBe(resolve('/workspace/fresh-worktree')) + await expect( + resolveRegisteredWorktreePath('/workspace/stale-worktree', store as never) + ).rejects.toThrow('Access denied: unknown repository or worktree path') + expect(listWorktreesMock).toHaveBeenCalledTimes(2) + expect(__getDetectedWorktreeScanCacheStatsForTests()).toEqual({ + cacheSize: 1, + inFlightSize: 0 + }) + }) + it('fetches the same-repo PR head via the SSH tracking-ref RPC, not git.exec', async () => { const fetchRemoteTrackingRef = vi.fn(async () => {}) const exec = vi.fn(async (args: string[]) => { @@ -6303,6 +6556,15 @@ describe('registerWorktreeHandlers', () => { expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { repoId: 'repo-1' }) + expect(runtimeStub.runWithWorktreeRemovalAdmission).toHaveBeenCalledWith( + 'repo-1::/workspace/feature-wt', + expect.any(Function), + 'local' + ) + expect(runtimeStub.verifyTerminalsStoppedForRemoval).toHaveBeenCalledWith( + 'repo-1::/workspace/feature-wt', + 'local' + ) }) it('recovers forced Windows long-path worktree removal through local deletion and prune', async () => { @@ -6449,6 +6711,7 @@ describe('registerWorktreeHandlers', () => { }) expect(runHookMock).not.toHaveBeenCalled() expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled() + expect(runtimeStub.verifyTerminalsStoppedForRemoval).toHaveBeenCalledWith(worktreeId, 'local') expect(removeWorktreeMock).not.toHaveBeenCalled() expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], { cwd: '/workspace/repo' @@ -7457,6 +7720,10 @@ describe('registerWorktreeHandlers', () => { expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled() expect(runHookMock).not.toHaveBeenCalled() expect(removeWorktreeMock).not.toHaveBeenCalled() + expect(runtimeStub.verifyTerminalsStoppedForRemoval).toHaveBeenCalledWith( + 'repo-1::/workspace/already-deleted-wt', + 'local' + ) expect(runtimeStub.clearOptimisticReconcileToken).toHaveBeenCalledWith( 'repo-1::/workspace/already-deleted-wt' ) diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index df0dfd4c585..48b7dab7518 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -5,6 +5,7 @@ import { readFile, stat } from 'node:fs/promises' import { randomUUID } from 'node:crypto' import type { Store } from '../persistence' import { isFolderRepo } from '../../shared/repo-kind' +import { readBranchRenameFailureOutputForDisplay } from '../agent-hooks/branch-rename-failure-output' import { isWorkspaceKey, parseWorkspaceKey, @@ -124,7 +125,10 @@ function getRepoForWorktreeRemoval( // Why: deletion must never guess between host owners. Legacy unscoped calls // remain compatible only while the repo id still has one unique owner. if (matches.length === 1) { - return matches[0] + const legacyMatch = store.getRepo(repoId) + return legacyMatch && (!hostId || getRepoExecutionHostId(legacyMatch) === hostId) + ? legacyMatch + : matches[0] } if (matches.length > 1) { return undefined @@ -469,37 +473,59 @@ type DetectedWorktreeScanCacheEntry = { worktrees: GitWorktreeInfo[] } +type DetectedWorktreeScan = { + invalidated: boolean + promise: Promise +} + type DetectedWorktreeScanResult = { gitWorktrees: GitWorktreeInfo[] fresh: boolean } const detectedWorktreeScanCache = new Map() -const detectedWorktreeScanInFlight = new Map>() -const detectedWorktreeScanGenerations = new Map() +const detectedWorktreeScanInFlight = new Map() function invalidateDetectedWorktreeScanCache(repoId: string): void { const keyPrefix = `${repoId}\0` for (const key of new Set([ ...detectedWorktreeScanCache.keys(), - ...detectedWorktreeScanInFlight.keys(), - ...detectedWorktreeScanGenerations.keys() + ...detectedWorktreeScanInFlight.keys() ])) { if (!key.startsWith(keyPrefix)) { continue } detectedWorktreeScanCache.delete(key) - detectedWorktreeScanInFlight.delete(key) - detectedWorktreeScanGenerations.set(key, (detectedWorktreeScanGenerations.get(key) ?? 0) + 1) + const inFlight = detectedWorktreeScanInFlight.get(key) + if (inFlight) { + // Why: the detached scan keeps this token, so later scans can settle + // without making an older result fresh again. + inFlight.invalidated = true + detectedWorktreeScanInFlight.delete(key) + } } } registerWorktreeChangeInvalidator(invalidateDetectedWorktreeScanCache) export function __resetDetectedWorktreeScanCacheForTests(): void { + // Why: scans still pending across a test reset must not repopulate the + // cache afterward and leak state into the next test. + for (const scan of detectedWorktreeScanInFlight.values()) { + scan.invalidated = true + } detectedWorktreeScanCache.clear() detectedWorktreeScanInFlight.clear() - detectedWorktreeScanGenerations.clear() +} + +export function __getDetectedWorktreeScanCacheStatsForTests(): { + cacheSize: number + inFlightSize: number +} { + return { + cacheSize: detectedWorktreeScanCache.size, + inFlightSize: detectedWorktreeScanInFlight.size + } } async function listDetectedGitWorktrees( @@ -522,24 +548,25 @@ async function listDetectedGitWorktrees( const inFlight = detectedWorktreeScanInFlight.get(cacheKey) if (inFlight) { - return { gitWorktrees: await inFlight, fresh: false } + return { gitWorktrees: await inFlight.promise, fresh: false } } - const scan = listRepoWorktrees(repo, localWorktreeGitOptions) - const generation = detectedWorktreeScanGenerations.get(cacheKey) ?? 0 + const scan: DetectedWorktreeScan = { + invalidated: false, + promise: listRepoWorktrees(repo, localWorktreeGitOptions) + } detectedWorktreeScanInFlight.set(cacheKey, scan) try { - const gitWorktrees = await scan + const gitWorktrees = await scan.promise // Why: a create/remove notification can invalidate while the git scan is // still running. Do not let that stale scan repopulate the cache afterward. - const isCurrentGeneration = (detectedWorktreeScanGenerations.get(cacheKey) ?? 0) === generation - if (isCurrentGeneration) { + if (!scan.invalidated) { detectedWorktreeScanCache.set(cacheKey, { worktrees: gitWorktrees, expiresAt: Date.now() + DETECTED_WORKTREE_SCAN_CACHE_TTL_MS }) } - return { gitWorktrees, fresh: isCurrentGeneration } + return { gitWorktrees, fresh: !scan.invalidated } } finally { if (detectedWorktreeScanInFlight.get(cacheKey) === scan) { detectedWorktreeScanInFlight.delete(cacheKey) @@ -947,6 +974,26 @@ export function registerWorktreeHandlers( store: Store, runtime: OrcaRuntimeService ): void { + const withCreateAdmission = ( + repoId: string, + action: () => Promise, + hostId?: ExecutionHostId + ): Promise => + typeof runtime.runWithWorktreeCreateAdmission === 'function' + ? runtime.runWithWorktreeCreateAdmission(repoId, action, hostId) + : action() + const withRemovalAdmission = ( + worktreeId: string, + action: () => Promise, + hostId?: ExecutionHostId + ): Promise => + typeof runtime.runWithWorktreeRemovalAdmission === 'function' + ? runtime.runWithWorktreeRemovalAdmission(worktreeId, action, hostId) + : action() + const verifyTerminalsStopped = (worktreeId: string, hostId?: ExecutionHostId): Promise => + typeof runtime.verifyTerminalsStoppedForRemoval === 'function' + ? runtime.verifyTerminalsStoppedForRemoval(worktreeId, hostId) + : Promise.resolve() // Remove any previously registered handlers so we can re-register them // (e.g. when macOS re-activates the app and creates a new window). ipcMain.removeHandler('worktrees:listAll') @@ -963,6 +1010,7 @@ export function registerWorktreeHandlers( ipcMain.removeHandler('worktrees:listLineage') ipcMain.removeHandler('worktrees:updateLineage') ipcMain.removeHandler('worktrees:persistSortOrder') + ipcMain.removeHandler('worktrees:getBranchRenameFailureOutput') ipcMain.removeHandler('hooks:check') ipcMain.removeHandler('hooks:inspectSetupScriptImports') ipcMain.removeHandler('hooks:createIssueCommandRunner') @@ -1195,6 +1243,11 @@ export function registerWorktreeHandlers( ipcMain.handle( 'worktrees:create', async (_event, args: CreateWorktreeArgs): Promise => { + const repo = getRepoForWorktreeRemoval(store, args.repoId, args.hostId) + if (!repo) { + throw new Error(`Repo not found: ${args.repoId}`) + } + const hostId = getRepoExecutionHostId(repo) // Why span here: worktree creation chains a clone-or-checkout, an // install hook, and several git invocations. Wrapping the IPC entry // gives every child git span a parent to attach to, so a failure in @@ -1203,72 +1256,72 @@ export function registerWorktreeHandlers( // attributes — branch names can carry user-content (e.g. an issue // title) and the redactor would have to learn yet another rule; // the repo ID is the safer correlator for the bundle. - return withWorktreeSpan({ stage: 'create' }, async () => { - const repo = store.getRepo(args.repoId) - if (!repo) { - throw new Error(`Repo not found: ${args.repoId}`) - } + return withCreateAdmission( + args.repoId, + () => + withWorktreeSpan({ stage: 'create' }, async () => { + const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource) + const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown' - const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource) - const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown' + const automationProvenance = resolveAutomationWorkspaceProvenance({ + authority: runtime, + repoSelector: args.repoId, + repo, + request: args.automationProvenanceRequest + }) + const createArgs: CreateWorktreeArgsWithSystemProvenance = { + ...args, + automationProvenance + } - const automationProvenance = resolveAutomationWorkspaceProvenance({ - authority: runtime, - repoSelector: args.repoId, - repo, - request: args.automationProvenanceRequest - }) - const createArgs: CreateWorktreeArgsWithSystemProvenance = { - ...args, - automationProvenance - } + let result: CreateWorktreeResult + try { + // Why: only wrap the helpers themselves. The pre-validation throws + // above (`Repo not found`, `Folder mode does not support creating + // worktrees`) signal IPC-shape bugs, not the user-visible + // git/filesystem failures the funnel cares about — bucketing them + // into `unknown` would pollute the failure taxonomy. + result = isFolderRepo(repo) + ? createFolderWorkspace(createArgs, repo, store) + : repo.connectionId + ? await createRemoteWorktree(createArgs, repo, store, mainWindow) + : await createLocalWorktree(createArgs, repo, store, mainWindow, runtime) + } catch (error) { + releaseAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) + track('workspace_create_failed', { + source, + error_class: classifyWorkspaceCreateError(error), + ...getCohortAtEmit() + }) + throw error + } + finishAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) - let result: CreateWorktreeResult - try { - // Why: only wrap the helpers themselves. The pre-validation throws - // above (`Repo not found`, `Folder mode does not support creating - // worktrees`) signal IPC-shape bugs, not the user-visible - // git/filesystem failures the funnel cares about — bucketing them - // into `unknown` would pollute the failure taxonomy. - result = isFolderRepo(repo) - ? createFolderWorkspace(createArgs, repo, store) - : repo.connectionId - ? await createRemoteWorktree(createArgs, repo, store, mainWindow) - : await createLocalWorktree(createArgs, repo, store, mainWindow, runtime) - } catch (error) { - releaseAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) - track('workspace_create_failed', { - source, - error_class: classifyWorkspaceCreateError(error), - ...getCohortAtEmit() - }) - throw error - } - finishAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) + // Why: emit `workspace_created` only after the underlying create has + // resolved (the helpers throw on failure, so reaching this line means + // git-add succeeded — we deliberately do not also emit a separate + // `workspace_initialized`, see telemetry-plan.md§Deferred events). + // `from_existing_branch` is true iff the caller specified a non-empty + // baseBranch; an unspecified baseBranch means "branch from default + // HEAD", which is the not-from-existing-branch case. We never send + // the branch name itself. + track('workspace_created', { + source, + from_existing_branch: + !isFolderRepo(repo) && + typeof args.baseBranch === 'string' && + args.baseBranch.length > 0, + ...getCohortAtEmit() + }) - // Why: emit `workspace_created` only after the underlying create has - // resolved (the helpers throw on failure, so reaching this line means - // git-add succeeded — we deliberately do not also emit a separate - // `workspace_initialized`, see telemetry-plan.md§Deferred events). - // `from_existing_branch` is true iff the caller specified a non-empty - // baseBranch; an unspecified baseBranch means "branch from default - // HEAD", which is the not-from-existing-branch case. We never send - // the branch name itself. - track('workspace_created', { - source, - from_existing_branch: - !isFolderRepo(repo) && - typeof args.baseBranch === 'string' && - args.baseBranch.length > 0, - ...getCohortAtEmit() - }) + if (isFolderRepo(repo)) { + notifyWorktreesChanged(mainWindow, repo.id) + } - if (isFolderRepo(repo)) { - notifyWorktreesChanged(mainWindow, repo.id) - } - - return result - }) + return result + }), + hostId + ) } ) @@ -1377,10 +1430,8 @@ export function registerWorktreeHandlers( if (!repo) { throw new Error(`Repo not found: ${repoId}`) } - const inFlightKey = getWorktreeRemovalInFlightKey( - args.worktreeId, - getRepoExecutionHostId(repo) - ) + const hostId = getRepoExecutionHostId(repo) + const inFlightKey = getWorktreeRemovalInFlightKey(args.worktreeId, hostId) const optionsKey = getWorktreeRemovalOptionsKey(args) const inFlightRemoval = worktreeRemovalsInFlight.get(inFlightKey) if (inFlightRemoval) { @@ -1393,135 +1444,450 @@ export function registerWorktreeHandlers( // Why: stale toast actions, double-clicks, and Space/sidebar races can // target the same worktree concurrently. Share the destructive backend // operation so only one path touches Git and the filesystem. - const removal = (async (): Promise => { - if (isFolderRepo(repo)) { - if (args.worktreeId === getFolderWorkspaceRootId(repo)) { - throw new Error( - 'Cannot delete the project root workspace. Remove the folder project instead.' - ) - } - // Why: folder workspaces share one filesystem root, so there is no Git - // remove step to close shells; sweep PTYs before dropping metadata. - await killAllProcessesForWorktree(args.worktreeId, { - runtime, - localProvider: getLocalPtyProvider(), - onPtyStopped: clearProviderPtyState - }).catch((err) => { - console.warn(`[worktree-teardown] failed for ${args.worktreeId}:`, err) - }) - removeWorktreeMetadataAndTransientState(store, args.worktreeId) - preservedBranchCleanupByWorktreeId.delete(args.worktreeId) - notifyWorktreesChanged(mainWindow, repoId) - return {} - } - - // Why: the renderer-supplied worktreeId contains a filesystem path. - // Re-derive the canonical path from git before any destructive action. - const provider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null - const localWorktreeGitOptions = repo.connectionId - ? {} - : getLocalProjectWorktreeGitOptions(store, repo) - const hasLocalWorktreeGitOptions = Object.keys(localWorktreeGitOptions).length > 0 - const registeredWorktrees = repo.connectionId - ? await provider!.listWorktrees(repo.path) - : hasLocalWorktreeGitOptions - ? await listGitWorktreesStrict(repo.path, localWorktreeGitOptions) - : await listGitWorktreesStrict(repo.path) - const removedMeta = store.getWorktreeMeta(args.worktreeId) - const removedPushTarget = removedMeta?.pushTarget - const registeredWorktree = findRegisteredDeletableWorktree( - repo.path, - worktreePath, - registeredWorktrees - ) - if (!registeredWorktree) { - const fsProvider = repo.connectionId ? getSshFilesystemProvider(repo.connectionId) : null - let canCleanOrphanedDirectory = false - if ( - canCleanupUnregisteredOrcaWorktreeDirectory({ - meta: removedMeta + const removal = withRemovalAdmission( + args.worktreeId, + async () => { + if (isFolderRepo(repo)) { + if (args.worktreeId === getFolderWorkspaceRootId(repo)) { + throw new Error( + 'Cannot delete the project root workspace. Remove the folder project instead.' + ) + } + await verifyTerminalsStopped(args.worktreeId, hostId) + // Why: folder workspaces share one filesystem root, so there is no Git + // remove step to close shells; sweep PTYs before dropping metadata. + await killAllProcessesForWorktree(args.worktreeId, { + runtime, + localProvider: getLocalPtyProvider(), + onPtyStopped: clearProviderPtyState + }).catch((err) => { + console.warn(`[worktree-teardown] failed for ${args.worktreeId}:`, err) }) - ) { - if (repo.connectionId) { - if (!fsProvider) { - throw new Error('SSH filesystem provider unavailable') - } - if (!fsProvider.lstat) { - throw new Error('SSH filesystem provider lstat unavailable') - } - canCleanOrphanedDirectory = await canSafelyRemoveOrphanedWorktreeDirectory( - worktreePath, - repo.path, - (path) => fsProvider.lstat!(path), - (path) => fsProvider.readFile(path) - ) - } else { - const access = getLocalWorktreePathAccess(localWorktreeGitOptions) - canCleanOrphanedDirectory = - !isDangerousWorktreeRemovalPath(worktreePath, repo.path) && - (await canSafelyRemoveOrphanedWorktreeDirectory( - toLocalWorktreeRuntimePath(worktreePath, localWorktreeGitOptions), - toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), - access.statPath, - access.readPath - )) - } - } - if (canCleanOrphanedDirectory) { - assertWorktreeDoesNotContainRegisteredWorktree(worktreePath, registeredWorktrees) - if (!args.force) { - throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) - } - if (repo.connectionId) { - await fsProvider!.deletePath(worktreePath, true) - await cleanupUnusedWorktreePushTargetRemoteSsh( - provider!, - repo.path, - args.worktreeId, - removedPushTarget, - store - ) - } else { - await closeLocalWatcherForRemoval(worktreePath) - await removeLocalWorktreePath(worktreePath, localWorktreeGitOptions) - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - args.worktreeId, - removedPushTarget, - store, - localWorktreeGitOptions - ) - invalidateAuthorizedRootsCache() - } - runtime.clearOptimisticReconcileToken(args.worktreeId) removeWorktreeMetadataAndTransientState(store, args.worktreeId) preservedBranchCleanupByWorktreeId.delete(args.worktreeId) notifyWorktreesChanged(mainWindow, repoId) return {} } - if (!repo.connectionId) { - const access = getLocalWorktreePathAccess(localWorktreeGitOptions) - const runtimeWorktreePath = toLocalWorktreeRuntimePath( - worktreePath, - localWorktreeGitOptions - ) + + // Why: the renderer-supplied worktreeId contains a filesystem path. + // Re-derive the canonical path from git before any destructive action. + const provider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null + const localWorktreeGitOptions = repo.connectionId + ? {} + : getLocalProjectWorktreeGitOptions(store, repo) + const hasLocalWorktreeGitOptions = Object.keys(localWorktreeGitOptions).length > 0 + const registeredWorktrees = repo.connectionId + ? await provider!.listWorktrees(repo.path) + : hasLocalWorktreeGitOptions + ? await listGitWorktreesStrict(repo.path, localWorktreeGitOptions) + : await listGitWorktreesStrict(repo.path) + const removedMeta = store.getWorktreeMeta(args.worktreeId) + const removedPushTarget = removedMeta?.pushTarget + const registeredWorktree = findRegisteredDeletableWorktree( + repo.path, + worktreePath, + registeredWorktrees + ) + if (!registeredWorktree) { + const fsProvider = repo.connectionId + ? getSshFilesystemProvider(repo.connectionId) + : null + let canCleanOrphanedDirectory = false if ( - await canCleanupUnregisteredOrcaLeftoverDirectory({ - meta: removedMeta, - worktreePath, - runtimeWorktreePath, - repo, - runtimeRepoPath: toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), - registeredWorktrees, - statPath: access.statPath, - isGitRepository: (path) => isLocalGitRepository(path, localWorktreeGitOptions) + canCleanupUnregisteredOrcaWorktreeDirectory({ + meta: removedMeta }) ) { + if (repo.connectionId) { + if (!fsProvider) { + throw new Error('SSH filesystem provider unavailable') + } + if (!fsProvider.lstat) { + throw new Error('SSH filesystem provider lstat unavailable') + } + canCleanOrphanedDirectory = await canSafelyRemoveOrphanedWorktreeDirectory( + worktreePath, + repo.path, + (path) => fsProvider.lstat!(path), + (path) => fsProvider.readFile(path) + ) + } else { + const access = getLocalWorktreePathAccess(localWorktreeGitOptions) + canCleanOrphanedDirectory = + !isDangerousWorktreeRemovalPath(worktreePath, repo.path) && + (await canSafelyRemoveOrphanedWorktreeDirectory( + toLocalWorktreeRuntimePath(worktreePath, localWorktreeGitOptions), + toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), + access.statPath, + access.readPath + )) + } + } + if (canCleanOrphanedDirectory) { + assertWorktreeDoesNotContainRegisteredWorktree(worktreePath, registeredWorktrees) if (!args.force) { throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) } - await closeLocalWatcherForRemoval(worktreePath) - await removeLocalWorktreePath(worktreePath, localWorktreeGitOptions) + await verifyTerminalsStopped(args.worktreeId, hostId) + if (repo.connectionId) { + await fsProvider!.deletePath(worktreePath, true) + await cleanupUnusedWorktreePushTargetRemoteSsh( + provider!, + repo.path, + args.worktreeId, + removedPushTarget, + store + ) + } else { + await closeLocalWatcherForRemoval(worktreePath) + await removeLocalWorktreePath(worktreePath, localWorktreeGitOptions) + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + args.worktreeId, + removedPushTarget, + store, + localWorktreeGitOptions + ) + invalidateAuthorizedRootsCache() + } + runtime.clearOptimisticReconcileToken(args.worktreeId) + removeWorktreeMetadataAndTransientState(store, args.worktreeId) + preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + notifyWorktreesChanged(mainWindow, repoId) + return {} + } + if (!repo.connectionId) { + const access = getLocalWorktreePathAccess(localWorktreeGitOptions) + const runtimeWorktreePath = toLocalWorktreeRuntimePath( + worktreePath, + localWorktreeGitOptions + ) + if ( + await canCleanupUnregisteredOrcaLeftoverDirectory({ + meta: removedMeta, + worktreePath, + runtimeWorktreePath, + repo, + runtimeRepoPath: toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), + registeredWorktrees, + statPath: access.statPath, + isGitRepository: (path) => isLocalGitRepository(path, localWorktreeGitOptions) + }) + ) { + if (!args.force) { + throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) + } + await verifyTerminalsStopped(args.worktreeId, hostId) + await closeLocalWatcherForRemoval(worktreePath) + await removeLocalWorktreePath(worktreePath, localWorktreeGitOptions) + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + args.worktreeId, + removedPushTarget, + store, + localWorktreeGitOptions + ) + runtime.clearOptimisticReconcileToken(args.worktreeId) + removeWorktreeMetadataAndTransientState(store, args.worktreeId) + preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + invalidateAuthorizedRootsCache() + notifyWorktreesChanged(mainWindow, repoId) + return {} + } + } + if (await isAlreadyRemovedWorktreePath(repo, worktreePath, localWorktreeGitOptions)) { + if (!args.force && !removedMeta) { + // Why: without persisted metadata, require the renderer recovery + // path before deleting Orca-only state for an unregistered path. + throw new Error(UNREGISTERED_MISSING_WORKTREE_MESSAGE) + } + // Why: a manually deleted worktree is already gone from Git and disk. + // The sidebar delete action has persisted metadata proving this was + // an Orca-known row, so no force confirmation is needed. + await verifyTerminalsStopped(args.worktreeId, hostId) + if (repo.connectionId) { + await cleanupUnusedWorktreePushTargetRemoteSsh( + provider!, + repo.path, + args.worktreeId, + removedPushTarget, + store + ) + } else { + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + args.worktreeId, + removedPushTarget, + store, + localWorktreeGitOptions + ) + invalidateAuthorizedRootsCache() + } + runtime.clearOptimisticReconcileToken(args.worktreeId) + removeWorktreeMetadataAndTransientState(store, args.worktreeId) + preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + notifyWorktreesChanged(mainWindow, repoId) + return {} + } + throw new Error(`Refusing to delete unregistered worktree path: ${worktreePath}`) + } + const canonicalWorktreePath = registeredWorktree.path + const deleteBranch = removedMeta?.preserveBranchOnDelete !== true + + // Why: a Git lock must block before archive hooks or linked-path cleanup + // mutate the workspace; dirty-file force is a separate permission. + try { + assertWorktreeUnlockedForRemoval(registeredWorktree) + } catch (error) { + throw new Error( + formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false) + ) + } + + // Why: a prior forced Windows recovery can delete the directory but leave + // Git's stale registration; recover and verify it before clearing metadata. + if ( + !repo.connectionId && + args.force === true && + process.platform === 'win32' && + (isWindowsAbsolutePathLike(canonicalWorktreePath) || + !!localWorktreeGitOptions.wslDistro) && + removedMeta && + (await isAlreadyRemovedWorktreePath( + repo, + canonicalWorktreePath, + localWorktreeGitOptions + )) + ) { + await verifyTerminalsStopped(args.worktreeId, hostId) + const removalResult = await removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval({ + canonicalWorktreePath, + repoPath: repo.path, + localWorktreeGitOptions, + registeredWorktree, + deleteBranch + }) + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + args.worktreeId, + removedPushTarget, + store, + localWorktreeGitOptions + ) + rememberPreservedBranchCleanupTarget( + args.worktreeId, + removalResult, + registeredWorktree.head, + removedPushTarget + ) + runtime.clearOptimisticReconcileToken(args.worktreeId) + removeWorktreeMetadataAndTransientState(store, args.worktreeId) + invalidateAuthorizedRootsCache() + notifyWorktreesChanged(mainWindow, repoId) + return removalResult ?? {} + } + + let shouldTearDownPtys = true + + // Run archive hook before removal so teardown scripts still see the worktree directory. + const hooks = await getArchiveHooksForRemoval(repo) + if (hooks?.scripts.archive && !args.skipArchive) { + const result = repo.connectionId + ? await runRemoteArchiveHook(repo, canonicalWorktreePath, hooks.scripts.archive) + : await runHook( + 'archive', + canonicalWorktreePath, + repo, + undefined, + localWorktreeGitOptions + ) + if (!result.success) { + console.error( + `[hooks] archive hook failed for ${canonicalWorktreePath}:`, + result.output + ) + } + } + + if (repo.connectionId) { + // Why: SSH deletion mirrors the local flow: hooks run while the + // directory is intact, then the clean check guards destructive removal. + if (!args.force) { + const { clean, stdout } = await provider!.worktreeIsClean(canonicalWorktreePath) + if (!clean) { + const error = new Error('Worktree has uncommitted or untracked changes.') + ;(error as Error & { stdout?: string }).stdout = stdout + throw error + } + } + + await verifyTerminalsStopped(args.worktreeId, hostId) + const remoteRemoveOptions = !deleteBranch ? { deleteBranch } : {} + const rawRemovalResult = await (Object.keys(remoteRemoveOptions).length > 0 + ? provider!.removeWorktree(canonicalWorktreePath, args.force, remoteRemoveOptions) + : provider!.removeWorktree(canonicalWorktreePath, args.force)) + const removalResult = preserveBranchHeadFallback( + rawRemovalResult, + registeredWorktree.head + ) + await cleanupUnusedWorktreePushTargetRemoteSsh( + provider!, + repo.path, + args.worktreeId, + removedPushTarget, + store + ) + rememberPreservedBranchCleanupTarget( + args.worktreeId, + removalResult, + registeredWorktree.head, + removedPushTarget + ) + runtime.clearOptimisticReconcileToken(args.worktreeId) + removeWorktreeMetadataAndTransientState(store, args.worktreeId) + notifyWorktreesChanged(mainWindow, repoId) + return removalResult ?? {} + } + + const refreshedWorktrees = hasLocalWorktreeGitOptions + ? await listGitWorktreesStrict(repo.path, localWorktreeGitOptions) + : await listGitWorktreesStrict(repo.path) + const refreshedRegisteredWorktree = findRegisteredDeletableWorktree( + repo.path, + canonicalWorktreePath, + refreshedWorktrees + ) + if (!refreshedRegisteredWorktree) { + throw new Error( + `Worktree registration changed during deletion: ${canonicalWorktreePath}. Retry deletion.` + ) + } + try { + // Why: an archive hook can race another Git client that locks the row; + // recheck before linked-path, watcher, or terminal teardown side effects. + assertWorktreeUnlockedForRemoval(refreshedRegisteredWorktree) + } catch (error) { + throw new Error( + formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false) + ) + } + + // Why: `git worktree remove` (non-force) refuses to delete a worktree + // that has untracked files. User-configured symlinks look untracked, + // so unlink them before the clean check; regular APFS clone-copied + // files are left for git to judge so we never delete user edits. + if (repo.symlinkPaths && repo.symlinkPaths.length > 0) { + await removeWorktreeLinkedPaths(canonicalWorktreePath, repo.symlinkPaths) + } + + try { + await (hasLocalWorktreeGitOptions + ? assertWorktreeCleanForRemoval( + canonicalWorktreePath, + args.force ?? false, + localWorktreeGitOptions + ) + : assertWorktreeCleanForRemoval(canonicalWorktreePath, args.force ?? false)) + } catch (error) { + if (!isOrphanCompatiblePreflightError(error)) { + throw new Error( + formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false) + ) + } + // Why: orphan cleanup does not need live shells to be killed first, + // and preflight did not prove the worktree is cleanly removable. + shouldTearDownPtys = false + } + + await closeLocalWatcherForRemoval(canonicalWorktreePath) + + await verifyTerminalsStopped(args.worktreeId, hostId) + if (shouldTearDownPtys) { + // Why: once preflight proves normal deletion is clean, kill PTYs before + // git-level removal so Windows handles cannot keep the directory busy. + await killAllProcessesForWorktree(args.worktreeId, { + runtime, + localProvider: getLocalPtyProvider(), + onPtyStopped: clearProviderPtyState + }) + .then((r) => { + const total = r.runtimeStopped + r.providerStopped + r.registryStopped + if (total > 0) { + console.info( + `[worktree-teardown] ${args.worktreeId} killed runtime=${r.runtimeStopped} provider=${r.providerStopped} registry=${r.registryStopped}` + ) + } + }) + .catch((err) => { + console.warn(`[worktree-teardown] failed for ${args.worktreeId}:`, err) + }) + } + + let removalResult: RemoveWorktreeResult | undefined + try { + const removeOptions = { + ...(!deleteBranch ? { deleteBranch } : {}), + // Why: this handler already paid for an authoritative worktree + // list to validate the target; reuse it instead of rescanning + // every sibling worktree during the hot delete path. + knownRemovedWorktree: refreshedRegisteredWorktree, + ...(hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {}) + } + removalResult = preserveBranchHeadFallback( + await removeWorktree( + repo.path, + canonicalWorktreePath, + args.force ?? false, + removeOptions + ), + refreshedRegisteredWorktree.head + ) + } catch (error) { + // Why: Git for Windows can deregister a clean worktree before its + // recursive filesystem deletion fails transiently. + const recoveredRemovalResult = await recoverLocalWindowsWorktreeRemoval({ + error, + force: args.force ?? false, + canonicalWorktreePath, + repoPath: repo.path, + localWorktreeGitOptions, + registeredWorktree: refreshedRegisteredWorktree, + deleteBranch, + closeWatcher: closeLocalWatcherForRemoval + }) + if (recoveredRemovalResult) { + removalResult = recoveredRemovalResult + } else if (isOrphanedWorktreeError(error)) { + // If git no longer tracks this worktree, clean up the directory and metadata + console.warn( + `[worktrees] Orphaned worktree detected at ${canonicalWorktreePath}, cleaning up` + ) + const access = getLocalWorktreePathAccess(localWorktreeGitOptions) + if ( + await canSafelyRemoveOrphanedWorktreeDirectory( + toLocalWorktreeRuntimePath(canonicalWorktreePath, localWorktreeGitOptions), + toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), + access.statPath, + access.readPath + ) + ) { + await closeLocalWatcherForRemoval(canonicalWorktreePath) + await removeLocalWorktreePath(canonicalWorktreePath, localWorktreeGitOptions).catch( + () => {} + ) + } else { + console.warn( + `[worktrees] Refusing recursive cleanup for unproven worktree directory: ${canonicalWorktreePath}` + ) + } + // Why: `git worktree remove` failed, so git's internal worktree tracking + // (`.git/worktrees/`) is still intact. Without pruning, `git worktree + // list` continues to show the stale entry and the branch it had checked out + // remains locked — other worktrees cannot check it out. + await gitExecFileAsync(['worktree', 'prune'], { + cwd: repo.path, + ...localWorktreeGitOptions + }).catch(() => {}) await cleanupUnusedWorktreePushTargetRemote( repo.path, args.worktreeId, @@ -1535,74 +1901,12 @@ export function registerWorktreeHandlers( invalidateAuthorizedRootsCache() notifyWorktreesChanged(mainWindow, repoId) return {} - } - } - if (await isAlreadyRemovedWorktreePath(repo, worktreePath, localWorktreeGitOptions)) { - if (!args.force && !removedMeta) { - // Why: without persisted metadata, require the renderer recovery - // path before deleting Orca-only state for an unregistered path. - throw new Error(UNREGISTERED_MISSING_WORKTREE_MESSAGE) - } - // Why: a manually deleted worktree is already gone from Git and disk. - // The sidebar delete action has persisted metadata proving this was - // an Orca-known row, so no force confirmation is needed. - if (repo.connectionId) { - await cleanupUnusedWorktreePushTargetRemoteSsh( - provider!, - repo.path, - args.worktreeId, - removedPushTarget, - store - ) } else { - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - args.worktreeId, - removedPushTarget, - store, - localWorktreeGitOptions + throw new Error( + formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false) ) - invalidateAuthorizedRootsCache() } - runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState(store, args.worktreeId) - preservedBranchCleanupByWorktreeId.delete(args.worktreeId) - notifyWorktreesChanged(mainWindow, repoId) - return {} } - throw new Error(`Refusing to delete unregistered worktree path: ${worktreePath}`) - } - const canonicalWorktreePath = registeredWorktree.path - const deleteBranch = removedMeta?.preserveBranchOnDelete !== true - - // Why: a Git lock must block before archive hooks or linked-path cleanup - // mutate the workspace; dirty-file force is a separate permission. - try { - assertWorktreeUnlockedForRemoval(registeredWorktree) - } catch (error) { - throw new Error( - formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false) - ) - } - - // Why: a prior forced Windows recovery can delete the directory but leave - // Git's stale registration; recover and verify it before clearing metadata. - if ( - !repo.connectionId && - args.force === true && - process.platform === 'win32' && - (isWindowsAbsolutePathLike(canonicalWorktreePath) || - !!localWorktreeGitOptions.wslDistro) && - removedMeta && - (await isAlreadyRemovedWorktreePath(repo, canonicalWorktreePath, localWorktreeGitOptions)) - ) { - const removalResult = await removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval({ - canonicalWorktreePath, - repoPath: repo.path, - localWorktreeGitOptions, - registeredWorktree, - deleteBranch - }) await cleanupUnusedWorktreePushTargetRemote( repo.path, args.worktreeId, @@ -1613,254 +1917,18 @@ export function registerWorktreeHandlers( rememberPreservedBranchCleanupTarget( args.worktreeId, removalResult, - registeredWorktree.head, + refreshedRegisteredWorktree.head, removedPushTarget ) runtime.clearOptimisticReconcileToken(args.worktreeId) removeWorktreeMetadataAndTransientState(store, args.worktreeId) invalidateAuthorizedRootsCache() + notifyWorktreesChanged(mainWindow, repoId) return removalResult ?? {} - } - - let shouldTearDownPtys = true - - // Run archive hook before removal so teardown scripts still see the worktree directory. - const hooks = await getArchiveHooksForRemoval(repo) - if (hooks?.scripts.archive && !args.skipArchive) { - const result = repo.connectionId - ? await runRemoteArchiveHook(repo, canonicalWorktreePath, hooks.scripts.archive) - : await runHook( - 'archive', - canonicalWorktreePath, - repo, - undefined, - localWorktreeGitOptions - ) - if (!result.success) { - console.error( - `[hooks] archive hook failed for ${canonicalWorktreePath}:`, - result.output - ) - } - } - - if (repo.connectionId) { - // Why: SSH deletion mirrors the local flow: hooks run while the - // directory is intact, then the clean check guards destructive removal. - if (!args.force) { - const { clean, stdout } = await provider!.worktreeIsClean(canonicalWorktreePath) - if (!clean) { - const error = new Error('Worktree has uncommitted or untracked changes.') - ;(error as Error & { stdout?: string }).stdout = stdout - throw error - } - } - - const remoteRemoveOptions = !deleteBranch ? { deleteBranch } : {} - const rawRemovalResult = await (Object.keys(remoteRemoveOptions).length > 0 - ? provider!.removeWorktree(canonicalWorktreePath, args.force, remoteRemoveOptions) - : provider!.removeWorktree(canonicalWorktreePath, args.force)) - const removalResult = preserveBranchHeadFallback( - rawRemovalResult, - registeredWorktree.head - ) - await cleanupUnusedWorktreePushTargetRemoteSsh( - provider!, - repo.path, - args.worktreeId, - removedPushTarget, - store - ) - rememberPreservedBranchCleanupTarget( - args.worktreeId, - removalResult, - registeredWorktree.head, - removedPushTarget - ) - runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState(store, args.worktreeId) - notifyWorktreesChanged(mainWindow, repoId) - return removalResult ?? {} - } - - const refreshedWorktrees = hasLocalWorktreeGitOptions - ? await listGitWorktreesStrict(repo.path, localWorktreeGitOptions) - : await listGitWorktreesStrict(repo.path) - const refreshedRegisteredWorktree = findRegisteredDeletableWorktree( - repo.path, - canonicalWorktreePath, - refreshedWorktrees - ) - if (!refreshedRegisteredWorktree) { - throw new Error( - `Worktree registration changed during deletion: ${canonicalWorktreePath}. Retry deletion.` - ) - } - try { - // Why: an archive hook can race another Git client that locks the row; - // recheck before linked-path, watcher, or terminal teardown side effects. - assertWorktreeUnlockedForRemoval(refreshedRegisteredWorktree) - } catch (error) { - throw new Error( - formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false) - ) - } - - // Why: `git worktree remove` (non-force) refuses to delete a worktree - // that has untracked files. User-configured symlinks look untracked, - // so unlink them before the clean check; regular APFS clone-copied - // files are left for git to judge so we never delete user edits. - if (repo.symlinkPaths && repo.symlinkPaths.length > 0) { - await removeWorktreeLinkedPaths(canonicalWorktreePath, repo.symlinkPaths) - } - - try { - await (hasLocalWorktreeGitOptions - ? assertWorktreeCleanForRemoval( - canonicalWorktreePath, - args.force ?? false, - localWorktreeGitOptions - ) - : assertWorktreeCleanForRemoval(canonicalWorktreePath, args.force ?? false)) - } catch (error) { - if (!isOrphanCompatiblePreflightError(error)) { - throw new Error( - formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false) - ) - } - // Why: orphan cleanup does not need live shells to be killed first, - // and preflight did not prove the worktree is cleanly removable. - shouldTearDownPtys = false - } - - await closeLocalWatcherForRemoval(canonicalWorktreePath) - - if (shouldTearDownPtys) { - // Why: once preflight proves normal deletion is clean, kill PTYs before - // git-level removal so Windows handles cannot keep the directory busy. - await killAllProcessesForWorktree(args.worktreeId, { - runtime, - localProvider: getLocalPtyProvider(), - onPtyStopped: clearProviderPtyState - }) - .then((r) => { - const total = r.runtimeStopped + r.providerStopped + r.registryStopped - if (total > 0) { - console.info( - `[worktree-teardown] ${args.worktreeId} killed runtime=${r.runtimeStopped} provider=${r.providerStopped} registry=${r.registryStopped}` - ) - } - }) - .catch((err) => { - console.warn(`[worktree-teardown] failed for ${args.worktreeId}:`, err) - }) - } - - let removalResult: RemoveWorktreeResult | undefined - try { - const removeOptions = { - ...(!deleteBranch ? { deleteBranch } : {}), - // Why: this handler already paid for an authoritative worktree - // list to validate the target; reuse it instead of rescanning - // every sibling worktree during the hot delete path. - knownRemovedWorktree: refreshedRegisteredWorktree, - ...(hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {}) - } - removalResult = preserveBranchHeadFallback( - await removeWorktree( - repo.path, - canonicalWorktreePath, - args.force ?? false, - removeOptions - ), - refreshedRegisteredWorktree.head - ) - } catch (error) { - // Why: Git for Windows can deregister a clean worktree before its - // recursive filesystem deletion fails transiently. - const recoveredRemovalResult = await recoverLocalWindowsWorktreeRemoval({ - error, - force: args.force ?? false, - canonicalWorktreePath, - repoPath: repo.path, - localWorktreeGitOptions, - registeredWorktree: refreshedRegisteredWorktree, - deleteBranch, - closeWatcher: closeLocalWatcherForRemoval - }) - if (recoveredRemovalResult) { - removalResult = recoveredRemovalResult - } else if (isOrphanedWorktreeError(error)) { - // If git no longer tracks this worktree, clean up the directory and metadata - console.warn( - `[worktrees] Orphaned worktree detected at ${canonicalWorktreePath}, cleaning up` - ) - const access = getLocalWorktreePathAccess(localWorktreeGitOptions) - if ( - await canSafelyRemoveOrphanedWorktreeDirectory( - toLocalWorktreeRuntimePath(canonicalWorktreePath, localWorktreeGitOptions), - toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), - access.statPath, - access.readPath - ) - ) { - await closeLocalWatcherForRemoval(canonicalWorktreePath) - await removeLocalWorktreePath(canonicalWorktreePath, localWorktreeGitOptions).catch( - () => {} - ) - } else { - console.warn( - `[worktrees] Refusing recursive cleanup for unproven worktree directory: ${canonicalWorktreePath}` - ) - } - // Why: `git worktree remove` failed, so git's internal worktree tracking - // (`.git/worktrees/`) is still intact. Without pruning, `git worktree - // list` continues to show the stale entry and the branch it had checked out - // remains locked — other worktrees cannot check it out. - await gitExecFileAsync(['worktree', 'prune'], { - cwd: repo.path, - ...localWorktreeGitOptions - }).catch(() => {}) - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - args.worktreeId, - removedPushTarget, - store, - localWorktreeGitOptions - ) - runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState(store, args.worktreeId) - preservedBranchCleanupByWorktreeId.delete(args.worktreeId) - invalidateAuthorizedRootsCache() - notifyWorktreesChanged(mainWindow, repoId) - return {} - } else { - throw new Error( - formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false) - ) - } - } - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - args.worktreeId, - removedPushTarget, - store, - localWorktreeGitOptions - ) - rememberPreservedBranchCleanupTarget( - args.worktreeId, - removalResult, - refreshedRegisteredWorktree.head, - removedPushTarget - ) - runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState(store, args.worktreeId) - invalidateAuthorizedRootsCache() - - notifyWorktreesChanged(mainWindow, repoId) - return removalResult ?? {} - })() + }, + hostId + ) worktreeRemovalsInFlight.set(inFlightKey, { optionsKey, promise: removal }) try { return await removal @@ -1905,30 +1973,34 @@ export function registerWorktreeHandlers( throw new Error(`Worktree deletion already in progress: ${args.worktreeId}`) } - const forget = (async (): Promise => { - if (isFolderRepo(repo) && args.worktreeId === getFolderWorkspaceRootId(repo)) { - throw new Error( - 'Cannot delete the project root workspace. Remove the folder project instead.' - ) - } + const forget = withRemovalAdmission( + args.worktreeId, + async (): Promise => { + if (isFolderRepo(repo) && args.worktreeId === getFolderWorkspaceRootId(repo)) { + throw new Error( + 'Cannot delete the project root workspace. Remove the folder project instead.' + ) + } - // Why: best-effort PTY sweep. killAllProcessesForWorktree resolves - // synchronously for a dead SSH relay (the provider tombstones the lease - // and returns without awaiting the remote), so this never hangs. - await killAllProcessesForWorktree(args.worktreeId, { - runtime, - localProvider: getLocalPtyProvider(), - onPtyStopped: clearProviderPtyState - }).catch((err) => { - console.warn(`[worktree-teardown] forget-local failed for ${args.worktreeId}:`, err) - }) + // Why: best-effort PTY sweep. killAllProcessesForWorktree resolves + // synchronously for a dead SSH relay (the provider tombstones the lease + // and returns without awaiting the remote), so this never hangs. + await killAllProcessesForWorktree(args.worktreeId, { + runtime, + localProvider: getLocalPtyProvider(), + onPtyStopped: clearProviderPtyState + }).catch((err) => { + console.warn(`[worktree-teardown] forget-local failed for ${args.worktreeId}:`, err) + }) - runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState(store, args.worktreeId) - preservedBranchCleanupByWorktreeId.delete(args.worktreeId) - notifyWorktreesChanged(mainWindow, repoId) - return {} - })() + runtime.clearOptimisticReconcileToken(args.worktreeId) + removeWorktreeMetadataAndTransientState(store, args.worktreeId) + preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + notifyWorktreesChanged(mainWindow, repoId) + return {} + }, + getRepoExecutionHostId(repo) + ) worktreeRemovalsInFlight.set(inFlightKey, { optionsKey, promise: forget }) try { return await forget @@ -2075,6 +2147,18 @@ export function registerWorktreeHandlers( } }) + // Why: the full generation-failure output is main-memory only (never in + // worktree metadata), so the rename-failed dialog pulls it on demand. + ipcMain.handle( + 'worktrees:getBranchRenameFailureOutput', + (_event, args: { worktreeId: string }) => { + if (typeof args?.worktreeId !== 'string' || args.worktreeId.length === 0) { + return null + } + return readBranchRenameFailureOutputForDisplay(args.worktreeId) + } + ) + ipcMain.handle( 'hooks:check', async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => { diff --git a/src/main/kimi/hook-service.ts b/src/main/kimi/hook-service.ts index 459c0994e83..94f5d11ea3e 100644 --- a/src/main/kimi/hook-service.ts +++ b/src/main/kimi/hook-service.ts @@ -23,6 +23,7 @@ import { writeManagedScriptRemote, writeTextFileRemoteAtomic } from '../agent-hooks/installer-utils-remote' +import { buildPosixHookPayloadCapture } from '../agent-hooks/hook-stdin-contract' import { applyManagedKimiHooks, KIMI_HOOK_EVENTS, @@ -59,6 +60,7 @@ function getManagedCommand(scriptPath: string): string { function getManagedScript(): string { return [ '#!/bin/sh', + ...buildPosixHookPayloadCapture(), // Why: refresh PORT/TOKEN/ENV/VERSION from the current Orca install so a PTY // that survived an Orca restart still reaches the live listener. See // claude/hook-service.ts for the full rationale. @@ -68,10 +70,6 @@ function getManagedScript(): string { 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', ' exit 0', 'fi', - 'payload=$(cat)', - 'if [ -z "$payload" ]; then', - ' exit 0', - 'fi', // Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX // shell is not safe once a path contains quotes or newlines. Post the raw // hook payload plus metadata as form fields and let the receiver parse it. diff --git a/src/main/local-project-runtime-resolution.ts b/src/main/local-project-runtime-resolution.ts index 3cad91882f6..d7588ae51a1 100644 --- a/src/main/local-project-runtime-resolution.ts +++ b/src/main/local-project-runtime-resolution.ts @@ -1,5 +1,5 @@ import type { Store } from './persistence' -import type { Repo } from '../shared/types' +import type { Project, Repo } from '../shared/types' import { resolveProjectExecutionRuntime, type ProjectExecutionRuntimeResolution @@ -21,6 +21,25 @@ function canResolveProjectRuntimeForWorktreeId(store: Store): boolean { return canResolveProjectRuntimeForRepo(store) && typeof store.getRepo === 'function' } +function resolveLocalProjectRuntime( + store: Store, + project: Project, + settings: ReturnType = store.getSettings() +): ProjectExecutionRuntimeResolution { + const wslAvailable = hasCachedWslAvailability() + ? (getCachedWslAvailability() ?? undefined) + : undefined + const availableWslDistros = hasCachedWslDistros() ? getCachedWslDistros() : null + return resolveProjectExecutionRuntime({ + appPlatform: process.platform, + projectId: project.id, + projectRuntimePreference: project.localWindowsRuntimePreference, + globalWindowsRuntimeDefault: settings.localWindowsRuntimeDefault, + wslAvailable, + availableWslDistros + }) +} + export function resolveLocalProjectRuntimeForRepo( store: Store, repo: Repo @@ -35,18 +54,41 @@ export function resolveLocalProjectRuntimeForRepo( if (!project) { return undefined } - const wslAvailable = hasCachedWslAvailability() - ? (getCachedWslAvailability() ?? undefined) - : undefined - const availableWslDistros = hasCachedWslDistros() ? getCachedWslDistros() : null - return resolveProjectExecutionRuntime({ - appPlatform: process.platform, - projectId: project.id, - projectRuntimePreference: project.localWindowsRuntimePreference, - globalWindowsRuntimeDefault: store.getSettings().localWindowsRuntimeDefault, - wslAvailable, - availableWslDistros - }) + return resolveLocalProjectRuntime(store, project) +} + +export function resolveLocalProjectRuntimesForRepos( + store: Store, + repos: readonly Repo[] +): ReadonlyMap { + const runtimeByRepoId = new Map() + if (!canResolveProjectRuntimeForRepo(store)) { + return runtimeByRepoId + } + const requestedRepoIds = new Set( + repos + .filter((repo) => getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID) + .map((repo) => repo.id) + ) + if (requestedRepoIds.size === 0) { + return runtimeByRepoId + } + const settings = store.getSettings() + for (const project of store.getProjects()) { + const matchingRepoIds = project.sourceRepoIds.filter( + (repoId) => requestedRepoIds.has(repoId) && !runtimeByRepoId.has(repoId) + ) + if (matchingRepoIds.length === 0) { + continue + } + // Why: one project runtime applies to every source repo in that project; + // resolving it once prevents mobile polls from rescanning project settings. + const runtime = resolveLocalProjectRuntime(store, project, settings) + for (const repoId of matchingRepoIds) { + runtimeByRepoId.set(repoId, runtime) + } + } + return runtimeByRepoId } export function resolveLocalProjectRuntimeForWorktreeId( diff --git a/src/main/native-chat/transcript-read-cache.test.ts b/src/main/native-chat/transcript-read-cache.test.ts index 6f0fc39acb6..0d61f25f48e 100644 --- a/src/main/native-chat/transcript-read-cache.test.ts +++ b/src/main/native-chat/transcript-read-cache.test.ts @@ -110,6 +110,19 @@ describe('readNativeChatTranscriptCached', () => { expect('error' in result && result.error).toBeTruthy() }) + // Why: a just-created session's transcript can take up to minutes to exist on + // disk (#8401) — the miss must be marked notFound so watch/renderer callers + // retry instead of settling into a permanent error, and it must never be + // cached (a real error already isn't cached; this locks in the same for a miss). + it('marks a resolve miss as notFound and does not cache it', async () => { + await seedSession('present-2', 1) + const first = await readNativeChatTranscriptCached('claude', 'absent-2') + expect('error' in first && first.notFound).toBe(true) + expect(readSpy).not.toHaveBeenCalled() + const second = await readNativeChatTranscriptCached('claude', 'absent-2') + expect(second).not.toBe(first) + }) + // Why: two worktrees can present the SAME (agent, sessionId) via different // transcript files — e.g. the same session resumed into a second worktree, // which writes a new transcript file. Keying the cache by sessionId let one diff --git a/src/main/native-chat/transcript-read-cache.ts b/src/main/native-chat/transcript-read-cache.ts index 9f4df5bde87..8943c223dd2 100644 --- a/src/main/native-chat/transcript-read-cache.ts +++ b/src/main/native-chat/transcript-read-cache.ts @@ -91,7 +91,9 @@ export async function readNativeChatTranscriptCached( ): Promise { const filePath = await resolveSessionFilePath(agent, sessionId, { transcriptPath }) if (!filePath) { - return { error: `No transcript found for ${agent} session ${sessionId}` } + // Not cached (see below): a not-yet-flushed transcript should be re-checked + // on the next call, not pinned as a settled miss (#8401). + return { error: `No transcript found for ${agent} session ${sessionId}`, notFound: true } } const key = cacheKey(agent, filePath) diff --git a/src/main/native-chat/transcript-reader.test.ts b/src/main/native-chat/transcript-reader.test.ts index c6c2223c81f..cd87f2bdd59 100644 --- a/src/main/native-chat/transcript-reader.test.ts +++ b/src/main/native-chat/transcript-reader.test.ts @@ -242,19 +242,41 @@ describe('readNativeChatTranscript (codex)', () => { }) describe('readNativeChatTranscript (errors)', () => { - it('returns an error for an unreadable/missing file without throwing', async () => { + // Why: ENOENT after a successful resolve is the same first-flush/rotation + // race as an unresolved path (#8401) — it must stay retry-worthy. + it('marks an ENOENT on a directly-passed path as notFound (vanished after resolve)', async () => { const result = await readNativeChatTranscript('claude', 'sess', { filePath: join(tmpdir(), 'orca-native-chat-does-not-exist.jsonl') }) expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.notFound).toBe(true) + } }) - it('returns an error when no transcript can be resolved', async () => { + it('returns a real read error (no notFound) when the path exists but is unreadable', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-unreadable-')) + tempRoots.push(root) + // A directory instead of a file fails the read with a non-ENOENT error. + const result = await readNativeChatTranscript('claude', 'sess', { filePath: root }) + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.notFound).toBeUndefined() + } + }) + + // Why: a just-created Claude Code session's transcript can take up to minutes + // to exist on disk (#8401) — the miss must be marked retry-worthy so callers + // above (cache, watch, renderer) don't settle into a permanent error. + it('marks an unresolved session as notFound so callers know to retry', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-noresolve-')) tempRoots.push(root) const result = await readNativeChatTranscript('claude', 'missing', { claudeProjectsDir: join(root, 'empty') }) expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.notFound).toBe(true) + } }) }) diff --git a/src/main/native-chat/transcript-reader.ts b/src/main/native-chat/transcript-reader.ts index 1dbbd7dd82a..e2ea727c809 100644 --- a/src/main/native-chat/transcript-reader.ts +++ b/src/main/native-chat/transcript-reader.ts @@ -9,7 +9,11 @@ import { } from './transcript-line-decoders' import { decodeTranscriptStream } from './transcript-stream-lines' -export type ReadTranscriptResult = { messages: NativeChatMessage[] } | { error: string } +export type ReadTranscriptResult = + | { messages: NativeChatMessage[] } + // notFound marks a retry-worthy miss (transcript not flushed to disk yet, + // #8401) as opposed to a real parse/IO error callers surface immediately. + | { error: string; notFound?: true } export type ReadTranscriptOptions = ResolveSessionFileOptions & { /** Resolve directly to this file, skipping path discovery (used by tests). */ @@ -30,7 +34,7 @@ export async function readNativeChatTranscript( ): Promise { const filePath = options.filePath ?? (await resolveSessionFilePath(agent, sessionId, options)) if (!filePath) { - return { error: `No transcript found for ${agent} session ${sessionId}` } + return { error: `No transcript found for ${agent} session ${sessionId}`, notFound: true } } try { if (agent === 'claude') { @@ -44,6 +48,11 @@ export async function readNativeChatTranscript( } return { error: `Unsupported agent for native chat transcript: ${agent}` } } catch (err) { + // Why: ENOENT after a successful resolve is the same first-flush/rotation + // race as an unresolved path — keep it retry-worthy (#8401). + if ((err as NodeJS.ErrnoException | null)?.code === 'ENOENT') { + return { error: errorMessage(err), notFound: true } + } return { error: errorMessage(err) } } } diff --git a/src/main/native-chat/transcript-watch.test.ts b/src/main/native-chat/transcript-watch.test.ts index 55009521a99..4ff621d05bc 100644 --- a/src/main/native-chat/transcript-watch.test.ts +++ b/src/main/native-chat/transcript-watch.test.ts @@ -24,6 +24,14 @@ async function tempFile(initial: string): Promise { return filePath } +// A path inside a fresh temp dir with nothing written yet — simulates a +// just-created session whose agent hasn't flushed its first JSONL line (#8401). +async function pendingFilePath(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-watch-pending-')) + tempRoots.push(root) + return join(root, 'rollout.jsonl') +} + function claudeLine(uuid: string, role: 'user' | 'assistant', text: string): string { return `${JSON.stringify({ type: role, @@ -237,3 +245,77 @@ describe('subscribeNativeChatTranscript', () => { sub.unsubscribe() }) }) + +// Regression for #8401: Claude Code (and other agents) can take from ~3s to +// minutes to flush a brand-new session's first JSONL line, so the file +// genuinely doesn't exist when native chat subscribes. Before this fix, +// subscribeNativeChatTranscript returned a permanent no-op the instant the +// file was missing and never recovered once it appeared. +describe('subscribeNativeChatTranscript (resolve-poll for a not-yet-created file, #8401)', () => { + it('keeps retrying resolve+install and tails the file once it is created', async () => { + const filePath = await pendingFilePath() + const seen: NativeChatMessage[] = [] + + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => seen.push(...messages), + debounceMs: 5, + resolvePollIntervalMs: 20 + }) + + // Nothing installed yet — the file doesn't exist on disk. + expect(getActiveNativeChatWatcherCount()).toBe(0) + + // The agent flushes its first turn well after subscribe. + await new Promise((resolve) => setTimeout(resolve, 50)) + await writeFile(filePath, claudeLine('u-1', 'user', 'hello')) + + await waitFor(() => seen.some((m) => m.id === 'u-1')) + expect(getActiveNativeChatWatcherCount()).toBe(1) + + sub.unsubscribe() + expect(getActiveNativeChatWatcherCount()).toBe(0) + }) + + it('returns a no-op (no resolve poll) for a blank session id with no explicit file', async () => { + const before = getActiveNativeChatWatcherCount() + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: ' ', + onAppend: () => {}, + debounceMs: 5, + resolvePollIntervalMs: 10 + }) + + // An unresolvable target must not spin the resolve poll forever. + await new Promise((resolve) => setTimeout(resolve, 60)) + expect(getActiveNativeChatWatcherCount()).toBe(before) + sub.unsubscribe() + sub.unsubscribe() + expect(getActiveNativeChatWatcherCount()).toBe(before) + }) + + it('unsubscribing during the poll phase leaves no watcher or timer alive', async () => { + const filePath = await pendingFilePath() + const before = getActiveNativeChatWatcherCount() + + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: () => {}, + debounceMs: 5, + resolvePollIntervalMs: 20 + }) + + // Unsubscribe while still polling — the file is never created in this test. + sub.unsubscribe() + expect(getActiveNativeChatWatcherCount()).toBe(before) + + // Give any stray timer a chance to fire; it must not install a watcher. + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(getActiveNativeChatWatcherCount()).toBe(before) + }) +}) diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts index 4bbd934f0eb..1f55c33124a 100644 --- a/src/main/native-chat/transcript-watch.ts +++ b/src/main/native-chat/transcript-watch.ts @@ -29,6 +29,10 @@ export type SubscribeNativeChatTranscriptArgs = ResolveSessionFileOptions & { filePath?: string /** Coalesce window for rapid fs.watch events (ms). Defaults to 40ms. */ debounceMs?: number + /** Overrides the resolve-poll interval (see subscribeViaResolvePoll) so tests + * don't wait out the production backoff. Production ignores this and backs + * off from 500ms to a 5s cap. */ + resolvePollIntervalMs?: number } export type NativeChatTranscriptSubscription = { @@ -115,26 +119,17 @@ async function readAppendedMessages( } /** - * Subscribe to live appends on an agent's transcript file. Returns an - * unsubscribe fn that tears the watcher down completely. - * - * Handles file rotation/replacement: when the file shrinks (a new session id - * resolved to a smaller/newer file, or the file was truncated), the offset is - * reset to 0 so the replacement's content is read from the top. + * Install a live-tail watcher on an already-resolved file path. Returns null + * (rather than throwing) when `watch()` fails — e.g. the file vanished between + * resolve and install — so the caller can fall back to the resolve-poll below + * instead of surfacing a hard error. */ -export async function subscribeNativeChatTranscript( - args: SubscribeNativeChatTranscriptArgs -): Promise { - const { agent, sessionId, onAppend, debounceMs } = args - const decode = lineDecoderForAgent(agent) - const filePath = args.filePath ?? (await resolveSessionFilePath(agent, sessionId, args)) - - if (!filePath || !decode) { - // Nothing watchable — return a no-op teardown so callers can unconditionally - // unsubscribe without null-checks. - return { unsubscribe: () => {} } - } - +function installTranscriptWatcher( + filePath: string, + decode: (line: string, fallbackId: string) => NativeChatMessage | null, + onAppend: (messages: NativeChatMessage[]) => void, + debounceMs?: number +): NativeChatTranscriptSubscription | null { // Why: seed the offset at 0 so the FIRST drain re-reads the whole file. This // closes the read/subscribe race — a turn appended between the caller's // readSession EOF and the watcher install is still emitted. Re-emitted lines @@ -161,12 +156,12 @@ export async function subscribeNativeChatTranscript( do { pendingReadRequested = false try { - const currentSize = await fileSize(filePath!) + const currentSize = await fileSize(filePath) if (currentSize < offset) { // Rotation/replacement/truncation: re-read from the top. offset = 0 } - const { messages, consumedTo } = await readAppendedMessages(filePath!, offset, decode!) + const { messages, consumedTo } = await readAppendedMessages(filePath, offset, decode) offset = consumedTo if (!closed && messages.length > 0) { onAppend(messages) @@ -200,8 +195,8 @@ export async function subscribeNativeChatTranscript( try { watcher = watch(filePath, scheduleDrain) } catch { - // File vanished between resolve and watch — return a no-op teardown. - return { unsubscribe: () => {} } + // File vanished between resolve and watch. + return null } activeWatcherCount++ @@ -225,3 +220,135 @@ export async function subscribeNativeChatTranscript( } } } + +/** One resolve+install attempt. Returns null when the file isn't resolvable + * yet, or vanished between resolve and `watch()` — either case is retried by + * the resolve-poll rather than treated as a hard failure. */ +async function attemptInstall( + args: SubscribeNativeChatTranscriptArgs, + decode: (line: string, fallbackId: string) => NativeChatMessage | null +): Promise { + const filePath = args.filePath ?? (await resolveSessionFilePath(args.agent, args.sessionId, args)) + if (!filePath) { + return null + } + return installTranscriptWatcher(filePath, decode, args.onAppend, args.debounceMs) +} + +// Why: Claude Code (and other agents) can take from ~3s to minutes to flush a +// brand-new session's first JSONL line (#8401) — resolveSessionFilePath +// genuinely has nothing to find yet. Poll for it instead of going deaf; each +// attempt is a cheap glob, so the cost of polling is negligible next to the +// gap it covers. +const INITIAL_RESOLVE_POLL_MS = 500 +const MAX_RESOLVE_POLL_MS = 5_000 + +/** + * Background retry loop for a transcript that hasn't been resolvable yet. + * Returns a subscription immediately (per subscribeNativeChatTranscript's + * contract); the loop keeps retrying resolve+install until it succeeds or + * unsubscribe() cancels it. + */ +function subscribeViaResolvePoll( + args: SubscribeNativeChatTranscriptArgs, + decode: (line: string, fallbackId: string) => NativeChatMessage | null +): NativeChatTranscriptSubscription { + let closed = false + let installed: NativeChatTranscriptSubscription | null = null + let pollTimer: ReturnType | null = null + let delay = args.resolvePollIntervalMs ?? INITIAL_RESOLVE_POLL_MS + + function scheduleAttempt(): void { + if (closed) { + return + } + pollTimer = setTimeout(() => { + pollTimer = null + void runAttempt() + }, delay) + // Why: never hold the event loop open (headless `orca serve` shutdown) for + // a session that may genuinely never resolve. + pollTimer.unref?.() + // Only back off in production; a test-supplied interval stays fixed so + // tests resolve in bounded, predictable time. + if (args.resolvePollIntervalMs === undefined) { + delay = Math.min(delay * 2, MAX_RESOLVE_POLL_MS) + } + } + + async function runAttempt(): Promise { + if (closed) { + return + } + let result: NativeChatTranscriptSubscription | null + try { + result = await attemptInstall(args, decode) + } catch { + // Why: a transient resolve failure (EACCES/EIO during the glob) must not + // kill the poll loop with an unhandled rejection — retry like a miss. + result = null + } + if (closed) { + // unsubscribe() ran while this attempt was in flight. + result?.unsubscribe() + return + } + if (result) { + installed = result + return + } + scheduleAttempt() + } + + scheduleAttempt() + + return { + unsubscribe: () => { + if (closed) { + return + } + closed = true + if (pollTimer) { + clearTimeout(pollTimer) + pollTimer = null + } + installed?.unsubscribe() + installed = null + } + } +} + +/** + * Subscribe to live appends on an agent's transcript file. Returns an + * unsubscribe fn that tears the watcher down completely. + * + * Handles file rotation/replacement: when the file shrinks (a new session id + * resolved to a smaller/newer file, or the file was truncated), the offset is + * reset to 0 so the replacement's content is read from the top. + * + * When the transcript isn't resolvable yet (a just-created session whose + * agent hasn't flushed its first JSONL line, #8401), returns the subscription + * immediately and keeps retrying resolve+install in the background rather + * than returning a no-op that never recovers. + */ +export async function subscribeNativeChatTranscript( + args: SubscribeNativeChatTranscriptArgs +): Promise { + const decode = lineDecoderForAgent(args.agent) + if (!decode) { + // Nothing watchable — return a no-op teardown so callers can unconditionally + // unsubscribe without null-checks. + return { unsubscribe: () => {} } + } + // Why: a blank session id (and no explicit file) can never resolve — bail out + // instead of resolve-polling an unresolvable target forever. + if (!args.filePath && !args.sessionId.trim()) { + return { unsubscribe: () => {} } + } + + const installed = await attemptInstall(args, decode) + if (installed) { + return installed + } + return subscribeViaResolvePoll(args, decode) +} diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 4e921151e4f..a24ac462b6c 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -3,6 +3,7 @@ migration, mutation, and flush behavior in one file so schema changes are reviewed against the full storage contract instead of being scattered. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { + appendFileSync, writeFileSync, readFileSync, rmSync, @@ -13,6 +14,7 @@ import { statSync, symlinkSync } from 'node:fs' +import type * as NodeFsPromises from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import type { @@ -153,6 +155,17 @@ function dataFile(): string { return join(testState.dir, 'orca-data.json') } +function terminalTeardownIntentFile(): string { + return join(testState.dir, 'orca-terminal-teardown-intents.json') +} + +function readTerminalTeardownIntentJournal(): Record[] { + return readFileSync(terminalTeardownIntentFile(), 'utf-8') + .trim() + .split('\n') + .map((line) => JSON.parse(line) as Record) +} + function writeDataFile(data: unknown): void { mkdirSync(testState.dir, { recursive: true }) writeFileSync(dataFile(), JSON.stringify(data, null, 2), 'utf-8') @@ -562,6 +575,50 @@ describe('Store', () => { expect(ui.setupGuideSidebarDismissed).toBe(false) expect(ui.setupGuideBrowserMilestoneMigrated).toBe(true) expect(ui.setupGuideBrowserMilestoneLegacyComplete).toBe(false) + // Why: brand-new profiles never saw remaining-as-default. + expect(ui.usagePercentageDisplayChangeNoticeDismissed).toBe(true) + }) + + it('surfaces the usage percentage display change notice for upgraded profiles', async () => { + const persisted = getDefaultPersistedState(testState.dir) + writeDataFile({ + ...persisted, + onboarding: { + ...persisted.onboarding, + closedAt: 1, + outcome: 'completed' + }, + ui: { + ...persisted.ui, + // Why: omit the notice key so load resolves eligibility for existing profiles. + usagePercentageDisplayChangeNoticeDismissed: undefined + } + }) + + const store = await createStore() + + expect(store.getUI().usagePercentageDisplayChangeNoticeDismissed).toBe(false) + }) + + it('keeps the usage percentage display change notice dismissed when remaining was chosen', async () => { + const persisted = getDefaultPersistedState(testState.dir) + writeDataFile({ + ...persisted, + onboarding: { + ...persisted.onboarding, + closedAt: 1, + outcome: 'completed' + }, + ui: { + ...persisted.ui, + usagePercentageDisplay: 'remaining', + usagePercentageDisplayChangeNoticeDismissed: undefined + } + }) + + const store = await createStore() + + expect(store.getUI().usagePercentageDisplayChangeNoticeDismissed).toBe(true) }) it('defaults minimizeToTrayOnClose to false when unset', async () => { @@ -3410,6 +3467,24 @@ describe('Store', () => { expect(ui.workspaceHostOrder).toEqual(['ssh:ssh-new', 'local']) }) + it('reassignSshTargetId invalidates a warmed lease identity index', async () => { + const store = await createStore() + store.upsertSshRemotePtyLease({ + targetId: 'ssh-old', + ptyId: 'pty-2', + state: 'detached' + }) + expect(store.markSshRemotePtyLease('ssh-old', 'ssh:ssh-old@@pty-2', 'detached')).toBe(true) + + store.reassignSshTargetId('ssh-old', 'ssh-new') + + expect(store.markSshRemotePtyLease('ssh-new', 'ssh:ssh-new@@pty-2', 'attached')).toBe(true) + expect(store.getSshRemotePtyLeases('ssh-new')).toEqual([ + expect.objectContaining({ ptyId: 'pty-2', state: 'attached' }) + ]) + store.flush() + }) + it('reassignSshTargetId re-keys a session partition stored under the old ssh host id', async () => { const store = await createStore() store.setWorkspaceSession( @@ -4433,6 +4508,27 @@ describe('Store', () => { expect(updated.branchPrefix).toBe('git-username') }) + it('normalizes bot-author overrides on load and every settings write', async () => { + writeDataFile({ + settings: { + prBotAuthorOverrides: [' GretelFlux ', 'gretelflux', 42, '', 'another-bot'] + } + }) + const store = await createStore() + + expect(store.getSettings().prBotAuthorOverrides).toEqual(['another-bot', 'gretelflux']) + + const oversized = Array.from( + { length: 600 }, + (_, index) => ` bot-${String(index).padStart(4, '0')} ` + ) + const updated = store.updateSettings({ prBotAuthorOverrides: oversized }) + + expect(updated.prBotAuthorOverrides).toHaveLength(500) + expect(updated.prBotAuthorOverrides[0]).toBe('bot-0000') + expect(updated.prBotAuthorOverrides[499]).toBe('bot-0499') + }) + it('notifies settings listeners with changed keys only', async () => { const store = await createStore() const listener = vi.fn() @@ -6017,6 +6113,65 @@ describe('Store', () => { expect(store.getSettings().terminalMacOptionAsAltMigrated).toBe(true) }) + it('migrates inherited right-click paste to each platform default once', async () => { + for (const [platform, expected] of [ + ['win32', true], + ['darwin', false], + ['linux', false] + ] as const) { + await withPlatform(platform, async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { terminalRightClickToPaste: true }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const store = await createStore() + expect(store.getSettings().terminalRightClickToPaste).toBe(expected) + expect(store.getSettings().terminalRightClickToPasteDefaultedForPlatform).toBe(true) + }) + } + }) + + it('preserves an explicit Windows right-click paste opt-out during migration', async () => { + await withPlatform('win32', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { terminalRightClickToPaste: false }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const store = await createStore() + expect(store.getSettings().terminalRightClickToPaste).toBe(false) + expect(store.getSettings().terminalRightClickToPasteDefaultedForPlatform).toBe(true) + }) + }) + + it('preserves right-click paste choices after the platform migration', async () => { + await withPlatform('darwin', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { + terminalRightClickToPaste: true, + terminalRightClickToPasteDefaultedForPlatform: true + }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const store = await createStore() + expect(store.getSettings().terminalRightClickToPaste).toBe(true) + }) + }) + it('migrates inherited terminal bar cursor defaults to block on first load', async () => { writeDataFile({ schemaVersion: 1, @@ -7231,6 +7386,44 @@ describe('Store', () => { } }) + it('restores cross-tab pane authority aliases before hook ingestion', async () => { + const physicalPaneKey = makePaneKey('tab-source', TEST_LEAF_1) + const ownerPaneKey = makePaneKey('tab-target', TEST_LEAF_2) + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + legacyPaneKeyAliasEntries: [ + { + ptyId: 'pty-detached', + legacyPaneKey: physicalPaneKey, + stablePaneKey: ownerPaneKey, + updatedAt: 10 + } + ] + }) + + await createStore() + const { agentHookServer } = await import('./agent-hooks/server') + agentHookServer.ingestTerminalStatus({ + paneKey: physicalPaneKey, + tabId: 'tab-source', + worktreeId: 'wt1', + payload: { state: 'working', prompt: 'detached after restart' } + }) + + expect(agentHookServer.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: ownerPaneKey, + tabId: 'tab-target', + prompt: 'detached after restart' + }) + ]) + }) + it('persists fallback aliases when a legacy split layout has no PTY leaf bindings', async () => { writeDataFile({ schemaVersion: 1, @@ -8659,12 +8852,12 @@ describe('Store', () => { expect(session.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({}) }) - it('stores scoped SSH remote PTY leases as raw relay ids', async () => { + it('stores scoped SSH remote PTY leases as raw ids with their relay generation', async () => { const store = await createStore() store.upsertSshRemotePtyLease({ targetId: 'ssh-1', - ptyId: 'ssh:ssh-1@@remote-pty', + ptyId: 'ssh:ssh-1@@relay-generation@@remote-pty', state: 'attached' }) @@ -8672,11 +8865,391 @@ describe('Store', () => { expect.objectContaining({ targetId: 'ssh-1', ptyId: 'remote-pty', + relayInstanceId: 'relay-generation', state: 'attached' }) ]) }) + it('replaces teardown identity when a new relay boot reuses a raw PTY id', async () => { + const store = await createStore() + store.upsertSshRemotePtyLease({ + targetId: 'ssh-1', + ptyId: 'ssh:ssh-1@@boot-a@@remote-pty', + state: 'expired', + shutdownRequestedAt: 10 + }) + + store.upsertSshRemotePtyLease({ + targetId: 'ssh-1', + ptyId: 'ssh:ssh-1@@boot-b@@remote-pty', + state: 'attached' + }) + store.markSshRemotePtyShutdownRequested('ssh-1', 'ssh:ssh-1@@boot-a@@remote-pty') + + expect(store.getSshRemotePtyLeases('ssh-1')).toEqual([ + expect.objectContaining({ + ptyId: 'remote-pty', + relayInstanceId: 'boot-b', + state: 'attached' + }) + ]) + expect(store.getSshRemotePtyLeases('ssh-1')[0]).not.toHaveProperty('shutdownRequestedAt') + }) + + it('preserves SSH relay generation across save and reload', async () => { + const store = await createStore() + store.upsertSshRemotePtyLease({ + targetId: 'ssh-1', + ptyId: 'ssh:ssh-1@@boot-a@@remote-pty', + state: 'detached' + }) + + const reloaded = await createStore() + expect(reloaded.getSshRemotePtyLeases('ssh-1')[0]).toEqual( + expect.objectContaining({ relayInstanceId: 'boot-a' }) + ) + }) + + it('keeps an SSH generation migration journaled behind an in-flight stale save', async () => { + vi.useFakeTimers() + const actualFs = await vi.importActual('node:fs/promises') + let releaseWrite!: () => void + let markWriteStarted!: () => void + const writeCanFinish = new Promise((resolve) => { + releaseWrite = resolve + }) + const writeStarted = new Promise((resolve) => { + markWriteStarted = resolve + }) + let blockNextDataWrite = true + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + writeFile: async (...args: unknown[]) => { + const path = String(args[0]) + if (blockNextDataWrite && path.includes('orca-data.json.') && path.endsWith('.tmp')) { + blockNextDataWrite = false + markWriteStarted() + await writeCanFinish + } + return Reflect.apply(actualFs.writeFile, actualFs, args) + } + })) + try { + const store = await createStore() + store.upsertSshRemotePtyLease({ + targetId: 'ssh-1', + ptyId: 'remote-pty', + state: 'detached' + }) + store.updateUI({ sidebarWidth: 401 }) + vi.advanceTimersByTime(1_000) + await writeStarted + + expect(store.migrateLegacySshRemotePtyLeaseGeneration('ssh-1', 'remote-pty', 'boot-a')).toBe( + true + ) + releaseWrite() + await store.waitForPendingWrite() + + expect(readTerminalTeardownIntentJournal()).toContainEqual( + expect.objectContaining({ kind: 'ssh-migrate-generation', relayInstanceId: 'boot-a' }) + ) + const reloaded = await createStore() + expect(reloaded.getSshRemotePtyLeases('ssh-1')).toEqual([ + expect.objectContaining({ ptyId: 'remote-pty', relayInstanceId: 'boot-a' }) + ]) + } finally { + vi.clearAllTimers() + vi.doUnmock('node:fs/promises') + vi.resetModules() + vi.useRealTimers() + } + }) + + it('preserves exact local and runtime close intent across app restart', async () => { + const store = await createStore() + store.upsertPendingLocalPtyShutdown({ + ptyId: 'local-pty', + expectedPaneKey: 'tab:leaf', + expectedTabId: 'tab', + requestedAt: 10 + }) + store.upsertPendingRuntimeTerminalClose({ + environmentId: 'env-1', + handle: 'terminal-1', + runtimeId: 'runtime-a', + requestedAt: 20 + }) + + const reloaded = await createStore() + expect(reloaded.getPendingLocalPtyShutdowns()).toEqual([ + { + ptyId: 'local-pty', + expectedPaneKey: 'tab:leaf', + expectedTabId: 'tab', + requestedAt: 10 + } + ]) + expect(reloaded.getPendingRuntimeTerminalCloses()).toEqual([ + { + environmentId: 'env-1', + handle: 'terminal-1', + runtimeId: 'runtime-a', + requestedAt: 20 + } + ]) + }) + + it('keeps 65 teardown owners crash-durable without synchronous full-store flushes', async () => { + const store = await createStore() + const flushSpy = vi.spyOn(store, 'flush') + for (let index = 0; index < 65; index += 1) { + store.upsertPendingLocalPtyShutdown({ + ptyId: `local-${index}`, + expectedPaneKey: `tab-${index}:leaf-${index}`, + expectedTabId: `tab-${index}`, + requestedAt: index + }) + store.upsertPendingRuntimeTerminalClose({ + environmentId: `environment-${index}`, + handle: `terminal-${index}`, + runtimeId: `runtime-${index}`, + requestedAt: index + }) + } + + const reloaded = await createStore() + expect(reloaded.getPendingLocalPtyShutdowns()).toHaveLength(65) + expect(reloaded.getPendingRuntimeTerminalCloses()).toHaveLength(65) + const retainedJournal = readTerminalTeardownIntentJournal() + expect(retainedJournal).toHaveLength(130) + expect(retainedJournal.at(-1)).toEqual( + expect.objectContaining({ revision: 130, kind: 'runtime-upsert' }) + ) + for (let index = 0; index < 65; index += 1) { + store.removePendingLocalPtyShutdown(`local-${index}`) + store.removePendingRuntimeTerminalClose(`environment-${index}`, `terminal-${index}`) + } + const cleared = await createStore() + + expect(cleared.getPendingLocalPtyShutdowns()).toEqual([]) + expect(cleared.getPendingRuntimeTerminalCloses()).toEqual([]) + const clearedJournal = readTerminalTeardownIntentJournal() + expect(clearedJournal).toHaveLength(260) + expect(clearedJournal.at(-1)).toEqual( + expect.objectContaining({ revision: 260, kind: 'runtime-remove' }) + ) + expect(Math.max(...clearedJournal.map((record) => JSON.stringify(record).length))).toBeLessThan( + 1_024 + ) + expect(readFileSync(terminalTeardownIntentFile(), 'utf-8').length).toBeLessThan(100_000) + expect(flushSpy).not.toHaveBeenCalled() + flushSpy.mockRestore() + reloaded.flush() + cleared.flush() + store.flush() + expect(readTerminalTeardownIntentJournal()).toEqual([ + expect.objectContaining({ + version: 2, + revision: 260, + kind: 'checkpoint', + pendingLocalPtyShutdowns: [], + pendingRuntimeTerminalCloses: [] + }) + ]) + }) + + it('replays complete teardown journal records before a truncated crash tail', async () => { + const store = await createStore() + store.upsertPendingLocalPtyShutdown({ ptyId: 'local-a', requestedAt: 1 }) + store.upsertPendingLocalPtyShutdown({ ptyId: 'local-b', requestedAt: 2 }) + appendFileSync(terminalTeardownIntentFile(), '{"version":2,"revision":3', 'utf-8') + + const reloaded = await createStore() + expect(reloaded.getPendingLocalPtyShutdowns()).toEqual([ + { ptyId: 'local-a', requestedAt: 1 }, + { ptyId: 'local-b', requestedAt: 2 } + ]) + reloaded.upsertPendingLocalPtyShutdown({ ptyId: 'local-c', requestedAt: 3 }) + + const secondReload = await createStore() + expect(secondReload.getPendingLocalPtyShutdowns()).toEqual([ + { ptyId: 'local-a', requestedAt: 1 }, + { ptyId: 'local-b', requestedAt: 2 }, + { ptyId: 'local-c', requestedAt: 3 } + ]) + expect(readTerminalTeardownIntentJournal()).toEqual([ + expect.objectContaining({ kind: 'checkpoint', revision: 3 }) + ]) + store.flush() + reloaded.flush() + secondReload.flush() + }) + + it('rewrites a checkpoint before appending after a complete record loses its newline', async () => { + const store = await createStore() + store.upsertPendingLocalPtyShutdown({ ptyId: 'local-a', requestedAt: 1 }) + writeFileSync( + terminalTeardownIntentFile(), + readFileSync(terminalTeardownIntentFile(), 'utf-8').trimEnd(), + 'utf-8' + ) + + const reloaded = await createStore() + expect(reloaded.getPendingLocalPtyShutdowns()).toEqual([{ ptyId: 'local-a', requestedAt: 1 }]) + reloaded.upsertPendingLocalPtyShutdown({ ptyId: 'local-b', requestedAt: 2 }) + + const secondReload = await createStore() + expect(secondReload.getPendingLocalPtyShutdowns()).toEqual([ + { ptyId: 'local-a', requestedAt: 1 }, + { ptyId: 'local-b', requestedAt: 2 } + ]) + expect(readTerminalTeardownIntentJournal()).toEqual([ + expect.objectContaining({ kind: 'checkpoint', revision: 2 }) + ]) + store.flush() + reloaded.flush() + secondReload.flush() + }) + + it('checkpoints after an append and full-store fallback both fail', async () => { + const store = await createStore() + store.upsertPendingLocalPtyShutdown({ ptyId: 'local-a', requestedAt: 1 }) + store.flush() + const persistedState = readFileSync(dataFile(), 'utf-8') + + rmSync(terminalTeardownIntentFile(), { force: true }) + mkdirSync(terminalTeardownIntentFile()) + rmSync(dataFile(), { force: true }) + mkdirSync(dataFile()) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + expect(() => + store.upsertPendingLocalPtyShutdown({ ptyId: 'local-b', requestedAt: 2 }) + ).toThrow() + + rmSync(terminalTeardownIntentFile(), { recursive: true, force: true }) + writeFileSync(terminalTeardownIntentFile(), '{"version":2,"revision":2', 'utf-8') + rmSync(dataFile(), { recursive: true, force: true }) + writeFileSync(dataFile(), persistedState, 'utf-8') + store.upsertPendingLocalPtyShutdown({ ptyId: 'local-c', requestedAt: 3 }) + + const reloaded = await createStore() + expect(reloaded.getPendingLocalPtyShutdowns()).toEqual([ + { ptyId: 'local-a', requestedAt: 1 }, + { ptyId: 'local-b', requestedAt: 2 }, + { ptyId: 'local-c', requestedAt: 3 } + ]) + expect(readTerminalTeardownIntentJournal()).toEqual([ + expect.objectContaining({ kind: 'checkpoint', revision: 3 }) + ]) + store.flush() + reloaded.flush() + }) + + it.each(['local', 'runtime'] as const)( + 'retries a failed %s teardown removal against the real durable store', + async (kind) => { + const store = await createStore() + if (kind === 'local') { + store.upsertPendingLocalPtyShutdown({ ptyId: 'local-a', requestedAt: 1 }) + } else { + store.upsertPendingRuntimeTerminalClose({ + environmentId: 'environment-a', + handle: 'terminal-a', + runtimeId: 'runtime-a', + requestedAt: 1 + }) + } + store.flush() + const persistedState = readFileSync(dataFile(), 'utf-8') + const persistedJournal = readFileSync(terminalTeardownIntentFile(), 'utf-8') + rmSync(terminalTeardownIntentFile(), { force: true }) + mkdirSync(terminalTeardownIntentFile()) + rmSync(dataFile(), { force: true }) + mkdirSync(dataFile()) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const remove = () => + kind === 'local' + ? store.removePendingLocalPtyShutdown('local-a') + : store.removePendingRuntimeTerminalClose('environment-a', 'terminal-a') + expect(remove).toThrow() + + rmSync(terminalTeardownIntentFile(), { recursive: true, force: true }) + writeFileSync(terminalTeardownIntentFile(), persistedJournal, 'utf-8') + rmSync(dataFile(), { recursive: true, force: true }) + writeFileSync(dataFile(), persistedState, 'utf-8') + expect(remove).not.toThrow() + + const reloaded = await createStore() + if (kind === 'local') { + expect(reloaded.getPendingLocalPtyShutdowns()).toEqual([]) + } else { + expect(reloaded.getPendingRuntimeTerminalCloses()).toEqual([]) + } + store.flush() + reloaded.flush() + } + ) + + it('keeps the 50-PTY SSH shutdown journal within the constant-record budget', async () => { + const store = await createStore() + const owners = Array.from({ length: 50 }, (_, index) => ({ + targetId: `target-${index}-${'t'.repeat(36)}`, + relayInstanceId: `boot-${index}-${'g'.repeat(36)}`, + ptyId: `pty-${index}-${'p'.repeat(36)}` + })) + for (const owner of owners) { + store.upsertSshRemotePtyLease({ ...owner, state: 'attached' }) + } + for (const owner of owners) { + expect( + store.markSshRemotePtyShutdownRequested( + owner.targetId, + `ssh:${owner.targetId}@@${owner.relayInstanceId}@@${owner.ptyId}` + ) + ).toBe(true) + } + + const raw = readFileSync(terminalTeardownIntentFile(), 'utf-8') + const records = readTerminalTeardownIntentJournal() + const shutdownRecords = records.filter( + (record) => record.kind === 'ssh-set' && record.shutdownRequestedAt !== undefined + ) + expect(shutdownRecords).toHaveLength(50) + expect(Math.max(...records.map((record) => JSON.stringify(record).length))).toBeLessThan(1_024) + expect(raw.length).toBeLessThan(100_000) + + const reloaded = await createStore() + expect( + owners.filter( + (owner) => + reloaded.getSshRemotePtyLeases(owner.targetId)[0]?.shutdownRequestedAt !== undefined + ) + ).toHaveLength(50) + store.flush() + reloaded.flush() + }) + + it('ignores an older sidecar after the full-store fallback persisted a newer revision', async () => { + const store = await createStore() + store.upsertPendingLocalPtyShutdown({ + ptyId: 'local-stale', + requestedAt: 1 + }) + const staleSidecar = readFileSync(terminalTeardownIntentFile(), 'utf-8') + + store.removePendingLocalPtyShutdown('local-stale') + store.flush() + writeFileSync(terminalTeardownIntentFile(), staleSidecar, 'utf-8') + + const reloaded = await createStore() + expect(reloaded.getPendingLocalPtyShutdowns()).toEqual([]) + store.flush() + reloaded.flush() + }) + it('rejects mismatched scoped SSH remote PTY lease ids on write paths', async () => { const store = await createStore() @@ -8707,6 +9280,55 @@ describe('Store', () => { ]) }) + it('persists durable SSH shutdown intent while the provider is offline', async () => { + const store = await createStore() + store.upsertSshRemotePtyLease({ + targetId: 'ssh-1', + ptyId: 'remote-pty', + state: 'detached' + }) + + store.markSshRemotePtyShutdownRequested('ssh-1', 'ssh:ssh-1@@remote-pty') + + expect(store.getSshRemotePtyLeases('ssh-1')[0]).toEqual( + expect.objectContaining({ + ptyId: 'remote-pty', + shutdownRequestedAt: expect.any(Number) + }) + ) + const reloaded = await createStore() + expect(reloaded.getSshRemotePtyLeases('ssh-1')[0]).toEqual( + expect.objectContaining({ + ptyId: 'remote-pty', + shutdownRequestedAt: expect.any(Number) + }) + ) + store.flush() + reloaded.flush() + }) + + it('does not let generationless SSH ids mutate a generation-qualified replacement lease', async () => { + const store = await createStore() + store.upsertSshRemotePtyLease({ + targetId: 'ssh-1', + ptyId: 'ssh:ssh-1@@boot-b@@remote-pty', + state: 'attached' + }) + + expect(store.markSshRemotePtyShutdownRequested('ssh-1', 'ssh:ssh-1@@remote-pty')).toBe(false) + expect(store.markSshRemotePtyLease('ssh-1', 'ssh:ssh-1@@remote-pty', 'terminated')).toBe(false) + + expect(store.getSshRemotePtyLeases('ssh-1')).toEqual([ + expect.objectContaining({ + ptyId: 'remote-pty', + relayInstanceId: 'boot-b', + state: 'attached' + }) + ]) + expect(store.getSshRemotePtyLeases('ssh-1')[0]).not.toHaveProperty('shutdownRequestedAt') + store.flush() + }) + it('clears workspace bindings when marking an SSH remote PTY lease expired', async () => { const store = await createStore() store.upsertSshRemotePtyLease({ diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 0cac89b3f17..0bb2843b41d 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -17,6 +17,16 @@ import { writeFile, rename, mkdir, rm, copyFile } from 'node:fs/promises' import { join, dirname, isAbsolute, resolve, sep } from 'node:path' import { homedir } from 'node:os' import { createHash, randomUUID } from 'node:crypto' +import { + appendTerminalTeardownIntentMutation, + applyTerminalTeardownIntentSnapshot, + writeTerminalTeardownIntentSnapshot +} from './terminal-teardown-intent-snapshot' +import type { TerminalTeardownIntentMutation } from './terminal-teardown-intent-snapshot' +import { + indexSshRemotePtyLeases, + sshRemotePtyLeaseIdentityKey +} from './ssh/ssh-remote-pty-lease-index' import type { Automation, AutomationCreateInput, @@ -65,7 +75,9 @@ import type { TerminalLayoutSnapshot, TerminalTab, WorkspaceSessionPatch, - WorkspaceSessionState + WorkspaceSessionState, + PersistedLocalPtyShutdown, + PersistedRuntimeTerminalClose } from '../shared/types' import { deriveGlobalWindowsRuntimeDefaultFromLegacySettings, @@ -104,6 +116,10 @@ import { ONBOARDING_FINAL_STEP } from '../shared/constants' import { parseWorkspaceSession } from '../shared/workspace-session-schema' +import { normalizeUsagePercentageDisplay } from '../shared/usage-percentage-display' +import { isExistingPersistedProfile } from '../shared/project-order-manual-default-notice' +import { resolveUsagePercentageDisplayChangeNoticeDismissed } from '../shared/usage-percentage-display-change-notice' +import { normalizePRBotAuthorOverrides } from '../shared/pr-bot-author-overrides' import { LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostOrder, @@ -112,7 +128,7 @@ import { toSshExecutionHostId, type ExecutionHostId } from '../shared/execution-host' -import { toRelaySshPtyId } from './providers/ssh-pty-id' +import { parseAppSshPtyId, toRelaySshPtyId } from './providers/ssh-pty-id' import { migrateUiHostScopeSshTargetId, migrateWorkspaceSessionSshTargetId @@ -1573,6 +1589,7 @@ function normalizeSshRemotePtyLease(value: unknown): SshRemotePtyLease | null { return { targetId: raw.targetId, ptyId: raw.ptyId, + ...(typeof raw.relayInstanceId === 'string' ? { relayInstanceId: raw.relayInstanceId } : {}), ...(typeof raw.worktreeId === 'string' ? { worktreeId: raw.worktreeId } : {}), ...(typeof raw.tabId === 'string' ? { tabId: raw.tabId } : {}), ...(typeof raw.leafId === 'string' && raw.leafId.length <= 256 ? { leafId: raw.leafId } : {}), @@ -1580,7 +1597,46 @@ function normalizeSshRemotePtyLease(value: unknown): SshRemotePtyLease | null { createdAt: typeof raw.createdAt === 'number' ? raw.createdAt : now, updatedAt: typeof raw.updatedAt === 'number' ? raw.updatedAt : now, ...(typeof raw.lastAttachedAt === 'number' ? { lastAttachedAt: raw.lastAttachedAt } : {}), - ...(typeof raw.lastDetachedAt === 'number' ? { lastDetachedAt: raw.lastDetachedAt } : {}) + ...(typeof raw.lastDetachedAt === 'number' ? { lastDetachedAt: raw.lastDetachedAt } : {}), + ...(typeof raw.shutdownRequestedAt === 'number' + ? { shutdownRequestedAt: raw.shutdownRequestedAt } + : {}) + } +} + +function normalizeLocalPtyShutdown(value: unknown): PersistedLocalPtyShutdown | null { + if (!value || typeof value !== 'object') { + return null + } + const raw = value as Partial + if (typeof raw.ptyId !== 'string' || typeof raw.requestedAt !== 'number') { + return null + } + return { + ptyId: raw.ptyId, + requestedAt: raw.requestedAt, + ...(typeof raw.expectedPaneKey === 'string' ? { expectedPaneKey: raw.expectedPaneKey } : {}), + ...(typeof raw.expectedTabId === 'string' ? { expectedTabId: raw.expectedTabId } : {}) + } +} + +function normalizeRuntimeTerminalClose(value: unknown): PersistedRuntimeTerminalClose | null { + if (!value || typeof value !== 'object') { + return null + } + const raw = value as Partial + if ( + typeof raw.environmentId !== 'string' || + typeof raw.handle !== 'string' || + typeof raw.requestedAt !== 'number' + ) { + return null + } + return { + environmentId: raw.environmentId, + handle: raw.handle, + ...(typeof raw.runtimeId === 'string' ? { runtimeId: raw.runtimeId } : {}), + requestedAt: raw.requestedAt } } @@ -2260,11 +2316,34 @@ function normalizeLegacyPaneKeyAliasEntries(value: unknown): LegacyPaneKeyAliasE return false } const legacy = parseLegacyNumericPaneKey(candidate.legacyPaneKey) + const relocatedSource = parsePaneKey(candidate.legacyPaneKey) const stable = parsePaneKey(candidate.stablePaneKey) - return Boolean(legacy && stable && legacy.tabId === stable.tabId) + return Boolean(stable && ((legacy && legacy.tabId === stable.tabId) || relocatedSource)) }) } +function registerPersistedPaneKeyAlias(entry: LegacyPaneKeyAliasEntry): void { + if (parseLegacyNumericPaneKey(entry.legacyPaneKey)) { + agentHookServer.registerPaneKeyAlias( + entry.legacyPaneKey, + entry.stablePaneKey, + entry.ptyId, + entry.updatedAt, + { overwriteExisting: false } + ) + return + } + // Why: detached agents keep their original UUID pane key across restarts; + // restore the physical-to-current-owner mapping before hook replay begins. + agentHookServer.transferPaneAuthority( + entry.legacyPaneKey, + entry.stablePaneKey, + entry.ptyId, + entry.updatedAt, + { authorityVerified: false } + ) +} + function mergeLegacyPaneKeyAliasEntries( entries: LegacyPaneKeyAliasEntry[] ): LegacyPaneKeyAliasEntry[] { @@ -2615,6 +2694,8 @@ export class Store { private lastWrittenStateHash: string | null = null private firstPendingSaveAt: number | null = null private githubCacheDirty = false + private terminalTeardownIntentJournalReady = false + private sshRemotePtyLeasesByIdentity: Map | null = null private gitUsernameCache = new Map() private loadNeedsSave = false private settingsChangeListeners = new Set< @@ -2637,6 +2718,10 @@ export class Store { fallbackSnapshotRoot: legacySnapshotRoot === profileSnapshotRoot ? null : legacySnapshotRoot } const loaded = this.load() + this.terminalTeardownIntentJournalReady = applyTerminalTeardownIntentSnapshot( + loaded, + this.dataFile + ) const normalized = normalizePersistedPaneIdentityState(loaded) this.state = normalized.state const adaptedProjectGroups = this.adaptFlatFolderScanProjectGroups() @@ -2644,13 +2729,7 @@ export class Store { setMigrationUnsupportedPty(entry) } for (const entry of normalized.legacyPaneKeyAliasEntries) { - agentHookServer.registerPaneKeyAlias( - entry.legacyPaneKey, - entry.stablePaneKey, - entry.ptyId, - entry.updatedAt, - { overwriteExisting: false } - ) + registerPersistedPaneKeyAlias(entry) } setMigrationUnsupportedPtyPersistenceListener((entries) => { this.state.migrationUnsupportedPtyEntries = entries @@ -2996,6 +3075,11 @@ export class Store { const migratedTerminalLineHeight = normalizeTerminalLineHeight( parsed.settings?.terminalLineHeight ) + const terminalRightClickToPasteDefaultedForPlatform = + parsed.settings?.terminalRightClickToPasteDefaultedForPlatform === true + if (!terminalRightClickToPasteDefaultedForPlatform) { + this.loadNeedsSave = true + } if ( parsed.settings?.terminalLineHeight !== undefined && parsed.settings.terminalLineHeight !== migratedTerminalLineHeight @@ -3116,6 +3200,9 @@ export class Store { // product intent was only to change the default, and the setting // stays user-toggleable. ...stripLegacyTerminalScrollbackBytes(parsed.settings), + prBotAuthorOverrides: normalizePRBotAuthorOverrides( + parsed.settings?.prBotAuthorOverrides + ), // Why: v1.3.42 renamed the cosmetic sidekick setting to pet. Carry // the old persisted flag forward once so enabled users don't lose it. experimentalPet: @@ -3135,6 +3222,15 @@ export class Store { ...migratedAutoRenameBranchFromWork, ...migratedTerminalCursorStyle, terminalLineHeight: migratedTerminalLineHeight, + // Why: the old global true default was inherited, while false was + // always an explicit opt-out and must survive this one-shot reset. + terminalRightClickToPaste: terminalRightClickToPasteDefaultedForPlatform + ? (parsed.settings?.terminalRightClickToPaste ?? + defaults.settings.terminalRightClickToPaste) + : parsed.settings?.terminalRightClickToPaste === false + ? false + : defaults.settings.terminalRightClickToPaste, + terminalRightClickToPasteDefaultedForPlatform: true, ...migratedTerminalTuiScrollSensitivity.settings, experimentalActivity: migratedExperimentalActivity, experimentalActivityDefaultedOffForAllUsers: true, @@ -3305,6 +3401,25 @@ export class Store { ) { this.loadNeedsSave = true } + // Why: only upgraded profiles that still use the new default get + // the one-time usage-display change notice; brand-new profiles and + // users who already chose remaining stay quiet. + const usagePercentageDisplayChangeNoticeDismissed = + resolveUsagePercentageDisplayChangeNoticeDismissed({ + rawDismissed: parsed.ui?.usagePercentageDisplayChangeNoticeDismissed, + rawUsagePercentageDisplay: parsed.ui?.usagePercentageDisplay, + isExistingProfile: isExistingPersistedProfile({ + repoCount: parsed.repos?.length ?? 0, + onboardingClosedAt: normalizedOnboarding.closedAt, + ui: parsed.ui + }) + }) + if ( + parsed.ui?.usagePercentageDisplayChangeNoticeDismissed !== + usagePercentageDisplayChangeNoticeDismissed + ) { + this.loadNeedsSave = true + } return { ...defaults.ui, // Why: missing card properties should follow the persisted card @@ -3318,6 +3433,7 @@ export class Store { rightSidebarOpen, rightSidebarTab: normalizeRightSidebarTab(parsed.ui?.rightSidebarTab), setupGuideSidebarDismissed, + usagePercentageDisplayChangeNoticeDismissed, setupGuideBrowserMilestoneMigrated: typeof parsed.ui?.setupGuideBrowserMilestoneMigrated === 'boolean' ? parsed.ui.setupGuideBrowserMilestoneMigrated @@ -3384,6 +3500,18 @@ export class Store { sshRemotePtyLeases: (parsed.sshRemotePtyLeases ?? []) .map(normalizeSshRemotePtyLease) .filter((lease): lease is SshRemotePtyLease => lease !== null), + pendingLocalPtyShutdowns: (parsed.pendingLocalPtyShutdowns ?? []) + .map(normalizeLocalPtyShutdown) + .filter((request): request is PersistedLocalPtyShutdown => request !== null), + pendingRuntimeTerminalCloses: (parsed.pendingRuntimeTerminalCloses ?? []) + .map(normalizeRuntimeTerminalClose) + .filter((request): request is PersistedRuntimeTerminalClose => request !== null), + terminalTeardownIntentRevision: + typeof parsed.terminalTeardownIntentRevision === 'number' && + Number.isSafeInteger(parsed.terminalTeardownIntentRevision) && + parsed.terminalTeardownIntentRevision >= 0 + ? parsed.terminalTeardownIntentRevision + : 0, claudeLivePtySessionIds: normalizeClaudeLivePtySessionIds(parsed.claudeLivePtySessionIds), migrationUnsupportedPtyEntries: normalizeMigrationUnsupportedPtyEntries( parsed.migrationUnsupportedPtyEntries @@ -3682,6 +3810,11 @@ export class Store { // is not what the file holds. if (this.writeGeneration === gen) { this.lastWrittenStateHash = stateHash + // Why: a teardown mutation can append while this stale payload is in + // flight. Keep its journal record until that newer state reaches disk. + if (this.computeStateHash() === stateHash) { + this.compactTerminalTeardownIntentJournal() + } } } finally { if (!renamed) { @@ -3732,6 +3865,7 @@ export class Store { renameSync(tmpFile, dataFile) renamed = true this.lastWrittenStateHash = stateHash + this.compactTerminalTeardownIntentJournal() } finally { if (!renamed) { try { @@ -3761,6 +3895,19 @@ export class Store { this.writeToDiskSync({ force: asyncWriteWasInFlight }) } + private compactTerminalTeardownIntentJournal(): void { + try { + // Why: per-owner appends stay O(1); each successful full-state save + // bounds replay and disk growth to one current checkpoint. + writeTerminalTeardownIntentSnapshot(this.state, this.dataFile) + this.terminalTeardownIntentJournalReady = true + } catch (error) { + // The full store already contains this revision, so stale journal records + // are fenced on reload and compaction can safely retry on the next save. + console.warn('[persistence] Failed to compact terminal teardown intent journal:', error) + } + } + // ── Repos ────────────────────────────────────────────────────────── getRepos(): Repo[] { @@ -5246,6 +5393,13 @@ export class Store { if ('uiLanguage' in updates) { sanitizedUpdates.uiLanguage = normalizeUiLanguage(updates.uiLanguage) } + if ('prBotAuthorOverrides' in updates) { + // Why: every writer (desktop IPC, paired web RPC, and migrations) reaches + // this boundary, so the persisted list stays bounded and well-formed. + sanitizedUpdates.prBotAuthorOverrides = normalizePRBotAuthorOverrides( + updates.prBotAuthorOverrides + ) + } const historyWithPreviousLayout = buildWorkspaceDirHistoryForUpdate( this.state.settings, sanitizedUpdates @@ -5333,6 +5487,9 @@ export class Store { this.state.ui?.workspaceBoardColumnWidth ), syncTaskStatusFromWorkspaceBoard: this.state.ui?.syncTaskStatusFromWorkspaceBoard === true, + usagePercentageDisplay: normalizeUsagePercentageDisplay( + this.state.ui?.usagePercentageDisplay + ), // Why: strict boolean coercion so a missing/legacy value reads as false // (first-run notice still fires) rather than leaking a non-bool through. trayMinimizeNoticeShown: this.state.ui?.trayMinimizeNoticeShown === true, @@ -5412,6 +5569,9 @@ export class Store { sanitizedUpdates.syncTaskStatusFromWorkspaceBoard !== undefined ? sanitizedUpdates.syncTaskStatusFromWorkspaceBoard === true : this.state.ui?.syncTaskStatusFromWorkspaceBoard === true, + usagePercentageDisplay: normalizeUsagePercentageDisplay( + sanitizedUpdates.usagePercentageDisplay ?? this.state.ui?.usagePercentageDisplay + ), markdownTocPanelWidth: clampMarkdownTocPanelWidth( sanitizedUpdates.markdownTocPanelWidth ?? this.state.ui?.markdownTocPanelWidth ), @@ -5635,13 +5795,7 @@ export class Store { } } for (const entry of normalized.legacyPaneKeyAliasEntries) { - agentHookServer.registerPaneKeyAlias( - entry.legacyPaneKey, - entry.stablePaneKey, - entry.ptyId, - entry.updatedAt, - { overwriteExisting: false } - ) + registerPersistedPaneKeyAlias(entry) } session = normalized.session const remappedLeases = remapSshRemotePtyLeaseLeafIds( @@ -5651,6 +5805,7 @@ export class Store { ) if (remappedLeases.changed) { this.state.sshRemotePtyLeases = remappedLeases.leases + this.sshRemotePtyLeasesByIdentity = null } if (session && prior) { const priorTabs = prior.tabsByWorktree ?? {} @@ -6207,6 +6362,7 @@ export class Store { for (const lease of this.state.sshRemotePtyLeases ?? []) { if (lease.targetId === oldTargetId) { lease.targetId = newTargetId + this.sshRemotePtyLeasesByIdentity = null carrierChanged = true } } @@ -6254,6 +6410,116 @@ export class Store { return leases.filter((lease) => targetId === undefined || lease.targetId === targetId) } + private getSshRemotePtyLeasesByIdentity(): Map { + this.sshRemotePtyLeasesByIdentity ??= indexSshRemotePtyLeases( + this.state.sshRemotePtyLeases ?? [] + ) + return this.sshRemotePtyLeasesByIdentity + } + + getPendingLocalPtyShutdowns(): PersistedLocalPtyShutdown[] { + return [...(this.state.pendingLocalPtyShutdowns ?? [])] + } + + private shouldPersistTerminalTeardownMutation(changed: boolean): boolean { + // Why: an earlier failed journal write can leave disk behind memory, so an + // idempotent retry must checkpoint even after memory already changed. + return changed || !this.terminalTeardownIntentJournalReady + } + + upsertPendingLocalPtyShutdown(request: PersistedLocalPtyShutdown): void { + this.state.pendingLocalPtyShutdowns ??= [] + const index = this.state.pendingLocalPtyShutdowns.findIndex( + (entry) => entry.ptyId === request.ptyId + ) + if (index >= 0) { + this.state.pendingLocalPtyShutdowns[index] = request + } else { + this.state.pendingLocalPtyShutdowns.push(request) + } + this.persistTerminalTeardownIntents({ kind: 'local-upsert', request }) + } + + removePendingLocalPtyShutdown(ptyId: string): void { + const before = this.state.pendingLocalPtyShutdowns?.length ?? 0 + this.state.pendingLocalPtyShutdowns = (this.state.pendingLocalPtyShutdowns ?? []).filter( + (entry) => entry.ptyId !== ptyId + ) + if ( + this.shouldPersistTerminalTeardownMutation( + this.state.pendingLocalPtyShutdowns.length !== before + ) + ) { + this.persistTerminalTeardownIntents({ kind: 'local-remove', ptyId }) + } + } + + getPendingRuntimeTerminalCloses(): PersistedRuntimeTerminalClose[] { + return [...(this.state.pendingRuntimeTerminalCloses ?? [])] + } + + upsertPendingRuntimeTerminalClose(request: PersistedRuntimeTerminalClose): void { + this.state.pendingRuntimeTerminalCloses ??= [] + const index = this.state.pendingRuntimeTerminalCloses.findIndex( + (entry) => entry.environmentId === request.environmentId && entry.handle === request.handle + ) + if (index >= 0) { + this.state.pendingRuntimeTerminalCloses[index] = request + } else { + this.state.pendingRuntimeTerminalCloses.push(request) + } + this.persistTerminalTeardownIntents({ kind: 'runtime-upsert', request }) + } + + removePendingRuntimeTerminalClose(environmentId: string, handle: string): void { + const before = this.state.pendingRuntimeTerminalCloses?.length ?? 0 + this.state.pendingRuntimeTerminalCloses = ( + this.state.pendingRuntimeTerminalCloses ?? [] + ).filter((entry) => entry.environmentId !== environmentId || entry.handle !== handle) + if ( + this.shouldPersistTerminalTeardownMutation( + this.state.pendingRuntimeTerminalCloses.length !== before + ) + ) { + this.persistTerminalTeardownIntents({ + kind: 'runtime-remove', + environmentId, + handle + }) + } + } + + removePendingRuntimeTerminalClosesForEnvironment(environmentId: string): void { + const before = this.state.pendingRuntimeTerminalCloses?.length ?? 0 + this.state.pendingRuntimeTerminalCloses = ( + this.state.pendingRuntimeTerminalCloses ?? [] + ).filter((entry) => entry.environmentId !== environmentId) + if ( + this.shouldPersistTerminalTeardownMutation( + this.state.pendingRuntimeTerminalCloses.length !== before + ) + ) { + this.persistTerminalTeardownIntents({ + kind: 'runtime-remove-environment', + environmentId + }) + } + } + + private sshTeardownIntentMutationForLease( + lease: Pick + ): TerminalTeardownIntentMutation { + return { + kind: 'ssh-set', + targetId: lease.targetId, + ptyId: lease.ptyId, + ...(lease.relayInstanceId ? { relayInstanceId: lease.relayInstanceId } : {}), + ...(lease.shutdownRequestedAt === undefined + ? {} + : { shutdownRequestedAt: lease.shutdownRequestedAt }) + } + } + upsertSshRemotePtyLease( lease: Omit & Partial> @@ -6263,8 +6529,10 @@ export class Store { if (normalizedLease.leafId !== undefined && !isTerminalLeafId(normalizedLease.leafId)) { delete normalizedLease.leafId } + const parsedPtyId = parseAppSshPtyId(normalizedLease.ptyId) + normalizedLease.relayInstanceId ??= parsedPtyId?.relayInstanceId // Why: app-facing SSH PTY ids are globally scoped; durable relay leases - // stay target-local so reconnect can call relay pty.attach with raw ids. + // keep raw ids plus their boot generation so reconnect can reject reuse. normalizedLease.ptyId = this.getRelayPtyIdForSshLeaseStorage( normalizedLease.targetId, normalizedLease.ptyId @@ -6275,10 +6543,13 @@ export class Store { entry.targetId === normalizedLease.targetId && entry.ptyId === normalizedLease.ptyId ) const existing = existingIndex >= 0 ? this.state.sshRemotePtyLeases[existingIndex] : undefined + const sameGeneration = existing?.relayInstanceId === normalizedLease.relayInstanceId const next: SshRemotePtyLease = { - ...existing, + ...(sameGeneration ? existing : undefined), ...normalizedLease, - createdAt: existing?.createdAt ?? normalizedLease.createdAt ?? now, + createdAt: sameGeneration + ? (existing?.createdAt ?? normalizedLease.createdAt ?? now) + : (normalizedLease.createdAt ?? now), updatedAt: normalizedLease.updatedAt ?? now } if (existingIndex >= 0) { @@ -6286,7 +6557,9 @@ export class Store { } else { this.state.sshRemotePtyLeases.push(next) } + this.sshRemotePtyLeasesByIdentity = null this.flush() + this.persistTerminalTeardownIntents(this.sshTeardownIntentMutationForLease(next)) } markSshRemotePtyLeases(targetId: string, state: SshRemotePtyLease['state']): void { @@ -6312,6 +6585,10 @@ export class Store { } changed = true } + if (shouldClearBindings && lease.shutdownRequestedAt !== undefined) { + delete lease.shutdownRequestedAt + changed = true + } if (shouldClearBindings) { leasesToClear.push(lease) } @@ -6319,25 +6596,41 @@ export class Store { const bindingsChanged = shouldClearBindings ? this.clearSshRemotePtyBindingsForLeases(targetId, leasesToClear) : false - if (changed || bindingsChanged) { - this.flush() + if (this.shouldPersistTerminalTeardownMutation(changed || bindingsChanged)) { + if (shouldClearBindings) { + this.persistTerminalTeardownIntents({ kind: 'ssh-remove-target', targetId }) + } else { + this.scheduleSave() + } } } - markSshRemotePtyLease(targetId: string, ptyId: string, state: SshRemotePtyLease['state']): void { + markSshRemotePtyLease( + targetId: string, + ptyId: string, + state: SshRemotePtyLease['state'] + ): boolean { + const requestedGeneration = parseAppSshPtyId(ptyId)?.relayInstanceId const relayPtyId = this.getRelayPtyIdForSshLeaseStorage(targetId, ptyId) - const lease = this.state.sshRemotePtyLeases?.find( - (entry) => entry.targetId === targetId && entry.ptyId === relayPtyId + const lease = this.getSshRemotePtyLeasesByIdentity().get( + sshRemotePtyLeaseIdentityKey(targetId, relayPtyId, requestedGeneration) ) if (!lease) { - return + return false } const shouldClearBindings = state === 'terminated' || state === 'expired' + let intentChanged = false + if (shouldClearBindings && lease.shutdownRequestedAt !== undefined) { + delete lease.shutdownRequestedAt + intentChanged = true + } if (lease.state === state) { - if (shouldClearBindings && this.clearSshRemotePtyBindingsForLeases(targetId, [lease])) { - this.flush() + const bindingsChanged = + shouldClearBindings && this.clearSshRemotePtyBindingsForLeases(targetId, [lease]) + if (this.shouldPersistTerminalTeardownMutation(bindingsChanged || intentChanged)) { + this.persistTerminalTeardownIntents(this.sshTeardownIntentMutationForLease(lease)) } - return + return true } const now = Date.now() lease.state = state @@ -6349,22 +6642,86 @@ export class Store { } if (shouldClearBindings) { this.clearSshRemotePtyBindingsForLeases(targetId, [lease]) + this.persistTerminalTeardownIntents(this.sshTeardownIntentMutationForLease(lease)) + } else { + this.scheduleSave() } - this.flush() + return true + } + + migrateLegacySshRemotePtyLeaseGeneration( + targetId: string, + ptyId: string, + relayInstanceId: string + ): boolean { + const relayPtyId = this.getRelayPtyIdForSshLeaseStorage(targetId, ptyId) + const legacyKey = sshRemotePtyLeaseIdentityKey(targetId, relayPtyId) + const leasesByIdentity = this.getSshRemotePtyLeasesByIdentity() + const lease = leasesByIdentity.get(legacyKey) + if (!lease) { + return false + } + leasesByIdentity.delete(legacyKey) + const now = Date.now() + lease.relayInstanceId = relayInstanceId + leasesByIdentity.set(sshRemotePtyLeaseIdentityKey(targetId, relayPtyId, relayInstanceId), lease) + lease.state = 'attached' + lease.updatedAt = now + lease.lastAttachedAt = now + // Why: crash-safe adoption must remain O(1) per restored pane instead of + // synchronously serializing the full persisted store during startup. + this.persistTerminalTeardownIntents({ + kind: 'ssh-migrate-generation', + targetId, + ptyId: relayPtyId, + relayInstanceId, + attachedAt: now + }) + return true + } + + markSshRemotePtyShutdownRequested(targetId: string, ptyId: string): boolean { + const requestedGeneration = parseAppSshPtyId(ptyId)?.relayInstanceId + const relayPtyId = this.getRelayPtyIdForSshLeaseStorage(targetId, ptyId) + const lease = this.getSshRemotePtyLeasesByIdentity().get( + sshRemotePtyLeaseIdentityKey(targetId, relayPtyId, requestedGeneration) + ) + if (!lease) { + return false + } + lease.shutdownRequestedAt = Date.now() + lease.updatedAt = lease.shutdownRequestedAt + this.persistTerminalTeardownIntents(this.sshTeardownIntentMutationForLease(lease)) + return true } removeSshRemotePtyLease(targetId: string, ptyId: string): void { + const requestedGeneration = parseAppSshPtyId(ptyId)?.relayInstanceId const relayPtyId = this.getRelayPtyIdForSshLeaseStorage(targetId, ptyId) const leases = (this.state.sshRemotePtyLeases ?? []).filter( - (lease) => lease.targetId === targetId && lease.ptyId === relayPtyId + (lease) => + lease.targetId === targetId && + lease.ptyId === relayPtyId && + lease.relayInstanceId === requestedGeneration ) const before = this.state.sshRemotePtyLeases?.length ?? 0 this.clearSshRemotePtyBindingsForLeases(targetId, leases) this.state.sshRemotePtyLeases = (this.state.sshRemotePtyLeases ?? []).filter( - (lease) => lease.targetId !== targetId || lease.ptyId !== relayPtyId + (lease) => + lease.targetId !== targetId || + lease.ptyId !== relayPtyId || + lease.relayInstanceId !== requestedGeneration ) - if (this.state.sshRemotePtyLeases.length !== before) { - this.flush() + if ( + this.shouldPersistTerminalTeardownMutation(this.state.sshRemotePtyLeases.length !== before) + ) { + this.sshRemotePtyLeasesByIdentity = null + this.persistTerminalTeardownIntents({ + kind: 'ssh-set', + targetId, + ptyId: relayPtyId, + ...(requestedGeneration ? { relayInstanceId: requestedGeneration } : {}) + }) } } @@ -6375,8 +6732,11 @@ export class Store { this.state.sshRemotePtyLeases = this.state.sshRemotePtyLeases.filter( (lease) => lease.targetId !== targetId ) - if (this.state.sshRemotePtyLeases.length !== before) { - this.flush() + if ( + this.shouldPersistTerminalTeardownMutation(this.state.sshRemotePtyLeases.length !== before) + ) { + this.sshRemotePtyLeasesByIdentity = null + this.persistTerminalTeardownIntents({ kind: 'ssh-remove-target', targetId }) } } @@ -6447,6 +6807,32 @@ export class Store { // ── Flush (for shutdown) ─────────────────────────────────────────── + private persistTerminalTeardownIntents(mutation: TerminalTeardownIntentMutation): void { + if (this.writesFrozen) { + return + } + this.state.terminalTeardownIntentRevision = (this.state.terminalTeardownIntentRevision ?? 0) + 1 + try { + // Why: crash durability is per owner, but an O(1) journal append avoids + // rewriting every retained owner and three filesystem calls per mutation. + appendTerminalTeardownIntentMutation( + this.state, + this.dataFile, + mutation, + this.terminalTeardownIntentJournalReady + ) + this.terminalTeardownIntentJournalReady = true + this.scheduleSave() + } catch (error) { + console.warn('[persistence] Failed to write terminal teardown intent snapshot:', error) + // Why: an append may have left a partial record. If the full-store + // fallback also fails, the next mutation must checkpoint, never append. + this.terminalTeardownIntentJournalReady = false + // The full-store sync path is an expensive but durable I/O-failure fallback. + this.flushOrThrow() + } + } + flush(): void { try { this.flushOrThrow() diff --git a/src/main/pi/agent-status-extension-source.test.ts b/src/main/pi/agent-status-extension-source.test.ts index 09796786192..47ac259f1bb 100644 --- a/src/main/pi/agent-status-extension-source.test.ts +++ b/src/main/pi/agent-status-extension-source.test.ts @@ -113,6 +113,7 @@ function createHarness(args: { Promise, Buffer, URL, + AbortController, setTimeout, clearTimeout } as Record @@ -192,7 +193,7 @@ describe('getPiAgentStatusExtensionSource', () => { await harness.callHook('agent_start') expect(harness.fetchMock).toHaveBeenCalledTimes(1) - expect(harness.spawnMock).toHaveBeenCalledTimes(1) + await vi.waitFor(() => expect(harness.spawnMock).toHaveBeenCalledTimes(1)) const [command, args, options] = harness.spawnMock.mock.calls[0] ?? [] expect(command).toBe('/mnt/c/Windows/System32/curl.exe') @@ -253,7 +254,7 @@ describe('getPiAgentStatusExtensionSource', () => { await harness.callHook('agent_start') await harness.callHook('agent_end') - expect(harness.spawnMock).toHaveBeenCalledTimes(2) + await vi.waitFor(() => expect(harness.spawnMock).toHaveBeenCalledTimes(2)) // Why: WSL-ness and curl.exe presence are process-lifetime constants; // the per-event failure path must not re-probe /proc or /mnt/c. const procReads = harness.fsMock.readFileSync.mock.calls.filter(([path]) => @@ -278,6 +279,98 @@ describe('getPiAgentStatusExtensionSource', () => { expect(harness.spawnMock).not.toHaveBeenCalled() }) + it('does not hold Pi event dispatch open while hook delivery is pending', async () => { + let finishDelivery: (() => void) | undefined + const harness = createHarness({ + kind: 'pi', + fetchImpl: vi.fn( + () => + new Promise((resolve) => { + finishDelivery = () => resolve({ ok: true }) + }) + ) + }) + + let handlerReturned = false + const handlerCall = harness.callHook('agent_start').then(() => { + handlerReturned = true + }) + await Promise.resolve() + + // Why: Pi awaits extension handlers, so loopback status delivery cannot + // remain on the agent's critical path when Orca is stalled or restarting. + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + await vi.waitFor(() => expect(handlerReturned).toBe(true)) + + finishDelivery?.() + await handlerCall + }) + + it('leaves runtime shutdown to PTY teardown instead of reporting turn completion', () => { + const harness = createHarness({ kind: 'pi' }) + + // Why: Pi emits session_shutdown for reload/new/resume/fork while its PTY + // stays alive. agent_end is the only extension event that proves done. + expect(harness.handlers.session_shutdown).toBeUndefined() + }) + + it('bounds stalled delivery to one active request and the latest pending status', async () => { + const finishDeliveries: (() => void)[] = [] + const harness = createHarness({ + kind: 'pi', + fetchImpl: vi.fn( + () => + new Promise((resolve) => { + finishDeliveries.push(() => resolve({ ok: true })) + }) + ) + }) + + await Promise.all([ + harness.callHook('agent_start'), + harness.callHook('tool_execution_start', { toolName: 'read', args: { path: 'one.ts' } }), + harness.callHook('agent_end') + ]) + + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + finishDeliveries[0]?.() + await vi.waitFor(() => expect(harness.fetchMock).toHaveBeenCalledTimes(2)) + const latestBody = JSON.parse(String(harness.fetchMock.mock.calls[1]?.[1]?.body)) + expect(latestBody.payload).toEqual({ hook_event_name: 'agent_end' }) + + finishDeliveries[1]?.() + }) + + it('abandons a stalled request after one second and delivers the latest status', async () => { + vi.useFakeTimers() + try { + let requestCount = 0 + const harness = createHarness({ + kind: 'pi', + fetchImpl: vi.fn(() => { + requestCount += 1 + return requestCount === 1 ? new Promise(() => {}) : Promise.resolve({ ok: true }) + }) + }) + + await harness.callHook('agent_start') + await harness.callHook('agent_end') + + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + const firstSignal = harness.fetchMock.mock.calls[0]?.[1]?.signal as AbortSignal + expect(firstSignal.aborted).toBe(false) + + await vi.advanceTimersByTimeAsync(1000) + + expect(firstSignal.aborted).toBe(true) + expect(harness.fetchMock).toHaveBeenCalledTimes(2) + const latestBody = JSON.parse(String(harness.fetchMock.mock.calls[1]?.[1]?.body)) + expect(latestBody.payload).toEqual({ hook_event_name: 'agent_end' }) + } finally { + vi.useRealTimers() + } + }) + it('does not treat WSLENV alone as WSL evidence', async () => { const harness = createHarness({ kind: 'omp', diff --git a/src/main/pi/agent-status-extension-source.ts b/src/main/pi/agent-status-extension-source.ts index a607199e81d..a7abe4df9a5 100644 --- a/src/main/pi/agent-status-extension-source.ts +++ b/src/main/pi/agent-status-extension-source.ts @@ -11,6 +11,7 @@ // keep the source body in plain JS without TS types and avoid pulling pi or // any Orca dep into the pi runtime. import type { PiAgentKind } from '../../shared/pi-agent-kind' +import { getPiAgentStatusHandlerSourceLines } from './agent-status-handler-source' export const ORCA_PI_AGENT_STATUS_EXTENSION_FILE = 'orca-agent-status.ts' @@ -25,6 +26,12 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin '// Why: warn-once so a recurring parse error on a malformed endpoint', '// file does not spam stderr inside the pi TUI on every event.', 'let warnedBadEndpoint = false', + '// Why: Pi awaits extension handlers. Status delivery stays off that', + '// critical path, and the latest-only pending slot prevents a stalled', + '// Orca receiver from building an unbounded queue of obsolete snapshots.', + 'const HOOK_POST_TIMEOUT_MS = 1000', + 'let activePost = false', + 'let pendingPost: { hookEventName: string; extra: Record } | null = null', '', '// Why: re-reading the endpoint file on every event is cheap (small file,', '// rare changes) but stat+mtime caching avoids re-parsing on every event', @@ -104,7 +111,28 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin ' return configuredPath', '}', '', - 'async function post(hookEventName: string, extra: Record = {}): Promise {', + 'function post(hookEventName: string, extra: Record = {}): void {', + ' pendingPost = { hookEventName, extra }', + ' drainPosts()', + '}', + '', + 'function drainPosts(): void {', + ' if (activePost || !pendingPost) return', + ' const next = pendingPost', + ' pendingPost = null', + ' activePost = true', + ' void postOnce(next.hookEventName, next.extra)', + ' .catch(() => {})', + ' .finally(() => {', + ' activePost = false', + ' drainPosts()', + ' })', + '}', + '', + 'async function postOnce(', + ' hookEventName: string,', + ' extra: Record', + '): Promise {', ' const coords = resolveHookCoords()', ' const paneKey = process.env.ORCA_PANE_KEY', ' if (!coords.port || !coords.token || !paneKey) return', @@ -118,20 +146,35 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin ' version: coords.version,', ' payload: { hook_event_name: hookEventName, ...extra },', ' })', + " const controller = typeof AbortController !== 'undefined' ? new AbortController() : null", + ' let timeout: ReturnType | undefined', + ' const timeoutPromise = new Promise((_resolve, reject) => {', + ' timeout = setTimeout(() => {', + ' controller?.abort()', + " reject(new Error('Orca hook delivery timed out'))", + ' }, HOOK_POST_TIMEOUT_MS)', + " if (typeof timeout.unref === 'function') timeout.unref()", + ' })', ' try {', - ' await fetch(url, {', - " method: 'POST',", - ' headers: {', - " 'Content-Type': 'application/json',", - " 'X-Orca-Agent-Hook-Token': coords.token,", - ' },', - ' body,', - ' })', + ' await Promise.race([', + ' fetch(url, {', + " method: 'POST',", + ' headers: {', + " 'Content-Type': 'application/json',", + " 'X-Orca-Agent-Hook-Token': coords.token,", + ' },', + ' body,', + ' ...(controller ? { signal: controller.signal } : {}),', + ' }),', + ' timeoutPromise,', + ' ])', ' } catch {', ' // Why: status reporting must never fail the pi run just because Orca', ' // is unavailable or the loopback request failed (e.g. Orca restart).', ' if (!isWslRuntime()) return', ' postViaWindowsCurl(url, coords, body)', + ' } finally {', + ' if (timeout) clearTimeout(timeout)', ' }', '}', '', @@ -215,81 +258,6 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin ' }', '}', '', - '// Why: pi assistant messages carry content as an array of parts', - "// ({ type: 'text', text } / tool_use / tool_result / reasoning). We only", - "// surface the concatenated text parts as the visible 'last assistant", - "// message' for the dashboard preview — tool_use / reasoning would be", - '// noise (the dashboard already shows the active tool name + input).', - 'function extractAssistantText(message: unknown): string {', - " if (!message || typeof message !== 'object') return ''", - ' const content = (message as { content?: unknown }).content', - " if (typeof content === 'string') return content", - " if (!Array.isArray(content)) return ''", - " let out = ''", - ' for (const part of content) {', - " if (part && typeof part === 'object' && (part as { type?: unknown }).type === 'text') {", - ' const text = (part as { text?: unknown }).text', - " if (typeof text === 'string') out += text", - ' }', - ' }', - ' return out', - '}', - '', - "// Why: pi's tool_call event input shape is tool-specific (event.input is", - '// the raw args object). The agent-hooks server already runs', - '// deriveToolInputPreview(toolName, input) to render a friendly preview', - "// for known tool names ('bash' → command, 'read'/'write'/'edit' → path,", - '// etc.), so we forward the raw object verbatim under the same field', - '// names Claude uses (tool_name / tool_input) and let the server pick the', - '// preview. Keeps tool-name knowledge centralized on the receiver side.', - 'export default function (pi): void {', - " pi.on('before_agent_start', async (event) => {", - " await post('before_agent_start', { prompt: event.prompt ?? '' })", - ' })', - '', - " pi.on('agent_start', async () => {", - " await post('agent_start')", - ' })', - '', - " pi.on('tool_execution_start', async (event) => {", - " await post('tool_execution_start', {", - ' tool_name: event.toolName,', - ' tool_input: event.args,', - ' })', - ' })', - '', - " pi.on('tool_call', async (event) => {", - " await post('tool_call', {", - ' tool_name: event.toolName,', - ' tool_input: event.input,', - ' })', - ' })', - '', - " pi.on('tool_execution_end', async (event) => {", - " await post('tool_execution_end', {", - ' tool_name: event.toolName,', - ' })', - ' })', - '', - " // Why: capture the assistant's final text on each completed message", - ' // so the dashboard preview reflects the most recent reply even before', - ' // agent_end fires. message_end is the right hook because pi guarantees', - ' // it fires after the message is finalized (post-streaming).', - " pi.on('message_end', async (event) => {", - " if (event.message?.role !== 'assistant') return", - ' const text = extractAssistantText(event.message)', - ' if (!text) return', - " await post('message_end', { role: 'assistant', text })", - ' })', - '', - " pi.on('agent_end', async () => {", - " await post('agent_end')", - ' })', - '', - " pi.on('session_shutdown', async () => {", - " await post('session_shutdown')", - ' })', - '}', - '' + ...getPiAgentStatusHandlerSourceLines() ].join('\n') } diff --git a/src/main/pi/agent-status-handler-source.ts b/src/main/pi/agent-status-handler-source.ts new file mode 100644 index 00000000000..625e89df7c0 --- /dev/null +++ b/src/main/pi/agent-status-handler-source.ts @@ -0,0 +1,78 @@ +// Why: keep the generated handler registrations separate from hook transport; +// both are independently sizeable and the installed extension concatenates them. +export function getPiAgentStatusHandlerSourceLines(): string[] { + return [ + '// Why: pi assistant messages carry content as an array of parts', + "// ({ type: 'text', text } / tool_use / tool_result / reasoning). We only", + "// surface the concatenated text parts as the visible 'last assistant", + "// message' for the dashboard preview — tool_use / reasoning would be", + '// noise (the dashboard already shows the active tool name + input).', + 'function extractAssistantText(message: unknown): string {', + " if (!message || typeof message !== 'object') return ''", + ' const content = (message as { content?: unknown }).content', + " if (typeof content === 'string') return content", + " if (!Array.isArray(content)) return ''", + " let out = ''", + ' for (const part of content) {', + " if (part && typeof part === 'object' && (part as { type?: unknown }).type === 'text') {", + ' const text = (part as { text?: unknown }).text', + " if (typeof text === 'string') out += text", + ' }', + ' }', + ' return out', + '}', + '', + "// Why: pi's tool_call event input shape is tool-specific (event.input is", + '// the raw args object). The agent-hooks server already runs', + '// deriveToolInputPreview(toolName, input) to render a friendly preview', + "// for known tool names ('bash' → command, 'read'/'write'/'edit' → path,", + '// etc.), so we forward the raw object verbatim under the same field', + '// names Claude uses (tool_name / tool_input) and let the server pick the', + '// preview. Keeps tool-name knowledge centralized on the receiver side.', + 'export default function (pi): void {', + " pi.on('before_agent_start', (event) => {", + " post('before_agent_start', { prompt: event.prompt ?? '' })", + ' })', + '', + " pi.on('agent_start', () => {", + " post('agent_start')", + ' })', + '', + " pi.on('tool_execution_start', (event) => {", + " post('tool_execution_start', {", + ' tool_name: event.toolName,', + ' tool_input: event.args,', + ' })', + ' })', + '', + " pi.on('tool_call', (event) => {", + " post('tool_call', {", + ' tool_name: event.toolName,', + ' tool_input: event.input,', + ' })', + ' })', + '', + " pi.on('tool_execution_end', (event) => {", + " post('tool_execution_end', {", + ' tool_name: event.toolName,', + ' })', + ' })', + '', + " // Why: capture the assistant's final text on each completed message", + ' // so the dashboard preview reflects the most recent reply even before', + ' // agent_end fires. message_end is the right hook because pi guarantees', + ' // it fires after the message is finalized (post-streaming).', + " pi.on('message_end', (event) => {", + " if (event.message?.role !== 'assistant') return", + ' const text = extractAssistantText(event.message)', + ' if (!text) return', + " post('message_end', { role: 'assistant', text })", + ' })', + '', + " pi.on('agent_end', () => {", + " post('agent_end')", + ' })', + '}', + '' + ] +} diff --git a/src/main/project-runtime-git-options.ts b/src/main/project-runtime-git-options.ts index 5c6eccdb071..1c4e4945f32 100644 --- a/src/main/project-runtime-git-options.ts +++ b/src/main/project-runtime-git-options.ts @@ -1,8 +1,12 @@ import type { Store } from './persistence' import type { Repo } from '../shared/types' import { resolveLocalProjectRuntimeForRepo } from './local-project-runtime-resolution' +import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime' -export { resolveLocalProjectRuntimeForRepo } from './local-project-runtime-resolution' +export { + resolveLocalProjectRuntimeForRepo, + resolveLocalProjectRuntimesForRepos +} from './local-project-runtime-resolution' export type LocalProjectGitExecOptions = { cwd: string @@ -19,7 +23,16 @@ export function getLocalProjectGitExecOptions( ): LocalProjectGitExecOptions { // Why: local git must run in the same resolved project runtime as agents, // terminals, and preflight; repair states must not silently fall back to host git. - const projectRuntime = resolveLocalProjectRuntimeForRepo(store, repo) + return getLocalProjectGitExecOptionsForRuntime( + repo, + resolveLocalProjectRuntimeForRepo(store, repo) + ) +} + +function getLocalProjectGitExecOptionsForRuntime( + repo: Repo, + projectRuntime: ProjectExecutionRuntimeResolution | undefined +): LocalProjectGitExecOptions { if (!projectRuntime) { return { cwd: repo.path } } @@ -41,3 +54,13 @@ export function getLocalProjectWorktreeGitOptions( const { wslDistro } = getLocalProjectGitExecOptions(store, repo) return wslDistro ? { wslDistro } : {} } + +export function getLocalProjectWorktreeGitOptionsForRuntime( + repo: Repo, + projectRuntime: ProjectExecutionRuntimeResolution | undefined +): LocalProjectWorktreeGitOptions { + // Why: callers that already batch-resolved project runtimes must not rescan + // every project once per repo on a polling path. + const { wslDistro } = getLocalProjectGitExecOptionsForRuntime(repo, projectRuntime) + return wslDistro ? { wslDistro } : {} +} diff --git a/src/main/providers/local-pty-process-exit-proof.test.ts b/src/main/providers/local-pty-process-exit-proof.test.ts new file mode 100644 index 00000000000..7dd2d12da6f --- /dev/null +++ b/src/main/providers/local-pty-process-exit-proof.test.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { isLocalPtyProcessProvablyExited } from './local-pty-process-exit-proof' + +describe('isLocalPtyProcessProvablyExited', () => { + afterEach(() => vi.restoreAllMocks()) + + it('accepts only ESRCH as process-death proof', () => { + vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('gone'), { code: 'ESRCH' }) + }) + + expect(isLocalPtyProcessProvablyExited(123)).toBe(true) + }) + + it('treats EPERM as a still-live process', () => { + vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('denied'), { code: 'EPERM' }) + }) + + expect(isLocalPtyProcessProvablyExited(123)).toBe(false) + }) + + it('rejects invalid pids without probing', () => { + const kill = vi.spyOn(process, 'kill') + + expect(isLocalPtyProcessProvablyExited(0)).toBe(false) + expect(kill).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/providers/local-pty-process-exit-proof.ts b/src/main/providers/local-pty-process-exit-proof.ts new file mode 100644 index 00000000000..3f7a3ab06fe --- /dev/null +++ b/src/main/providers/local-pty-process-exit-proof.ts @@ -0,0 +1,13 @@ +export function isLocalPtyProcessProvablyExited(pid: number): boolean { + if (!Number.isSafeInteger(pid) || pid <= 0) { + return false + } + try { + process.kill(pid, 0) + return false + } catch (error) { + // Why: only ESRCH proves absence; EPERM and every other probe failure must + // retain the exact native owner for a later retry. + return (error as NodeJS.ErrnoException).code === 'ESRCH' + } +} diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index 66f68d6e868..48c5fb226f8 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -9,7 +9,8 @@ const { mkdirSyncMock, writeFileSyncMock, spawnMock, - resolveAgentForegroundProcessMock + resolveAgentForegroundProcessMock, + isLocalPtyProcessProvablyExitedMock } = vi.hoisted(() => ({ existsSyncMock: vi.fn(), statSyncMock: vi.fn(), @@ -17,7 +18,8 @@ const { mkdirSyncMock: vi.fn(), writeFileSyncMock: vi.fn(), spawnMock: vi.fn(), - resolveAgentForegroundProcessMock: vi.fn() + resolveAgentForegroundProcessMock: vi.fn(), + isLocalPtyProcessProvablyExitedMock: vi.fn(() => false) })) vi.mock('fs', () => ({ @@ -40,6 +42,10 @@ vi.mock('node-pty', () => ({ spawn: spawnMock })) +vi.mock('./local-pty-process-exit-proof', () => ({ + isLocalPtyProcessProvablyExited: isLocalPtyProcessProvablyExitedMock +})) + // Resolve PowerShell family names to deterministic absolute paths (the fs mock // above otherwise makes every probe miss). The real resolver — which skips the // Store App Execution Alias stub — is covered in @@ -124,6 +130,8 @@ describe('LocalPtyProvider', () => { mkdirSyncMock.mockReset() writeFileSyncMock.mockReset() resolveAgentForegroundProcessMock.mockReset() + isLocalPtyProcessProvablyExitedMock.mockReset() + isLocalPtyProcessProvablyExitedMock.mockReturnValue(false) resolveAgentForegroundProcessMock.mockImplementation( async (_pid: number, fallbackProcess: string | null) => fallbackProcess ) @@ -942,7 +950,32 @@ describe('LocalPtyProvider', () => { }) describe('shutdown', () => { - it('kills the PTY process', async () => { + it('rejects a stale pane identity before killing the local PTY', async () => { + const killSpy = mockProc.kill + const { id } = await provider.spawn({ + cols: 80, + rows: 24, + env: { ORCA_PANE_KEY: 'tab-b:leaf-b', ORCA_TAB_ID: 'tab-b' } + }) + + await expect( + provider.shutdown(id, { + immediate: true, + expectedPaneKey: 'tab-a:leaf-a', + expectedTabId: 'tab-a' + }) + ).rejects.toThrow('PTY identity mismatch') + expect(killSpy).not.toHaveBeenCalled() + expect(provider.hasPty(id)).toBe(true) + + await provider.shutdown(id, { + immediate: true, + expectedPaneKey: 'tab-b:leaf-b', + expectedTabId: 'tab-b' + }) + }) + + it('uses SIGKILL for immediate POSIX shutdown', async () => { // Why: capture the spy reference before shutdown triggers onExit → // POSIX kill neutralization. After neutralization, mockProc.kill is // replaced with a non-spy no-op to close the UnixTerminal.destroy() → @@ -950,7 +983,52 @@ describe('LocalPtyProvider', () => { const killSpy = mockProc.kill const { id } = await provider.spawn({ cols: 80, rows: 24 }) await provider.shutdown(id, { immediate: true }) - expect(killSpy).toHaveBeenCalled() + expect(killSpy).toHaveBeenCalledWith('SIGKILL') + }) + + it('retains the native PTY until an accepted kill produces exit proof', async () => { + mockProc.kill.mockImplementation(() => {}) + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + + await provider.shutdown(id, { immediate: true }) + + expect(provider.hasPty(id)).toBe(true) + expect(await provider.listProcesses()).toEqual( + expect.arrayContaining([expect.objectContaining({ id })]) + ) + exitCb?.({ exitCode: 137 }) + expect(provider.hasPty(id)).toBe(false) + }) + + it('does not let a queued old exit delete a same-id replacement', async () => { + let staleExit: ((event: { exitCode: number }) => void) | undefined + const oldProc = { + ...mockProc, + pid: 101, + kill: vi.fn(() => staleExit?.({ exitCode: 0 })), + onExit: vi.fn((callback) => { + staleExit = callback + return { dispose: vi.fn() } + }) + } + const replacementProc = { ...mockProc, pid: 202, kill: vi.fn() } + spawnMock.mockReturnValueOnce(oldProc).mockReturnValueOnce(replacementProc) + const exits = vi.fn() + provider.onExit(exits) + + await provider.spawn({ sessionId: 'same-id', cols: 80, rows: 24 }) + expect(provider.listOrphanedPtys(provider.advanceGeneration())).toContainEqual({ + id: 'same-id' + }) + await provider.shutdown('same-id', { immediate: true }) + await provider.spawn({ sessionId: 'same-id', cols: 80, rows: 24 }) + staleExit?.({ exitCode: 0 }) + + expect(provider.getPtyProcess('same-id')).toBe(replacementProc) + expect(provider.hasPty('same-id')).toBe(true) + expect(exits).toHaveBeenCalledOnce() + expect(exits).toHaveBeenCalledWith({ id: 'same-id', code: 0 }) + provider.killAll() }) it('invokes onExit callback via the node-pty exit handler', async () => { @@ -963,7 +1041,7 @@ describe('LocalPtyProvider', () => { it('does not destroy after an intentional Windows shutdown kill', async () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const killSpy = vi.fn() + const killSpy = vi.fn(() => exitCb?.({ exitCode: -1 })) const destroySpy = vi.fn(() => { killSpy() }) @@ -980,6 +1058,62 @@ describe('LocalPtyProvider', () => { expect(destroySpy).not.toHaveBeenCalled() }) + it('retains Windows ownership when native shutdown throws so retry can succeed', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const kill = vi + .fn() + .mockImplementationOnce(() => { + throw new Error('native close failed') + }) + .mockImplementationOnce(() => exitCb?.({ exitCode: -1 })) + spawnMock.mockReturnValue({ ...mockProc, kill }) + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + + await expect(provider.shutdown(id, { immediate: true })).rejects.toThrow( + 'native close failed' + ) + expect(provider.hasPty(id)).toBe(true) + expect(await provider.listProcesses()).toEqual( + expect.arrayContaining([expect.objectContaining({ id })]) + ) + + await expect(provider.shutdown(id, { immediate: true })).resolves.toBeUndefined() + expect(kill).toHaveBeenCalledTimes(2) + expect(provider.hasPty(id)).toBe(false) + }) + + it('reaps an exact native owner when kill throws after OS exit proof', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const exits = vi.fn() + const destroy = vi.fn() + provider.onExit(exits) + mockProc.kill.mockImplementation(() => { + throw new Error('native handle already closed') + }) + spawnMock.mockReturnValue({ ...mockProc, destroy }) + isLocalPtyProcessProvablyExitedMock.mockReturnValue(true) + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + + await expect(provider.shutdown(id, { immediate: true })).resolves.toBeUndefined() + + expect(provider.hasPty(id)).toBe(false) + expect(exits).toHaveBeenCalledWith({ id, code: -1 }) + expect(destroy).toHaveBeenCalledOnce() + }) + + it('reaps an accepted kill when OS exit proof replaces a missing onExit', async () => { + mockProc.kill.mockImplementation(() => {}) + isLocalPtyProcessProvablyExitedMock.mockReturnValue(true) + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + + await provider.shutdown(id, { immediate: true }) + + expect(provider.hasPty(id)).toBe(false) + expect(await provider.listProcesses()).not.toEqual( + expect.arrayContaining([expect.objectContaining({ id })]) + ) + }) + it('cancels pending shell-ready startup delivery on forced shutdown', async () => { vi.useFakeTimers() try { @@ -1113,6 +1247,28 @@ describe('LocalPtyProvider', () => { expect(newEntries[0]).toHaveProperty('id') expect(newEntries[0]).toHaveProperty('title', 'zsh') }) + + it('reaps delayed OS death without reissuing an accepted Windows close', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const kill = vi.fn() + const destroy = vi.fn(() => kill()) + const exits = vi.fn() + spawnMock.mockReturnValue({ ...mockProc, kill, destroy }) + isLocalPtyProcessProvablyExitedMock.mockReturnValueOnce(false).mockReturnValueOnce(true) + provider.onExit(exits) + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + + await provider.shutdown(id, { immediate: true }) + expect(provider.hasPty(id)).toBe(true) + await expect(provider.listProcesses()).resolves.not.toEqual( + expect.arrayContaining([expect.objectContaining({ id })]) + ) + + expect(provider.hasPty(id)).toBe(false) + expect(kill).toHaveBeenCalledOnce() + expect(destroy).not.toHaveBeenCalled() + expect(exits).toHaveBeenCalledWith({ id, code: -1 }) + }) }) describe('getDefaultShell', () => { diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 90ccae11ea6..fb29dbe3090 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -15,13 +15,19 @@ import { resolveProcessCwd } from './process-cwd' import { existsSync } from 'node:fs' import * as pty from 'node-pty' import { parseWslPath, isWslAvailable } from '../wsl' -import { splitWorktreeId } from '../../shared/worktree-id' +import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id' import { injectHistoryEnv, updateHistFileForFallback, logHistoryInjection } from '../terminal-history' -import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types' +import type { + IPtyProvider, + PtyProcessInfo, + PtyShutdownOptions, + PtySpawnOptions, + PtySpawnResult +} from './types' import { ensureNodePtySpawnHelperExecutable, validateWorkingDirectory, @@ -57,7 +63,13 @@ import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process import { readWindowsConptyProcessIds } from './windows-conpty-process-membership' import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from './pty-default-cwd' +import { isLocalPtyProcessProvablyExited } from './local-pty-process-exit-proof' import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query' +import { + assertPtyPaneIdentity, + getPtyPaneIdentityFromEnv, + type PtyPaneIdentity +} from '../pty/pty-shutdown-identity' const PANE_IDENTITY_ENV_KEYS = [ 'ORCA_PANE_KEY', @@ -71,6 +83,7 @@ const ptyProcesses = new Map() const ptyShellName = new Map() const ptyAgentForegroundContextPaths = new Map() const ptyTerminalHandle = new Map() +const ptyPaneIdentity = new Map() // Why: node-pty's onData/onExit register native NAPI ThreadSafeFunction // callbacks. If the PTY is killed without disposing these listeners, the // stale callbacks survive into node::FreeEnvironment() where NAPI attempts @@ -139,6 +152,10 @@ function disposePtyListeners(id: string): void { } } +const ptyShutdownInProgress = new Set() +const ptyExitDuringShutdown = new Map() +const ptyShutdownAcceptedAwaitingExit = new Set() + function runPtyCleanup(id: string): void { const cleanup = ptyCleanupCallbacks.get(id) if (!cleanup) { @@ -154,7 +171,11 @@ function runPtyCleanup(id: string): void { function getWslContextFromWorktreeId( worktreeId: string | undefined ): { distro: string; treatPosixCwdAsWsl: true } | undefined { - const worktreePath = worktreeId ? splitWorktreeId(worktreeId)?.worktreePath : undefined + // Why: strip any synthetic `::workspace:` folder-workspace suffix so WSL + // detection parses the real path, not a nonexistent identifier. + const worktreePath = worktreeId + ? splitWorktreeIdForFilesystem(worktreeId)?.worktreePath + : undefined const wslInfo = worktreePath ? parseWslPath(worktreePath) : null return wslInfo ? { distro: wslInfo.distro, treatPosixCwdAsWsl: true } : undefined } @@ -172,14 +193,22 @@ function getWslContextFromPreferredDistro( /** * Removes all local tracking state for a PTY id after teardown. */ -function clearPtyState(id: string): void { +function clearPtyState(id: string, expectedProc?: pty.IPty): boolean { + if (expectedProc && ptyProcesses.get(id) !== expectedProc) { + return false + } runPtyCleanup(id) disposePtyListeners(id) ptyProcesses.delete(id) ptyShellName.delete(id) ptyAgentForegroundContextPaths.delete(id) ptyTerminalHandle.delete(id) + ptyPaneIdentity.delete(id) ptyLoadGeneration.delete(id) + ptyShutdownInProgress.delete(id) + ptyExitDuringShutdown.delete(id) + ptyShutdownAcceptedAwaitingExit.delete(id) + return true } /** @@ -310,6 +339,7 @@ export type LocalPtyProviderOptions = { } export class LocalPtyProvider implements IPtyProvider { + readonly requiresShutdownExitProof = true private opts: LocalPtyProviderOptions constructor(opts: LocalPtyProviderOptions = {}) { @@ -685,6 +715,7 @@ export class LocalPtyProvider implements IPtyProvider { const proc = spawnResult.process ptyProcesses.set(id, proc) + ptyPaneIdentity.set(id, getPtyPaneIdentityFromEnv(finalEnv)) ptyShellName.set(id, getSpawnedShellName(shellPath)) if (finalEnv.ORCA_TERMINAL_HANDLE) { ptyTerminalHandle.set(id, finalEnv.ORCA_TERMINAL_HANDLE) @@ -791,12 +822,26 @@ export class LocalPtyProvider implements IPtyProvider { if (process.platform !== 'win32') { ;(proc as unknown as { kill: (sig?: string) => void }).kill = () => {} } + if (ptyProcesses.get(id) !== proc) { + // Why: native exit delivery can trail a same-id replacement. Clean + // only this old handle; id-keyed maps now belong to the new process. + startupCommandCleanup?.() + for (const disposable of disposables) { + disposable.dispose() + } + destroyPtyProcess(proc) + return + } + if (ptyShutdownInProgress.has(id)) { + ptyExitDuringShutdown.set(id, exitCode) + return + } if (shellReadyTimeout) { clearTimeout(shellReadyTimeout) shellReadyTimeout = null } startupCommandCleanup?.() - clearPtyState(id) + clearPtyState(id, proc) // Why: release the master ptmx fd on the natural-exit path — without // this, a shell that exits cleanly (the common case) never releases its // fd until the next GC. See docs/fix-pty-fd-leak.md. @@ -882,29 +927,75 @@ export class LocalPtyProvider implements IPtyProvider { return { cols: proc.cols, rows: proc.rows } } - async shutdown(id: string, _opts: { immediate?: boolean; keepHistory?: boolean }): Promise { + async shutdown(id: string, opts: PtyShutdownOptions): Promise { const proc = ptyProcesses.get(id) if (!proc) { return } - // Why: disposePtyListeners removes the onExit callback, so the natural - // exit cleanup path from node-pty won't fire. Cleanup and notification - // must happen unconditionally after the try/catch. - // Timer/writer cleanup must happen here too: disposing listeners prevents - // the natural onExit callback from running the usual clearPtyState path. - runPtyCleanup(id) - disposePtyListeners(id) + assertPtyPaneIdentity(id, ptyPaneIdentity.get(id) ?? { paneKey: null, tabId: null }, opts) + // Why: keep listeners live until native shutdown is accepted so a thrown + // kill retains retry ownership. The in-progress marker makes a synchronous + // onExit an independent death proof without racing duplicate cleanup. + ptyShutdownInProgress.add(id) + let exitCode = -1 + let exitObserved = false + let osExitProved = false + let nativeKillAccepted = false try { - proc.kill() - } catch { - /* Process may already be dead */ + // Why: a renderer or paired/mobile close is destructive. POSIX's + // default SIGHUP can be ignored; Windows node-pty requires no argument. + if (opts.immediate && process.platform !== 'win32') { + proc.kill('SIGKILL') + } else { + proc.kill() + } + nativeKillAccepted = true + } catch (error) { + if (!ptyExitDuringShutdown.has(id)) { + if (!isLocalPtyProcessProvablyExited(proc.pid)) { + throw error + } + osExitProved = true + } + } finally { + ptyShutdownInProgress.delete(id) + exitCode = ptyExitDuringShutdown.get(id) ?? -1 + exitObserved = ptyExitDuringShutdown.has(id) || osExitProved + ptyExitDuringShutdown.delete(id) } + if (!exitObserved) { + exitObserved = isLocalPtyProcessProvablyExited(proc.pid) + } + if (nativeKillAccepted && !exitObserved) { + ptyShutdownAcceptedAwaitingExit.add(id) + } + // Why: kill returning is request acceptance, not process-death proof. Keep + // the native entry and listeners until onExit (possibly async) confirms it. + runPtyCleanup(id) + if (exitObserved) { + disposePtyListeners(id) + destroyPtyProcess(proc, { alreadyKilled: nativeKillAccepted }) + clearPtyState(id, proc) + this.opts.onExit?.(id, exitCode) + for (const cb of exitListeners) { + cb({ id, code: exitCode }) + } + } + } + + private reapTrackedPtyIfProvablyExited(id: string, proc: pty.IPty): boolean { + if (ptyProcesses.get(id) !== proc || !isLocalPtyProcessProvablyExited(proc.pid)) { + return false + } + // Why: node-pty can omit onExit after an accepted close. Inventory is an + // authoritative retry probe, so ESRCH must perform the same exact cleanup. + clearPtyState(id, proc) destroyPtyProcess(proc, { alreadyKilled: true }) - clearPtyState(id) this.opts.onExit?.(id, -1) for (const cb of exitListeners) { cb({ id, code: -1 }) } + return true } async sendSignal(id: string, signal: string): Promise { @@ -1030,12 +1121,22 @@ export class LocalPtyProvider implements IPtyProvider { } async listProcesses(): Promise { - return Array.from(ptyProcesses.entries()).map(([id, proc]) => ({ - id, - cwd: '', - title: proc.process || ptyShellName.get(id) || 'shell', - ...(ptyTerminalHandle.get(id) ? { terminalHandle: ptyTerminalHandle.get(id) } : {}) - })) + const processes: PtyProcessInfo[] = [] + for (const [id, proc] of ptyProcesses) { + if ( + ptyShutdownAcceptedAwaitingExit.has(id) && + this.reapTrackedPtyIfProvablyExited(id, proc) + ) { + continue + } + processes.push({ + id, + cwd: '', + title: proc.process || ptyShellName.get(id) || 'shell', + ...(ptyTerminalHandle.get(id) ? { terminalHandle: ptyTerminalHandle.get(id) } : {}) + }) + } + return processes } async getDefaultShell(): Promise { @@ -1081,16 +1182,15 @@ export class LocalPtyProvider implements IPtyProvider { // ─── Local-only helpers (not part of IPtyProvider interface) ─────── - /** Kill orphaned PTYs from previous page loads. */ - killOrphanedPtys(currentGeneration: number): { id: string }[] { - const killed: { id: string }[] = [] - for (const [id, proc] of ptyProcesses) { + /** Enumerate PTYs owned by renderer generations older than the current page. */ + listOrphanedPtys(currentGeneration: number): { id: string }[] { + const orphaned: { id: string }[] = [] + for (const id of ptyProcesses.keys()) { if ((ptyLoadGeneration.get(id) ?? -1) < currentGeneration) { - safeKillAndClean(id, proc) - killed.push({ id }) + orphaned.push({ id }) } } - return killed + return orphaned } /** Advance the load generation counter (called on renderer reload). */ diff --git a/src/main/providers/local-pty-utils.test.ts b/src/main/providers/local-pty-utils.test.ts index 66fde349ded..0527922f4cc 100644 --- a/src/main/providers/local-pty-utils.test.ts +++ b/src/main/providers/local-pty-utils.test.ts @@ -33,7 +33,11 @@ vi.mock('./macos-tcc-login-shell', () => ({ wrapShellSpawnForMacosTccAttribution: wrapSpawnMock })) -import { spawnShellWithFallback, validateWorkingDirectory } from './local-pty-utils' +import { + resolveUnixShellPath, + spawnShellWithFallback, + validateWorkingDirectory +} from './local-pty-utils' const WSL_UNC_DIR = '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo' const NATIVE_DIR = 'C:\\Users\\jin\\repo' @@ -102,6 +106,48 @@ describe('validateWorkingDirectory', () => { }) }) +describe('resolveUnixShellPath', () => { + beforeEach(() => { + existsSyncMock.mockReset() + accessSyncMock.mockReset() + }) + + it('preserves non-absolute shells for execvp PATH and cwd resolution', () => { + expect(resolveUnixShellPath('bash')).toBe('bash') + expect(resolveUnixShellPath('./bin/custom-shell')).toBe('./bin/custom-shell') + expect(existsSyncMock).not.toHaveBeenCalled() + }) + + it('selects an executable fallback before node-pty can fork a missing shell', () => { + existsSyncMock.mockImplementation((path) => path === '/bin/sh') + accessSyncMock.mockReturnValue(undefined) + + expect(resolveUnixShellPath('/missing/zsh')).toBe('/bin/sh') + expect(existsSyncMock.mock.calls.map(([path]) => path)).toEqual([ + '/missing/zsh', + '/bin/zsh', + '/bin/bash', + '/bin/sh' + ]) + }) + + it('reports every attempted shell when no executable candidate exists', () => { + existsSyncMock.mockReturnValue(false) + + expect(() => resolveUnixShellPath('/missing/zsh')).toThrow( + 'No executable Unix shell found (tried: /missing/zsh, /bin/zsh, /bin/bash, /bin/sh)' + ) + }) + + it('dedupes a preferred shell that is already a fallback candidate', () => { + existsSyncMock.mockImplementation((path) => path === '/bin/sh') + accessSyncMock.mockReturnValue(undefined) + + expect(resolveUnixShellPath('/bin/sh')).toBe('/bin/sh') + expect(existsSyncMock.mock.calls.map(([path]) => path)).toEqual(['/bin/sh']) + }) +}) + describe('spawnShellWithFallback macOS TCC login wrapping', () => { let origPlatform: PropertyDescriptor | undefined diff --git a/src/main/providers/local-pty-utils.ts b/src/main/providers/local-pty-utils.ts index 53d016d312f..3e21095d535 100644 --- a/src/main/providers/local-pty-utils.ts +++ b/src/main/providers/local-pty-utils.ts @@ -1,4 +1,4 @@ -import { basename, join } from 'node:path' +import { basename, isAbsolute, join } from 'node:path' import { existsSync, accessSync, statSync, chmodSync, constants as fsConstants } from 'node:fs' import type * as pty from 'node-pty' import { isWslUncPath } from '../../shared/wsl-paths' @@ -7,6 +7,8 @@ import { wrapShellSpawnForMacosTccAttribution } from './macos-tcc-login-shell' let didEnsureSpawnHelperExecutable = false +const UNIX_SHELL_FALLBACKS = ['/bin/zsh', '/bin/bash', '/bin/sh'] as const + function toUnpackedAsarPath(candidate: string): string { return candidate .replace(/app\.asar([/\\])/, 'app.asar.unpacked$1') @@ -46,6 +48,25 @@ export function getShellValidationError(shellPath: string): string | null { return null } +/** + * Resolves an absolute Unix shell before node-pty forks. Bare commands and + * relative paths stay untouched so execvp can resolve them against PATH or cwd. + */ +export function resolveUnixShellPath(shellPath: string): string { + if (!isAbsolute(shellPath)) { + return shellPath + } + const candidates = [ + shellPath, + ...UNIX_SHELL_FALLBACKS.filter((candidate) => candidate !== shellPath) + ] + const resolved = candidates.find((candidate) => getShellValidationError(candidate) === null) + if (resolved) { + return resolved + } + throw new Error(`No executable Unix shell found (tried: ${candidates.join(', ')})`) +} + /** * Ensure the node-pty spawn-helper binary has the executable bit set. * @@ -252,7 +273,7 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu // Try fallback shells on Unix if (process.platform !== 'win32') { - const fallbackShells = ['/bin/zsh', '/bin/bash', '/bin/sh'].filter((s) => s !== shellPath) + const fallbackShells = UNIX_SHELL_FALLBACKS.filter((candidate) => candidate !== shellPath) for (const fallback of fallbackShells) { if (getShellValidationError(fallback)) { continue diff --git a/src/main/providers/ssh-filesystem-file-upload.ts b/src/main/providers/ssh-filesystem-file-upload.ts new file mode 100644 index 00000000000..6fa83145d69 --- /dev/null +++ b/src/main/providers/ssh-filesystem-file-upload.ts @@ -0,0 +1,35 @@ +import type { SFTPWrapper } from 'ssh2' +import { uploadFile as uploadFileViaSftp } from '../ssh/sftp-upload' +import type { FileUploadSession } from './types' + +export type SftpFactory = () => Promise + +export type SshRawTransferOptions = { + downloadFile?: (sourcePath: string, destinationPath: string) => Promise + openFileUploadSession?: () => Promise + writeBuffer?: ( + remotePath: string, + contents: Buffer, + options: { append: boolean; exclusive: boolean } + ) => Promise +} + +export async function openSshFileUploadSession( + createSftp?: SftpFactory, + rawTransfer?: SshRawTransferOptions +): Promise { + if (rawTransfer?.openFileUploadSession) { + return rawTransfer.openFileUploadSession() + } + if (!createSftp) { + throw new Error('Remote file upload is unavailable. Reconnect the SSH target and retry.') + } + const sftp = await createSftp() + return { + // Why: one session covers the whole import so normal SSH keeps its prior + // channel count even when a directory contains many files. + uploadFile: (sourcePath, destinationPath, options) => + uploadFileViaSftp(sftp, sourcePath, destinationPath, options), + close: () => sftp.end() + } +} diff --git a/src/main/providers/ssh-filesystem-provider-watch.ts b/src/main/providers/ssh-filesystem-provider-watch.ts index 7beea078ac3..02b87f3d005 100644 --- a/src/main/providers/ssh-filesystem-provider-watch.ts +++ b/src/main/providers/ssh-filesystem-provider-watch.ts @@ -4,6 +4,51 @@ import type { FsChangeEvent } from '../../shared/types' export type WatchRegistration = { callbacks: Set<(events: FsChangeEvent[]) => void> setupPromise: Promise + setupAbortController: AbortController + ready: boolean + stopping: boolean + unwatchSent: boolean +} + +function createWatchAbortError(): Error { + const error = new Error('Request "fs.watch" was cancelled') as Error & { name: string } + error.name = 'AbortError' + return error +} + +async function awaitSetupWithOptionalAbort( + setupPromise: Promise, + signal?: AbortSignal +): Promise { + if (!signal) { + await setupPromise + return + } + if (signal.aborted) { + throw createWatchAbortError() + } + await new Promise((resolve, reject) => { + const onAbort = (): void => { + cleanup() + reject(createWatchAbortError()) + } + const onSettled = (error?: unknown): void => { + cleanup() + if (error === undefined) { + resolve() + } else { + reject(error) + } + } + const cleanup = (): void => { + signal.removeEventListener('abort', onAbort) + } + signal.addEventListener('abort', onAbort, { once: true }) + setupPromise.then( + () => onSettled(), + (error) => onSettled(error) + ) + }) } export async function registerSshFilesystemWatch(args: { @@ -12,37 +57,79 @@ export async function registerSshFilesystemWatch(args: { registrations: Map rootPath: string callback: (events: FsChangeEvent[]) => void + signal?: AbortSignal }): Promise<() => void> { if (args.disposed()) { throw new Error('SSH filesystem provider disposed') } + if (args.signal?.aborted) { + throw createWatchAbortError() + } + let registration = args.registrations.get(args.rootPath) if (registration) { + if (registration.stopping) { + throw createWatchAbortError() + } registration.callbacks.add(args.callback) - await registration.setupPromise - assertActiveWatch(args, registration) - return createSshFilesystemWatchUnsubscribe(args, registration) + try { + // Why: each caller may leave a shared setup independently; registration + // ownership decides whether the physical relay request should stop. + await awaitSetupWithOptionalAbort(registration.setupPromise, args.signal) + assertActiveWatch(args, registration) + return createSshFilesystemWatchUnsubscribe(args, registration) + } catch (error) { + releaseSshFilesystemWatchCallback(args, registration) + throw error + } } const callbacks = new Set<(events: FsChangeEvent[]) => void>([args.callback]) - const setupPromise = args.mux.request('fs.watch', { rootPath: args.rootPath }).then( - () => undefined, - (error) => { - if (args.registrations.get(args.rootPath) === registration) { - args.registrations.delete(args.rootPath) + const setupAbortController = new AbortController() + registration = { + callbacks, + setupAbortController, + ready: false, + stopping: false, + unwatchSent: false, + setupPromise: Promise.resolve() + } + const createdRegistration = registration + // Why: the shared registration, not its first caller, owns relay setup. + // This keeps a same-root joiner alive when the original caller disconnects. + registration.setupPromise = args.mux + .request('fs.watch', { rootPath: args.rootPath }, { signal: setupAbortController.signal }) + .then( + () => { + createdRegistration.ready = true + if ( + createdRegistration.stopping || + createdRegistration.callbacks.size === 0 || + args.disposed() || + args.registrations.get(args.rootPath) !== createdRegistration + ) { + if (args.registrations.get(args.rootPath) === createdRegistration) { + args.registrations.delete(args.rootPath) + } + sendSshFilesystemUnwatchOnce(args.mux, args.rootPath, createdRegistration) + } + }, + (error) => { + if (args.registrations.get(args.rootPath) === createdRegistration) { + args.registrations.delete(args.rootPath) + } + throw error } - throw error - } - ) - registration = { callbacks, setupPromise } + ) args.registrations.set(args.rootPath, registration) - await setupPromise - if (args.disposed() || args.registrations.get(args.rootPath) !== registration) { - notifySshFilesystemUnwatch(args.mux, args.rootPath) - throw new Error('SSH filesystem provider disposed') + try { + await awaitSetupWithOptionalAbort(registration.setupPromise, args.signal) + assertActiveWatch(args, registration) + return createSshFilesystemWatchUnsubscribe(args, registration) + } catch (error) { + releaseSshFilesystemWatchCallback(args, registration) + throw error } - - return createSshFilesystemWatchUnsubscribe(args, registration) } export function notifySshFilesystemUnwatch(mux: SshChannelMultiplexer, rootPath: string): void { @@ -74,13 +161,52 @@ function createSshFilesystemWatchUnsubscribe( registration: WatchRegistration ): () => void { return () => { - registration.callbacks.delete(args.callback) - if ( - registration.callbacks.size === 0 && - args.registrations.get(args.rootPath) === registration - ) { - args.registrations.delete(args.rootPath) - notifySshFilesystemUnwatch(args.mux, args.rootPath) - } + releaseSshFilesystemWatchCallback(args, registration) } } + +function releaseSshFilesystemWatchCallback( + args: { + mux: SshChannelMultiplexer + registrations: Map + rootPath: string + callback: (events: FsChangeEvent[]) => void + }, + registration: WatchRegistration +): void { + registration.callbacks.delete(args.callback) + if (registration.callbacks.size > 0 || args.registrations.get(args.rootPath) !== registration) { + return + } + registration.stopping = true + if (registration.ready) { + args.registrations.delete(args.rootPath) + sendSshFilesystemUnwatchOnce(args.mux, args.rootPath, registration) + } else { + // Keep the cancelling registration indexed until its request settles so a + // new same-root setup cannot race an old late success and be unwatched. + registration.setupAbortController.abort() + } +} + +export function stopSshFilesystemWatchRegistration( + mux: SshChannelMultiplexer, + rootPath: string, + registration: WatchRegistration +): void { + registration.stopping = true + registration.setupAbortController.abort() + sendSshFilesystemUnwatchOnce(mux, rootPath, registration) +} + +function sendSshFilesystemUnwatchOnce( + mux: SshChannelMultiplexer, + rootPath: string, + registration: WatchRegistration +): void { + if (registration.unwatchSent) { + return + } + registration.unwatchSent = true + notifySshFilesystemUnwatch(mux, rootPath) +} diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts index d35460bd489..d583a93d7cc 100644 --- a/src/main/providers/ssh-filesystem-provider.test.ts +++ b/src/main/providers/ssh-filesystem-provider.test.ts @@ -1,4 +1,8 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PassThrough } from 'node:stream' import { SshFilesystemProvider } from './ssh-filesystem-provider' import { JsonRpcErrorCode } from '../ssh/relay-protocol' @@ -201,6 +205,23 @@ describe('SshFilesystemProvider', () => { expect(mux.request).not.toHaveBeenCalledWith('fs.writeFile', expect.anything()) }) + it('writes decoded bytes through raw transfer when provided', async () => { + const writeBuffer = vi.fn().mockResolvedValue(undefined) + provider = new SshFilesystemProvider('conn-1', mux as never, undefined, { writeBuffer }) + + await provider.writeFileBase64('/home/user/logo.png', 'cG5n') + await provider.writeFileBase64Chunk('/home/user/logo.png', 'bW9yZQ==', true) + + expect(writeBuffer).toHaveBeenNthCalledWith(1, '/home/user/logo.png', Buffer.from('png'), { + append: false, + exclusive: true + }) + expect(writeBuffer).toHaveBeenNthCalledWith(2, '/home/user/logo.png', Buffer.from('more'), { + append: true, + exclusive: false + }) + }) + it('can append decoded chunks through SFTP', async () => { const writeStream = { on: vi.fn((_event: string, _handler: (...args: unknown[]) => void) => writeStream), @@ -225,6 +246,15 @@ describe('SshFilesystemProvider', () => { }) describe('downloadFile', () => { + it('downloads raw bytes through raw transfer when provided', async () => { + const downloadFile = vi.fn().mockResolvedValue(undefined) + provider = new SshFilesystemProvider('conn-1', mux as never, undefined, { downloadFile }) + + await provider.downloadFile('/home/user/archive.zip', '/tmp/archive.zip') + + expect(downloadFile).toHaveBeenCalledWith('/home/user/archive.zip', '/tmp/archive.zip') + }) + it('downloads raw bytes through SFTP and closes the session', async () => { const sftp = { fastGet: vi.fn( @@ -263,6 +293,48 @@ describe('SshFilesystemProvider', () => { }) }) + describe('openFileUploadSession', () => { + it('uses one SFTP session for multiple files and closes it once', async () => { + const createWriteStream = vi.fn(() => { + const stream = new PassThrough() + stream.resume() + return stream + }) + const sftp = { createWriteStream, end: vi.fn() } + const createSftp = vi.fn().mockResolvedValue(sftp) + provider = new SshFilesystemProvider('conn-1', mux as never, createSftp as never) + const dir = mkdtempSync(join(tmpdir(), 'orca-sftp-upload-session-')) + const first = join(dir, 'first.txt') + const second = join(dir, 'second.txt') + writeFileSync(first, 'first') + writeFileSync(second, 'second') + + try { + const session = await provider.openFileUploadSession() + await session.uploadFile(first, '/remote/first.txt', { exclusive: true }) + await session.uploadFile(second, '/remote/second.txt', { exclusive: true }) + session.close() + + expect(createSftp).toHaveBeenCalledOnce() + expect(createWriteStream).toHaveBeenCalledTimes(2) + expect(sftp.end).toHaveBeenCalledOnce() + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('uses the transport-owned upload session when provided', async () => { + const uploadSession = { uploadFile: vi.fn(), close: vi.fn() } + const openFileUploadSession = vi.fn().mockResolvedValue(uploadSession) + provider = new SshFilesystemProvider('conn-1', mux as never, undefined, { + openFileUploadSession + }) + + await expect(provider.openFileUploadSession()).resolves.toBe(uploadSession) + expect(openFileUploadSession).toHaveBeenCalledOnce() + }) + }) + describe('createDirNoClobber', () => { it('sends fs.createDirNoClobber request', async () => { await provider.createDirNoClobber('/home/user/new-dir') @@ -466,10 +538,99 @@ describe('SshFilesystemProvider', () => { const callback = vi.fn() const unsub = await provider.watch('/home/user/project', callback) - expect(mux.request).toHaveBeenCalledWith('fs.watch', { rootPath: '/home/user/project' }) + expect(mux.request).toHaveBeenCalledWith( + 'fs.watch', + { rootPath: '/home/user/project' }, + { signal: expect.any(AbortSignal) } + ) expect(typeof unsub).toBe('function') }) + it('uses a registration-owned cancellation signal for the mux fs.watch request', async () => { + mux.request.mockResolvedValue(undefined) + const controller = new AbortController() + const callback = vi.fn() + + await provider.watch('/home/user/project', callback, { signal: controller.signal }) + + expect(mux.request).toHaveBeenCalledWith( + 'fs.watch', + { rootPath: '/home/user/project' }, + { signal: expect.any(AbortSignal) } + ) + const physicalSignal = mux.request.mock.calls[0][2]?.signal + expect(physicalSignal).not.toBe(controller.signal) + expect(physicalSignal?.aborted).toBe(false) + }) + + it('rejects promptly when the watch signal aborts during setup', async () => { + let resolveWatch: () => void = () => {} + mux.request.mockImplementationOnce( + (_method, _params, options?: { signal?: AbortSignal }) => + new Promise((resolve, reject) => { + resolveWatch = resolve + options?.signal?.addEventListener( + 'abort', + () => { + const error = new Error('Request "fs.watch" was cancelled') as Error & { + name: string + } + error.name = 'AbortError' + reject(error) + }, + { once: true } + ) + }) + ) + const controller = new AbortController() + const pendingWatch = provider.watch('/home/user/project', vi.fn(), { + signal: controller.signal + }) + + controller.abort() + + await expect(pendingWatch).rejects.toMatchObject({ name: 'AbortError' }) + expect(mux.request.mock.calls[0][2]?.signal?.aborted).toBe(true) + resolveWatch() + }) + + it('keeps shared setup alive when only its first caller aborts', async () => { + let resolveWatch: () => void = () => {} + let physicalSignal: AbortSignal | undefined + mux.request.mockImplementationOnce( + (_method, _params, options?: { signal?: AbortSignal }) => + new Promise((resolve) => { + resolveWatch = resolve + physicalSignal = options?.signal + }) + ) + const firstController = new AbortController() + const first = vi.fn() + const second = vi.fn() + + const firstWatch = provider.watch('/home/user/project', first, { + signal: firstController.signal + }) + const secondWatch = provider.watch('/home/user/project', second) + firstController.abort() + + await expect(firstWatch).rejects.toMatchObject({ name: 'AbortError' }) + expect(physicalSignal?.aborted).toBe(false) + + resolveWatch() + const unsubscribeSecond = await secondWatch + const notifHandler = mux.onNotification.mock.calls[0][0] + const events = [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }] + notifHandler('fs.changed', { events }) + + expect(first).not.toHaveBeenCalled() + expect(second).toHaveBeenCalledWith(events) + unsubscribeSecond() + expect(mux.notify).toHaveBeenCalledWith('fs.unwatch', { + rootPath: '/home/user/project' + }) + }) + it('forwards fs.changed notifications to watch callback', async () => { const callback = vi.fn() await provider.watch('/home/user/project', callback) @@ -488,7 +649,11 @@ describe('SshFilesystemProvider', () => { const unsubSecond = await provider.watch('/home/user/project', second) expect(mux.request).toHaveBeenCalledTimes(1) - expect(mux.request).toHaveBeenCalledWith('fs.watch', { rootPath: '/home/user/project' }) + expect(mux.request).toHaveBeenCalledWith( + 'fs.watch', + { rootPath: '/home/user/project' }, + { signal: expect.any(AbortSignal) } + ) const notifHandler = mux.onNotification.mock.calls[0][0] const events = [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }] diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts index ce5256d2744..ed4520e8f5a 100644 --- a/src/main/providers/ssh-filesystem-provider.ts +++ b/src/main/providers/ssh-filesystem-provider.ts @@ -3,22 +3,25 @@ import { isMethodNotFoundError, readFileViaStream } from '../ssh/ssh-filesystem- import { uploadBuffer } from '../ssh/sftp-upload' import { fastGetViaSftp, lstatViaSftp } from './ssh-filesystem-provider-sftp' import { - notifySshFilesystemUnwatch, + openSshFileUploadSession, + type SftpFactory, + type SshRawTransferOptions +} from './ssh-filesystem-file-upload' +import { registerSshFilesystemWatch, + stopSshFilesystemWatchRegistration, type WatchRegistration } from './ssh-filesystem-provider-watch' import type { IFilesystemProvider, FileStat, FileReadResult, + FileUploadSession, TerminalArtifactAccessOptions } from './types' import type { DirEntry, FsChangeEvent, SearchOptions, SearchResult } from '../../shared/types' import { isPathInsideOrEqual } from '../../shared/cross-platform-path' import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types' -import type { SFTPWrapper } from 'ssh2' - -type SftpFactory = () => Promise const WORKSPACE_SPACE_SCAN_TIMEOUT_MS = 130_000 export class SshFilesystemProvider implements IFilesystemProvider { @@ -33,7 +36,8 @@ export class SshFilesystemProvider implements IFilesystemProvider { constructor( connectionId: string, mux: SshChannelMultiplexer, - private readonly createSftp?: SftpFactory + private readonly createSftp?: SftpFactory, + private readonly rawTransfer?: SshRawTransferOptions ) { this.connectionId = connectionId this.mux = mux @@ -62,8 +66,8 @@ export class SshFilesystemProvider implements IFilesystemProvider { this.unsubscribeNotifications() this.unsubscribeNotifications = null } - for (const rootPath of this.watchListeners.keys()) { - notifySshFilesystemUnwatch(this.mux, rootPath) + for (const [rootPath, registration] of this.watchListeners) { + stopSshFilesystemWatchRegistration(this.mux, rootPath, registration) } this.watchListeners.clear() } @@ -120,6 +124,10 @@ export class SshFilesystemProvider implements IFilesystemProvider { } async downloadFile(sourcePath: string, destinationPath: string): Promise { + if (this.rawTransfer?.downloadFile) { + await this.rawTransfer.downloadFile(sourcePath, destinationPath) + return + } if (!this.createSftp) { throw new Error('Remote file download is unavailable. Reconnect the SSH target and retry.') } @@ -131,6 +139,10 @@ export class SshFilesystemProvider implements IFilesystemProvider { } } + async openFileUploadSession(): Promise { + return openSshFileUploadSession(this.createSftp, this.rawTransfer) + } + async getTempDir(): Promise { this.tempDirPromise ??= this.mux.request('fs.tempDir', {}).then( (result) => result as string, @@ -186,6 +198,11 @@ export class SshFilesystemProvider implements IFilesystemProvider { contentBase64: string, append: boolean ): Promise { + const contents = Buffer.from(contentBase64, 'base64') + if (this.rawTransfer?.writeBuffer) { + await this.rawTransfer.writeBuffer(filePath, contents, { append, exclusive: !append }) + return + } if (!this.createSftp) { throw new Error('remote_binary_upload_unavailable') } @@ -193,7 +210,7 @@ export class SshFilesystemProvider implements IFilesystemProvider { try { // Why: relay fs.writeFile is text-only. SFTP writes the decoded bytes // directly so runtime uploads do not corrupt images, PDFs, or archives. - await uploadBuffer(sftp, Buffer.from(contentBase64, 'base64'), filePath, { + await uploadBuffer(sftp, contents, filePath, { append, exclusive: !append }) @@ -299,13 +316,18 @@ export class SshFilesystemProvider implements IFilesystemProvider { })) as string[] } - async watch(rootPath: string, callback: (events: FsChangeEvent[]) => void): Promise<() => void> { + async watch( + rootPath: string, + callback: (events: FsChangeEvent[]) => void, + options?: { signal?: AbortSignal } + ): Promise<() => void> { return registerSshFilesystemWatch({ mux: this.mux, disposed: () => this.disposed, registrations: this.watchListeners, rootPath, - callback + callback, + signal: options?.signal }) } } diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index ca0a9fd8261..7fe38858129 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -49,6 +49,15 @@ describe('SshPtyProvider', () => { expect(result).toEqual({ id: scopedPty1 }) }) + it('includes relay boot identity so recycled counters cannot collide', async () => { + provider = new SshPtyProvider('conn-1', mux as never, undefined, 'relay-generation-2') + mux.request.mockResolvedValue({ id: 'pty-1' }) + + const result = await provider.spawn({ cols: 80, rows: 24 }) + + expect(result.id).toBe('ssh:conn-1@@relay-generation-2@@pty-1') + }) + it('passes cwd and env through', async () => { mux.request.mockResolvedValue({ id: 'pty-2' }) @@ -434,6 +443,21 @@ describe('SshPtyProvider', () => { }) }) + it('shutdown forwards pane identity to reject recycled relay ids', async () => { + await provider.shutdown(scopedPty1, { + immediate: true, + expectedPaneKey: 'tab-a:leaf-a', + expectedTabId: 'tab-a' + }) + expect(mux.request).toHaveBeenCalledWith('pty.shutdown', { + id: 'pty-1', + immediate: true, + keepHistory: false, + expectedPaneKey: 'tab-a:leaf-a', + expectedTabId: 'tab-a' + }) + }) + it('sendSignal sends pty.sendSignal request', async () => { await provider.sendSignal(scopedPty1, 'SIGINT') expect(mux.request).toHaveBeenCalledWith('pty.sendSignal', { id: 'pty-1', signal: 'SIGINT' }) @@ -485,6 +509,15 @@ describe('SshPtyProvider', () => { ) }) + it('rejects a recycled id from an older relay generation before shutdown', async () => { + provider = new SshPtyProvider('conn-1', mux as never, undefined, 'relay-new') + + await expect( + provider.shutdown('ssh:conn-1@@relay-old@@pty-1', { immediate: true }) + ).rejects.toThrow('relay generation mismatch') + expect(mux.request).not.toHaveBeenCalled() + }) + it('listProcesses returns process list', async () => { const processes = [{ id: 'pty-1', cwd: '/home', title: 'zsh' }] mux.request.mockResolvedValue(processes) diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index e386e89dbba..0b2b3a6e4d3 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -1,6 +1,12 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types' -import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id' +import type { + IPtyProvider, + PtyProcessInfo, + PtyShutdownOptions, + PtySpawnOptions, + PtySpawnResult +} from './types' +import { parseAppSshPtyId, toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id' import { seedPowerlevel10kWizardEnv } from '../pty/powerlevel10k-wizard-env' type DataCallback = (payload: { id: string; data: string }) => void @@ -33,6 +39,7 @@ export function isSshPtyIdentityMismatchError(err: unknown): boolean { * as LocalPtyProvider so the dispatch layer can route transparently. */ export class SshPtyProvider implements IPtyProvider { + readonly requiresShutdownExitProof = true private mux: SshChannelMultiplexer private connectionId: string private dataListeners = new Set() @@ -46,7 +53,8 @@ export class SshPtyProvider implements IPtyProvider { constructor( connectionId: string, mux: SshChannelMultiplexer, - private readonly remoteCliBridgeEnv?: RemoteCliBridgeEnv + private readonly remoteCliBridgeEnv?: RemoteCliBridgeEnv, + private readonly relayInstanceId?: string ) { this.connectionId = connectionId this.mux = mux @@ -89,12 +97,26 @@ export class SshPtyProvider implements IPtyProvider { return this.connectionId } + getRelayInstanceId(): string | undefined { + return this.relayInstanceId + } + private toRelayPtyId(id: string): string { + const parsed = parseAppSshPtyId(id) + if ( + parsed?.relayInstanceId && + this.relayInstanceId && + parsed.relayInstanceId !== this.relayInstanceId + ) { + throw new Error( + `${SSH_PTY_IDENTITY_MISMATCH_ERROR}: PTY "${id}" not found (relay generation mismatch)` + ) + } return toRelaySshPtyId(this.connectionId, id) } private toAppPtyId(id: string): string { - return toAppSshPtyId(this.connectionId, id) + return toAppSshPtyId(this.connectionId, id, this.relayInstanceId) } async spawn(opts: PtySpawnOptions): Promise { @@ -234,11 +256,13 @@ export class SshPtyProvider implements IPtyProvider { this.mux.notify('pty.resize', { id: this.toRelayPtyId(id), cols, rows }) } - async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise { + async shutdown(id: string, opts: PtyShutdownOptions): Promise { await this.mux.request('pty.shutdown', { id: this.toRelayPtyId(id), immediate: opts.immediate ?? false, - keepHistory: opts.keepHistory ?? false + keepHistory: opts.keepHistory ?? false, + ...(opts.expectedPaneKey ? { expectedPaneKey: opts.expectedPaneKey } : {}), + ...(opts.expectedTabId ? { expectedTabId: opts.expectedTabId } : {}) }) } diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index d5873c948ea..10565e422dd 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -144,6 +144,14 @@ export type PtySpawnResult = { } } +export type PtyShutdownOptions = { + immediate?: boolean + keepHistory?: boolean + /** Relay generations can recycle pty-N; identity prevents stale shutdown. */ + expectedPaneKey?: string + expectedTabId?: string +} + export type PtyProcessInfo = { id: string cwd: string @@ -153,6 +161,9 @@ export type PtyProcessInfo = { } export type IPtyProvider = { + /** A returned kill request is only acceptance; retain ownership until an + * exit event or listProcesses readback proves the backing process is gone. */ + readonly requiresShutdownExitProof?: boolean spawn(opts: PtySpawnOptions): Promise attach(id: string): Promise hasPty?: (id: string) => boolean @@ -201,7 +212,7 @@ export type IPtyProvider = { */ getAppliedSize?: (id: string) => Promise<{ cols: number; rows: number } | null> - shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise + shutdown(id: string, opts: PtyShutdownOptions): Promise sendSignal(id: string, signal: string): Promise getCwd(id: string): Promise getInitialCwd(id: string): Promise @@ -250,6 +261,7 @@ export type IFilesystemProvider = { options: TerminalArtifactAccessOptions ): Promise downloadFile?(sourcePath: string, destinationPath: string): Promise + openFileUploadSession?(): Promise getTempDir?(): Promise writeFile(filePath: string, content: string): Promise writeTerminalArtifact?( @@ -278,7 +290,20 @@ export type IFilesystemProvider = { rootPath: string, options?: { signal?: AbortSignal } ): Promise - watch(rootPath: string, callback: (events: FsChangeEvent[]) => void): Promise<() => void> + watch( + rootPath: string, + callback: (events: FsChangeEvent[]) => void, + options?: { signal?: AbortSignal } + ): Promise<() => void> +} + +export type FileUploadSession = { + uploadFile( + sourcePath: string, + destinationPath: string, + options?: { exclusive?: boolean } + ): Promise + close(): void } export type TerminalArtifactAccessOptions = { diff --git a/src/main/providers/windows-foreground-process-rows.test.ts b/src/main/providers/windows-foreground-process-rows.test.ts new file mode 100644 index 00000000000..61482224e50 --- /dev/null +++ b/src/main/providers/windows-foreground-process-rows.test.ts @@ -0,0 +1,99 @@ +// Regression guard: the Windows agent foreground-process scan re-forks +// powershell.exe (or the wmic fallback) on a ~1s/pane cadence. Electron's main +// process has no console, so a spawn without windowsHide pops a fresh conhost +// window per scan that flashes and steals keyboard focus from the foreground app +// (including Orca's own terminal). Both probes MUST pass windowsHide: true. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() })) + +vi.mock('child_process', () => ({ execFile: execFileMock })) + +import { + queryWindowsProcessDescendants, + resetWindowsProcessRowsSnapshotForTests +} from './windows-foreground-process-rows' + +type ExecFileCallback = (err: unknown, result: { stdout: string; stderr: string }) => void +type ExecFileCall = [string, string[], Record, ExecFileCallback] + +const POWERSHELL_ROWS_JSON = JSON.stringify([ + { + ProcessId: 100, + ParentProcessId: 50, + Name: 'powershell.exe', + CommandLine: 'powershell.exe', + ExecutablePath: 'C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe' + }, + { + ProcessId: 200, + ParentProcessId: 100, + Name: 'node.exe', + CommandLine: 'node C:/Users/dev/AppData/codex/bin/codex.js', + ExecutablePath: 'C:/Program Files/nodejs/node.exe' + } +]) + +const WMIC_ROWS_VALUE = + 'CommandLine=powershell.exe\n' + + 'ExecutablePath=C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe\n' + + 'Name=powershell.exe\n' + + 'ParentProcessId=50\n' + + 'ProcessId=100\n\n' + + 'CommandLine=node C:/Users/dev/AppData/codex/bin/codex.js\n' + + 'ExecutablePath=C:/Program Files/nodejs/node.exe\n' + + 'Name=node.exe\n' + + 'ParentProcessId=100\n' + + 'ProcessId=200\n' + +/** Returns the options object passed to the mocked execFile for a given command. */ +function optionsForCommand(command: string): Record | undefined { + const call = execFileMock.mock.calls.find((args) => (args as ExecFileCall)[0] === command) as + | ExecFileCall + | undefined + return call?.[2] +} + +describe('windows foreground process rows spawn options', () => { + let platform: PropertyDescriptor | undefined + + beforeEach(() => { + execFileMock.mockReset() + resetWindowsProcessRowsSnapshotForTests() + platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + }) + + afterEach(() => { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + }) + + it('hides the console window for the powershell process-table scan', async () => { + execFileMock.mockImplementation((_cmd: string, _args, _opts, cb: ExecFileCallback) => { + cb(null, { stdout: POWERSHELL_ROWS_JSON, stderr: '' }) + }) + + const candidates = await queryWindowsProcessDescendants(100) + + expect(candidates?.[0]?.pid).toBe(200) + expect(optionsForCommand('powershell.exe')).toMatchObject({ windowsHide: true }) + }) + + it('hides the console window for the wmic fallback scan', async () => { + execFileMock.mockImplementation((cmd: string, _args, _opts, cb: ExecFileCallback) => { + // Force the powershell probe to miss so the wmic fallback runs. + if (cmd === 'powershell.exe') { + cb(new Error('powershell unavailable'), { stdout: '', stderr: '' }) + return + } + cb(null, { stdout: WMIC_ROWS_VALUE, stderr: '' }) + }) + + const candidates = await queryWindowsProcessDescendants(100) + + expect(candidates?.[0]?.pid).toBe(200) + expect(optionsForCommand('wmic')).toMatchObject({ windowsHide: true }) + }) +}) diff --git a/src/main/providers/windows-foreground-process-rows.ts b/src/main/providers/windows-foreground-process-rows.ts index 6fea54165c8..97aea1dccc1 100644 --- a/src/main/providers/windows-foreground-process-rows.ts +++ b/src/main/providers/windows-foreground-process-rows.ts @@ -209,6 +209,7 @@ function collectDescendants( return descendants } +/** Runs the PowerShell/CIM whole-process-table scan; returns null when unavailable. */ async function queryWindowsProcessesWithPowerShell(): Promise { try { const { stdout } = await execFileAsync( @@ -217,7 +218,12 @@ async function queryWindowsProcessesWithPowerShell(): Promise { try { const { stdout } = await execFileAsync( @@ -240,7 +247,10 @@ async function queryWindowsProcessesWithWmic(): Promise + +export function getPtyPaneIdentityFromEnv( + env: Record | undefined +): PtyPaneIdentity { + return { + paneKey: env?.ORCA_PANE_KEY?.trim() || null, + tabId: env?.ORCA_TAB_ID?.trim() || null + } +} + +export function assertPtyPaneIdentity( + id: string, + actual: PtyPaneIdentity, + expected: PtyExpectedPaneIdentityFields +): void { + if ( + (expected.expectedPaneKey !== undefined && actual.paneKey !== expected.expectedPaneKey) || + (expected.expectedTabId !== undefined && actual.tabId !== expected.expectedTabId) + ) { + throw new Error(`PTY identity mismatch for "${id}"`) + } +} diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index 550295460ad..fe0d94ab9cb 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -24,6 +24,7 @@ describe('addOrcaWslInteropEnv', () => { const env: Record = { ORCA_TERMINAL_HANDLE: 'term_wsl', ORCA_USER_DATA_PATH: 'C:\\Users\\jin\\AppData\\Roaming\\Orca', + ORCA_CLI_COMMAND: 'orca-ide', ORCA_OMP_STATUS_EXTENSION: 'C:\\Users\\jin\\.omp\\agent\\extensions\\orca-agent-status.ts', ORCA_PANE_KEY: 'tab-1:leaf-1', ORCA_TAB_ID: 'tab-1', @@ -38,6 +39,7 @@ describe('addOrcaWslInteropEnv', () => { expect(env.WSLENV).toContain('ORCA_TERMINAL_HANDLE/u') expect(env.WSLENV).toContain('ORCA_USER_DATA_PATH/p') + expect(env.WSLENV).toContain('ORCA_CLI_COMMAND/u') expect(env.WSLENV).toContain('ORCA_OMP_STATUS_EXTENSION/p') expect(env.WSLENV).toContain('ORCA_PANE_KEY/u') expect(env.WSLENV).toContain('ORCA_TAB_ID/u') diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts index 161621e8c0f..3f136e0a284 100644 --- a/src/main/pty/wsl-orca-env.ts +++ b/src/main/pty/wsl-orca-env.ts @@ -24,6 +24,7 @@ export function addOrcaWslInteropEnv(env: Record): void { const passthroughEntries = [ 'ORCA_TERMINAL_HANDLE/u', 'ORCA_USER_DATA_PATH/p', + 'ORCA_CLI_COMMAND/u', 'ORCA_PANE_KEY/u', 'ORCA_TAB_ID/u', 'ORCA_WORKTREE_ID/u', diff --git a/src/main/rate-limits/claude-fetcher.test.ts b/src/main/rate-limits/claude-fetcher.test.ts index 6571177a928..da85dc69100 100644 --- a/src/main/rate-limits/claude-fetcher.test.ts +++ b/src/main/rate-limits/claude-fetcher.test.ts @@ -214,6 +214,87 @@ describe('fetchClaudeRateLimits', () => { }) }) + it('maps active Fable usage from the scoped OAuth limits array without a PTY read', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + provenance: 'managed:account-1' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ claudeAiOauth: { accessToken: 'oauth-token' } }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + five_hour: { utilization: 36 }, + seven_day: { utilization: 73 }, + fable_weekly: { utilization: 12 }, + limits: [ + { kind: 'weekly_scoped', percent: 55, scope: null }, + { + kind: 'weekly_scoped', + percent: 100, + resets_at: '2026-07-17T20:00:00.099908+00:00', + is_active: true, + scope: { model: { display_name: 'Fable' } } + } + ] + }), + { status: 200 } + ) + ) + + await expect( + fetchClaudeRateLimits({ authPreparation, allowUsagePanelSupplement: true }) + ).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + fableWeekly: { + usedPercent: 100, + resetsAt: Date.parse('2026-07-17T20:00:00.099908+00:00') + }, + usageMetadata: { attemptedSources: ['oauth'] } + }) + expect(fetchViaPty).not.toHaveBeenCalled() + }) + + it('ignores inactive scoped Fable usage and retains the legacy OAuth fallback', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ claudeAiOauth: { accessToken: 'oauth-token' } }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + five_hour: { utilization: 11 }, + seven_day: { utilization: 22 }, + fable_weekly: { utilization: 33 }, + limits: [ + { + kind: 'weekly_scoped', + percent: 90, + is_active: false, + scope: { model: { display_name: 'fable' } } + } + ] + }), + { status: 200 } + ) + ) + + await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ + fableWeekly: { usedPercent: 33 } + }) + }) + it('supplements managed-account OAuth usage with Fable from the CLI usage panel', async () => { const configDir = '/Users/test/.claude' const authPreparation: ClaudeRuntimeAuthPreparation = { diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index f84e1d11892..af0563762c2 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -300,12 +300,21 @@ type OAuthUsageWindow = { resets_at?: string | number } +type OAuthUsageLimit = { + kind?: string + percent?: number + resets_at?: string | number + is_active?: boolean + scope?: { model?: { display_name?: string } | null } | null +} + type OAuthUsageResponse = { five_hour?: OAuthUsageWindow seven_day?: OAuthUsageWindow fable_weekly?: OAuthUsageWindow fable_seven_day?: OAuthUsageWindow seven_day_fable?: OAuthUsageWindow + limits?: OAuthUsageLimit[] | null } type ClaudeUsageAttemptState = { @@ -391,9 +400,22 @@ function mapWindow( } function mapFableWeeklyWindow(data: OAuthUsageResponse): RateLimitWindow | null { - // Why: a bare "fable" field does not prove the window length. Only accept - // explicit weekly/seven-day names for the distinct Fable meter. + // Why: model quotas moved into structured scoped limits; prefer that current + // contract while retaining explicit legacy weekly fields for older responses. + const scoped = Array.isArray(data.limits) + ? data.limits.find( + (limit) => + limit?.kind === 'weekly_scoped' && + limit.is_active !== false && + Number.isFinite(limit.percent) && + limit.scope?.model?.display_name?.trim().toLowerCase() === 'fable' + ) + : undefined return ( + mapWindow( + scoped ? { used_percentage: scoped.percent, resets_at: scoped.resets_at } : undefined, + 10080 + ) ?? mapWindow(data.fable_weekly, 10080) ?? mapWindow(data.fable_seven_day, 10080) ?? mapWindow(data.seven_day_fable, 10080) diff --git a/src/main/rate-limits/grok-auth.test.ts b/src/main/rate-limits/grok-auth.test.ts index 793e3e7a694..7bf8f9e62b3 100644 --- a/src/main/rate-limits/grok-auth.test.ts +++ b/src/main/rate-limits/grok-auth.test.ts @@ -45,4 +45,112 @@ describe('readGrokAuthSession', () => { error: 'Grok auth file is invalid' }) }) + + it.each(['https://auth.x.ai', 'https://auth.x.ai::client'])( + 'prefers the %s issuer entry over an earlier alternate issuer', + async (preferredIssuer) => { + vi.doMock('node:fs', () => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => + JSON.stringify({ + 'https://stale.example.com::client': { + key: 'stale-token', + user_id: 'stale-user', + email: 'stale@example.com', + expires_at: '2099-01-01T00:00:00.000Z' + }, + [preferredIssuer]: { + key: 'live-token', + user_id: 'live-user', + email: 'live@example.com', + team_id: 'team-1', + expires_at: '2099-06-01T00:00:00.000Z', + oidc_client_id: 'client-1' + } + }) + ) + })) + const { readGrokAuthSession } = await import('./grok-auth') + + expect(readGrokAuthSession()).toEqual({ + status: 'ok', + session: { + accessToken: 'live-token', + userId: 'live-user', + email: 'live@example.com', + teamId: 'team-1', + expiresAtMs: Date.parse('2099-06-01T00:00:00.000Z'), + oidcClientId: 'client-1' + } + }) + } + ) + + it('falls back to the first tokenized entry when no auth.x.ai key exists', async () => { + vi.doMock('node:fs', () => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => + JSON.stringify({ + 'https://alternate.example.com::client': { + key: 'alt-token', + user_id: 'alt-user', + email: 'alt@example.com', + expires_at: '2099-01-01T00:00:00.000Z' + } + }) + ) + })) + const { readGrokAuthSession } = await import('./grok-auth') + + expect(readGrokAuthSession()).toEqual({ + status: 'ok', + session: { + accessToken: 'alt-token', + userId: 'alt-user', + email: 'alt@example.com', + teamId: null, + expiresAtMs: Date.parse('2099-01-01T00:00:00.000Z'), + oidcClientId: null + } + }) + }) + + it('skips an expired auth.x.ai client entry when a fresh one follows it', async () => { + vi.doMock('node:fs', () => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => + JSON.stringify({ + 'https://auth.x.ai::old-client': { + key: 'expired-token', + expires_at: '2020-01-01T00:00:00.000Z' + }, + 'https://auth.x.ai::current-client': { + key: 'fresh-token', + expires_at: '2099-01-01T00:00:00.000Z' + } + }) + ) + })) + const { readGrokAuthSession } = await import('./grok-auth') + + expect(readGrokAuthSession()).toMatchObject({ + status: 'ok', + session: { accessToken: 'fresh-token' } + }) + }) + + it('does not resurrect an alternate issuer when an auth.x.ai entry is tokenless', async () => { + vi.doMock('node:fs', () => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => + JSON.stringify({ + 'https://alternate.example.com::client': { key: 'stale-token' }, + 'https://auth.x.ai::client': { user_id: 'signed-out-user' } + }) + ) + })) + const { readGrokAuthSession } = await import('./grok-auth') + + expect(readGrokAuthSession()).toEqual({ status: 'missing' }) + }) }) diff --git a/src/main/rate-limits/grok-auth.ts b/src/main/rate-limits/grok-auth.ts index 10ba629ff34..e055c022975 100644 --- a/src/main/rate-limits/grok-auth.ts +++ b/src/main/rate-limits/grok-auth.ts @@ -29,6 +29,8 @@ type GrokAuthEntry = { oidc_client_id?: string } +type TokenizedGrokAuthEntry = GrokAuthEntry & { key: string } + export type GrokAuthReadResult = | { status: 'missing' } | { status: 'error'; error: string } @@ -43,7 +45,7 @@ function getGrokAuthReadError(err: unknown): string { return 'Unable to read Grok auth file' } -function parseAuthEntry(value: unknown): GrokAuthEntry | null { +function parseAuthEntry(value: unknown): TokenizedGrokAuthEntry | null { if (typeof value !== 'object' || value === null) { return null } @@ -51,7 +53,7 @@ function parseAuthEntry(value: unknown): GrokAuthEntry | null { if (typeof entry.key !== 'string' || entry.key.length === 0) { return null } - return entry + return entry as TokenizedGrokAuthEntry } function parseExpiresAtMs(iso: string | undefined): number | null { @@ -62,6 +64,24 @@ function parseExpiresAtMs(iso: string | undefined): number | null { return Number.isFinite(ms) ? ms : null } +// Why: stale alternate issuers can precede the default xAI OAuth session in auth.json. +const PREFERRED_GROK_AUTH_ISSUER = 'https://auth.x.ai' + +function sessionFromAuthEntry(authEntry: TokenizedGrokAuthEntry): GrokAuthSession { + return { + accessToken: authEntry.key, + userId: typeof authEntry.user_id === 'string' ? authEntry.user_id : null, + email: typeof authEntry.email === 'string' ? authEntry.email : null, + teamId: typeof authEntry.team_id === 'string' ? authEntry.team_id : null, + expiresAtMs: parseExpiresAtMs(authEntry.expires_at), + oidcClientId: typeof authEntry.oidc_client_id === 'string' ? authEntry.oidc_client_id : null + } +} + +function isPreferredGrokAuthKey(key: string): boolean { + return key === PREFERRED_GROK_AUTH_ISSUER || key.startsWith(`${PREFERRED_GROK_AUTH_ISSUER}::`) +} + export function readGrokAuthSession(): GrokAuthReadResult { const path = getGrokAuthPath() if (!existsSync(path)) { @@ -72,23 +92,32 @@ export function readGrokAuthSession(): GrokAuthReadResult { if (typeof parsed !== 'object' || parsed === null) { return { status: 'error', error: 'Grok auth file is invalid' } } - for (const entry of Object.values(parsed)) { + let preferredKeySeen = false + let expiredPreferred: GrokAuthSession | null = null + let fallback: GrokAuthSession | null = null + for (const [key, entry] of Object.entries(parsed as Record)) { + const isPreferred = isPreferredGrokAuthKey(key) + preferredKeySeen ||= isPreferred const authEntry = parseAuthEntry(entry) - if (!authEntry?.key) { + if (!authEntry) { continue } - return { - status: 'ok', - session: { - accessToken: authEntry.key, - userId: typeof authEntry.user_id === 'string' ? authEntry.user_id : null, - email: typeof authEntry.email === 'string' ? authEntry.email : null, - teamId: typeof authEntry.team_id === 'string' ? authEntry.team_id : null, - expiresAtMs: parseExpiresAtMs(authEntry.expires_at), - oidcClientId: - typeof authEntry.oidc_client_id === 'string' ? authEntry.oidc_client_id : null + const session = sessionFromAuthEntry(authEntry) + if (isPreferred) { + if (isGrokAccessTokenFresh(session)) { + return { status: 'ok', session } } + expiredPreferred ??= session + continue } + if (!fallback) { + fallback = session + } + } + // Why: alternate issuers are compatibility fallbacks only when no default entry exists. + const selectedSession = expiredPreferred ?? (preferredKeySeen ? null : fallback) + if (selectedSession) { + return { status: 'ok', session: selectedSession } } // Why: a token-less file (e.g. after grok logout) means signed out, not a // failure — 'error' would keep a status-bar alert visible for that user. diff --git a/src/main/rate-limits/grok-fetcher.test.ts b/src/main/rate-limits/grok-fetcher.test.ts index f816d5c552f..07c536f32a0 100644 --- a/src/main/rate-limits/grok-fetcher.test.ts +++ b/src/main/rate-limits/grok-fetcher.test.ts @@ -159,6 +159,10 @@ describe('fetchGrokRateLimits', () => { const result = await fetchGrokRateLimits() expect(result.status).toBe('error') expect(result.error).toMatch(/expired/i) + // Why: a stored-but-expired access token is refreshed by Grok CLI on next + // use (a genuine sign-out returns 'missing'), so the message must not tell + // users to re-run `grok login` (#8497). + expect(result.error).not.toMatch(/grok login/i) expect(netFetchMock).not.toHaveBeenCalled() }) }) diff --git a/src/main/rate-limits/grok-fetcher.ts b/src/main/rate-limits/grok-fetcher.ts index 963a2fb1350..099298a56c4 100644 --- a/src/main/rate-limits/grok-fetcher.ts +++ b/src/main/rate-limits/grok-fetcher.ts @@ -145,7 +145,10 @@ export async function fetchGrokRateLimits( } const session = readResult.session if (!isGrokAccessTokenFresh(session)) { - return result('error', 'Grok session expired — run grok login to refresh') + // Why: a genuine sign-out returns 'missing' earlier, so reaching here always + // means a stored, refreshable session — Grok CLI refreshes the access token + // on its next run, so don't tell users to re-run `grok login` (#8497). + return result('error', 'Grok access token expired — Grok CLI will refresh it on next use') } try { diff --git a/src/main/runtime/claude-agent-teams-shim-env.ts b/src/main/runtime/claude-agent-teams-shim-env.ts index 7a9a6dc1a22..6b6eeea94ba 100644 --- a/src/main/runtime/claude-agent-teams-shim-env.ts +++ b/src/main/runtime/claude-agent-teams-shim-env.ts @@ -83,7 +83,7 @@ function bundledLauncherPath(): string | null { return join(process.resourcesPath, 'bin', 'orca-ide') } if (process.platform === 'win32') { - return join(process.resourcesPath, 'bin', 'orca.cmd') + return join(process.resourcesPath, 'bin', 'orca.exe') } return null } diff --git a/src/main/runtime/file-watcher-host.test.ts b/src/main/runtime/file-watcher-host.test.ts index 6abea97bac7..e64a7e9d145 100644 --- a/src/main/runtime/file-watcher-host.test.ts +++ b/src/main/runtime/file-watcher-host.test.ts @@ -1,236 +1,301 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { FsChangeEvent } from '../../shared/types' +import type { + WatcherProcessCallback, + WatcherProcessHooks, + WatcherProcessSubscription +} from '../ipc/parcel-watcher-process' +import { WatcherProcessFailure } from '../ipc/parcel-watcher-process-failure' +import { + WATCHER_IGNORE_DIRS, + buildParcelWatcherIgnoreOptions +} from '../ipc/filesystem-watcher-ignore' -type MockWorker = { - terminated: boolean - postedMessages: unknown[] - workerData: unknown - on(event: string, listener: (arg?: unknown) => void): MockWorker - once(event: string, listener: (arg?: unknown) => void): MockWorker - off(event: string, listener: (arg?: unknown) => void): MockWorker - postMessage(message: unknown): void - terminate(): Promise - emit(event: string, arg?: unknown): void - listenerCount(event: string): number +const { + forgetRuntimeWatcherProcessRootMock, + resetRuntimeWatcherProcessForTestMock, + subscribeViaRuntimeWatcherProcessMock +} = vi.hoisted(() => ({ + forgetRuntimeWatcherProcessRootMock: vi.fn(), + resetRuntimeWatcherProcessForTestMock: vi.fn(), + subscribeViaRuntimeWatcherProcessMock: vi.fn() +})) + +vi.mock('../ipc/parcel-watcher-process', () => ({ + forgetRuntimeWatcherProcessRoot: forgetRuntimeWatcherProcessRootMock, + resetRuntimeWatcherProcessForTest: resetRuntimeWatcherProcessForTestMock, + subscribeViaRuntimeWatcherProcess: subscribeViaRuntimeWatcherProcessMock +})) + +import { + resetRuntimeRootWatchersForTest, + watchFileExplorerInWatcherProcess +} from './file-watcher-host' + +type InstalledWatch = { + callback: WatcherProcessCallback + hooks: WatcherProcessHooks + unsubscribe: ReturnType Promise>> } -const workerState = vi.hoisted(() => { - const instances: MockWorker[] = [] - class MockWorkerImpl { - terminated = false - postedMessages: unknown[] = [] - workerData: unknown - private listeners = new Map void; once: boolean }[]>() - - constructor(_workerPath: string, options: { workerData?: unknown }) { - this.workerData = options.workerData - instances.push(this as unknown as MockWorker) +function installSuccessfulWatch(): InstalledWatch { + let callback: WatcherProcessCallback | undefined + let hooks: WatcherProcessHooks | undefined + const unsubscribe = vi.fn(async () => undefined) + subscribeViaRuntimeWatcherProcessMock.mockImplementationOnce( + async ( + _rootPath: string, + nextCallback: WatcherProcessCallback, + _options: unknown, + nextHooks: WatcherProcessHooks + ): Promise => { + callback = nextCallback + hooks = nextHooks + return { unsubscribe } } - - on(event: string, listener: (arg?: unknown) => void): this { - const list = this.listeners.get(event) ?? [] - list.push({ listener, once: false }) - this.listeners.set(event, list) - return this - } - - once(event: string, listener: (arg?: unknown) => void): this { - const list = this.listeners.get(event) ?? [] - list.push({ listener, once: true }) - this.listeners.set(event, list) - return this - } - - off(event: string, listener: (arg?: unknown) => void): this { - const list = this.listeners.get(event) ?? [] - this.listeners.set( - event, - list.filter((entry) => entry.listener !== listener) - ) - return this - } - - postMessage(message: unknown): void { - this.postedMessages.push(message) - } - - async terminate(): Promise { - this.terminated = true - return 0 - } - - emit(event: string, arg?: unknown): void { - const entries = this.listeners.get(event)?.slice() ?? [] - for (const entry of entries) { - if (entry.once) { - this.off(event, entry.listener) - } - entry.listener(arg) + ) + return { + get callback() { + if (!callback) { + throw new Error('watch callback not installed') } - } - - listenerCount(event: string): number { - return this.listeners.get(event)?.length ?? 0 - } + return callback + }, + get hooks() { + if (!hooks) { + throw new Error('watch hooks not installed') + } + return hooks + }, + unsubscribe } - return { instances, MockWorkerImpl } -}) - -vi.mock('electron', () => ({ - app: { isPackaged: false } -})) - -vi.mock('worker_threads', () => ({ - Worker: workerState.MockWorkerImpl -})) - -import { watchFileExplorerInWorker } from './file-watcher-host' - -function lastWorker(): MockWorker { - const worker = workerState.instances.at(-1) - if (!worker) { - throw new Error('no worker spawned') - } - return worker } -describe('watchFileExplorerInWorker', () => { +describe('watchFileExplorerInWatcherProcess', () => { beforeEach(() => { - workerState.instances.length = 0 + forgetRuntimeWatcherProcessRootMock.mockReset() + resetRuntimeWatcherProcessForTestMock.mockReset() + subscribeViaRuntimeWatcherProcessMock.mockReset() }) afterEach(() => { + resetRuntimeRootWatchersForTest() vi.restoreAllMocks() }) - it('resolves to an unsubscribe fn once the worker reports ready', async () => { - const promise = watchFileExplorerInWorker('/repo', vi.fn()) - const worker = lastWorker() - expect(worker.workerData).toMatchObject({ rootPath: '/repo' }) + it('installs a bounded watch in the shared runtime watcher process', async () => { + const watch = installSuccessfulWatch() - worker.emit('message', { type: 'ready' }) - const dispose = await promise - expect(typeof dispose).toBe('function') - }) + const dispose = await watchFileExplorerInWatcherProcess('/repo', vi.fn()) - it('forwards worker events to the callback only after ready', async () => { - const onEvents = vi.fn<(events: FsChangeEvent[]) => void>() - const promise = watchFileExplorerInWorker('/repo', onEvents) - const worker = lastWorker() - worker.emit('message', { type: 'ready' }) - await promise - - const events: FsChangeEvent[] = [ - { kind: 'update', absolutePath: '/repo/a.txt', isDirectory: false } - ] - worker.emit('message', { type: 'events', events }) - expect(onEvents).toHaveBeenCalledWith(events) - }) - - it('rejects if the worker errors before the crawl goes live', async () => { - const promise = watchFileExplorerInWorker('/repo', vi.fn()) - const worker = lastWorker() - worker.emit('message', { type: 'error', message: 'addon missing' }) - - await expect(promise).rejects.toThrow('addon missing') - expect(worker.terminated).toBe(true) - }) - - it('rejects if the worker exits before ready', async () => { - const promise = watchFileExplorerInWorker('/repo', vi.fn()) - const worker = lastWorker() - worker.emit('exit', 1) - - await expect(promise).rejects.toThrow(/exited before ready/) - }) - - it('emits an overflow if a live worker crashes', async () => { - const onEvents = vi.fn<(events: FsChangeEvent[]) => void>() - const promise = watchFileExplorerInWorker('/repo', onEvents) - const worker = lastWorker() - worker.emit('message', { type: 'ready' }) - await promise - - worker.emit('error', new Error('boom')) - expect(onEvents).toHaveBeenCalledWith([{ kind: 'overflow', absolutePath: '/repo' }]) - }) - - it('unsubscribes and waits for a clean worker exit without force-terminating', async () => { - const promise = watchFileExplorerInWorker('/repo', vi.fn()) - const worker = lastWorker() - worker.emit('message', { type: 'ready' }) - const dispose = await promise - - const disposed = dispose() - expect(worker.postedMessages).toContainEqual({ type: 'unsubscribe' }) - // The worker unsubscribes its native watcher, closes its port and exits on - // its own — no force terminate, which is what corrupts the native watcher. - worker.emit('exit', 0) - await disposed - expect(worker.terminated).toBe(false) - expect(worker.listenerCount('exit')).toBe(1) - - // Idempotent: a second dispose does nothing further. - await dispose() - expect( - worker.postedMessages.filter((m) => (m as { type?: string }).type === 'unsubscribe') - ).toHaveLength(1) - }) - - it('shares pending dispose work across racing callers', async () => { - const promise = watchFileExplorerInWorker('/repo', vi.fn()) - const worker = lastWorker() - worker.emit('message', { type: 'ready' }) - const dispose = await promise + expect(subscribeViaRuntimeWatcherProcessMock).toHaveBeenCalledWith( + '/repo', + expect.any(Function), + buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS), + expect.objectContaining({ + delivery: { includeDirectoryMetadata: true, maxEventsPerBatch: 200 }, + onInterruption: expect.any(Function), + onOverflow: expect.any(Function), + onTerminalError: expect.any(Function), + signal: expect.any(AbortSignal) + }) + ) const firstDispose = dispose() const secondDispose = dispose() expect(secondDispose).toBe(firstDispose) - expect( - worker.postedMessages.filter((m) => (m as { type?: string }).type === 'unsubscribe') - ).toHaveLength(1) - - worker.emit('exit', 0) - await Promise.all([firstDispose, secondDispose]) - expect(worker.terminated).toBe(false) + await firstDispose + expect(watch.unsubscribe).toHaveBeenCalledTimes(1) }) - it('force-terminates the worker only if it fails to exit within the timeout', async () => { - vi.useFakeTimers() - try { - const promise = watchFileExplorerInWorker('/repo', vi.fn()) - const worker = lastWorker() - worker.emit('message', { type: 'ready' }) - const dispose = await promise - - const disposed = dispose() - expect(worker.postedMessages).toContainEqual({ type: 'unsubscribe' }) - expect(worker.listenerCount('exit')).toBe(2) - // Worker is wedged and never emits exit: the backstop must terminate it. - await vi.advanceTimersByTimeAsync(10_000) - await disposed - expect(worker.terminated).toBe(true) - expect(worker.listenerCount('exit')).toBe(1) - } finally { - vi.useRealTimers() - } - }) - - it('stops forwarding events after dispose', async () => { + it('maps child-enriched watcher events without host-process stat work', async () => { + const watch = installSuccessfulWatch() const onEvents = vi.fn<(events: FsChangeEvent[]) => void>() - const promise = watchFileExplorerInWorker('/repo', onEvents) - const worker = lastWorker() - worker.emit('message', { type: 'ready' }) - const dispose = await promise - const disposed = dispose() - worker.emit('exit', 0) - await disposed - onEvents.mockClear() + await watchFileExplorerInWatcherProcess('/repo', onEvents) + + watch.callback(null, [ + { type: 'update', path: '/repo/file.txt', isDirectory: false }, + { type: 'create', path: '/repo/dir', isDirectory: true }, + { type: 'delete', path: '/repo/gone.txt' } + ]) + + expect(onEvents).toHaveBeenCalledWith([ + { kind: 'update', absolutePath: '/repo/file.txt', isDirectory: false }, + { kind: 'create', absolutePath: '/repo/dir', isDirectory: true }, + { kind: 'delete', absolutePath: '/repo/gone.txt', isDirectory: undefined } + ]) + }) + + it('turns child overflow and recoverable watcher errors into a conservative refresh', async () => { + const watch = installSuccessfulWatch() + const onEvents = vi.fn<(events: FsChangeEvent[]) => void>() + vi.spyOn(console, 'error').mockImplementation(() => undefined) + await watchFileExplorerInWatcherProcess('/repo', onEvents) + + watch.hooks.onOverflow?.() + watch.callback(new Error('Events were dropped by the FSEvents client.'), []) + + expect(onEvents).toHaveBeenNthCalledWith(1, [{ kind: 'overflow', absolutePath: '/repo' }]) + expect(onEvents).toHaveBeenNthCalledWith(2, [{ kind: 'overflow', absolutePath: '/repo' }]) + }) + + it('refreshes only after the child has resubscribed', async () => { + const watch = installSuccessfulWatch() + const onEvents = vi.fn<(events: FsChangeEvent[]) => void>() + await watchFileExplorerInWatcherProcess('/repo', onEvents) + + watch.hooks.onInterruption?.() + + expect(onEvents).toHaveBeenCalledWith([{ kind: 'overflow', absolutePath: '/repo' }]) + }) + + it('keeps same-root subscribers alive while a failed shard is replaced', async () => { + const watch = installSuccessfulWatch() + const replacement = installSuccessfulWatch() + const firstEvents = vi.fn<(events: FsChangeEvent[]) => void>() + const secondEvents = vi.fn<(events: FsChangeEvent[]) => void>() + const firstError = vi.fn() + const secondError = vi.fn() + await watchFileExplorerInWatcherProcess('/repo', firstEvents, firstError) + await watchFileExplorerInWatcherProcess('/repo', secondEvents, secondError) + + watch.hooks.onTerminalError?.( + new WatcherProcessFailure('crashed repeatedly', 'supervisor', 'supervisor_crash_fuse') + ) + await vi.waitFor(() => expect(subscribeViaRuntimeWatcherProcessMock).toHaveBeenCalledTimes(2)) + watch.callback(null, [{ type: 'update', path: '/repo/late.txt' }]) + replacement.callback(null, [{ type: 'update', path: '/repo/recovered.txt' }]) + + expect(firstError).not.toHaveBeenCalled() + expect(secondError).not.toHaveBeenCalled() + expect(firstEvents).toHaveBeenCalledWith([{ kind: 'overflow', absolutePath: '/repo' }]) + expect(firstEvents).toHaveBeenCalledWith([ + { kind: 'update', absolutePath: '/repo/recovered.txt', isDirectory: undefined } + ]) + expect(secondEvents).toHaveBeenCalledTimes(2) + }) + + it('ends subscribers only after isolated recovery also fails', async () => { + const watch = installSuccessfulWatch() + subscribeViaRuntimeWatcherProcessMock.mockRejectedValueOnce(new Error('isolated failure')) + const onEvents = vi.fn<(events: FsChangeEvent[]) => void>() + const onTerminalError = vi.fn() + await watchFileExplorerInWatcherProcess('/repo', onEvents, onTerminalError) + + watch.hooks.onTerminalError?.( + new WatcherProcessFailure('crashed repeatedly', 'supervisor', 'supervisor_crash_fuse') + ) + + await vi.waitFor(() => + expect(onTerminalError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'isolated failure' }) + ) + ) + expect(onEvents).toHaveBeenCalledWith([{ kind: 'overflow', absolutePath: '/repo' }]) + }) + + it('cancels a pending physical watch when its only client aborts', async () => { + let physicalSignal: AbortSignal | undefined + subscribeViaRuntimeWatcherProcessMock.mockImplementation( + (_rootPath, _callback, _options, hooks: WatcherProcessHooks) => + new Promise((_resolve, reject) => { + physicalSignal = hooks.signal + hooks.signal?.addEventListener( + 'abort', + () => reject(new Error('physical watch aborted')), + { once: true } + ) + }) + ) + const controller = new AbortController() + const pending = watchFileExplorerInWatcherProcess('/repo', vi.fn(), vi.fn(), controller.signal) + + controller.abort() + + await expect(pending).rejects.toThrow('aborted') + expect(physicalSignal?.aborted).toBe(true) + }) + + it('keeps pending setup alive when another same-root client still owns it', async () => { + let physicalSignal: AbortSignal | undefined + let resolvePhysical: ((subscription: WatcherProcessSubscription) => void) | undefined + const unsubscribe = vi.fn(async () => undefined) + subscribeViaRuntimeWatcherProcessMock.mockImplementation( + (_rootPath, _callback, _options, hooks: WatcherProcessHooks) => { + physicalSignal = hooks.signal + return new Promise((resolve) => { + resolvePhysical = resolve + }) + } + ) + const controller = new AbortController() + const first = watchFileExplorerInWatcherProcess('/repo', vi.fn(), vi.fn(), controller.signal) + const second = watchFileExplorerInWatcherProcess('/repo', vi.fn()) + + controller.abort() + await expect(first).rejects.toThrow('aborted') + expect(physicalSignal?.aborted).toBe(false) + + resolvePhysical?.({ unsubscribe }) + await expect(second).resolves.toEqual(expect.any(Function)) + expect(subscribeViaRuntimeWatcherProcessMock).toHaveBeenCalledTimes(1) + }) + + it('shares one native subscription per root across same-root callers', async () => { + const firstRoot = installSuccessfulWatch() + const secondRoot = installSuccessfulWatch() + + await watchFileExplorerInWatcherProcess('/repo-a', vi.fn()) + await watchFileExplorerInWatcherProcess('/repo-a', vi.fn()) + await watchFileExplorerInWatcherProcess('/repo-b', vi.fn()) + + expect(subscribeViaRuntimeWatcherProcessMock).toHaveBeenCalledTimes(2) + expect(subscribeViaRuntimeWatcherProcessMock).toHaveBeenNthCalledWith( + 1, + '/repo-a', + expect.any(Function), + expect.any(Object), + expect.any(Object) + ) + expect(subscribeViaRuntimeWatcherProcessMock).toHaveBeenNthCalledWith( + 2, + '/repo-b', + expect.any(Function), + expect.any(Object), + expect.any(Object) + ) + expect(firstRoot.unsubscribe).not.toHaveBeenCalled() + expect(secondRoot.unsubscribe).not.toHaveBeenCalled() + }) + + it('stops forwarding events after the last subscriber disposes', async () => { + const watch = installSuccessfulWatch() + const onEvents = vi.fn<(events: FsChangeEvent[]) => void>() + const dispose = await watchFileExplorerInWatcherProcess('/repo', onEvents) + + await dispose() + watch.callback(null, [{ type: 'update', path: '/repo/a.txt' }]) + watch.hooks.onInterruption?.() - worker.emit('message', { - type: 'events', - events: [{ kind: 'update', absolutePath: '/repo/a.txt' }] - }) expect(onEvents).not.toHaveBeenCalled() }) + + it('propagates an initial watcher process subscription failure', async () => { + subscribeViaRuntimeWatcherProcessMock.mockRejectedValue(new Error('watcher unavailable')) + + await expect(watchFileExplorerInWatcherProcess('/repo', vi.fn())).rejects.toThrow( + 'watcher unavailable' + ) + }) + + it('does not retry a root-specific initial subscription failure', async () => { + subscribeViaRuntimeWatcherProcessMock.mockRejectedValue( + new WatcherProcessFailure('root unavailable', 'subscription', 'subscribe_failed') + ) + + await expect(watchFileExplorerInWatcherProcess('/missing', vi.fn())).rejects.toThrow( + 'root unavailable' + ) + expect(subscribeViaRuntimeWatcherProcessMock).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/main/runtime/file-watcher-host.ts b/src/main/runtime/file-watcher-host.ts index f368ea270e2..68e5711150d 100644 --- a/src/main/runtime/file-watcher-host.ts +++ b/src/main/runtime/file-watcher-host.ts @@ -1,160 +1,286 @@ -// Why: spawns the file-watcher worker thread and adapts it to the synchronous -// `watchFileExplorer` contract (a promise that resolves to an unsubscribe fn -// once the recursive crawl is live). Running @parcel/watcher in the worker -// keeps its blocking initial crawl off the main process's libuv pool so a huge -// non-git tree can't wedge the `serve` runtime (issue #5308). -import { Worker } from 'node:worker_threads' -import { join } from 'node:path' -import { app } from 'electron' import type { FsChangeEvent } from '../../shared/types' -import type { FileWatcherHostMessage, FileWatcherWorkerMessage } from './file-watcher-worker' -// Why: shares the explorer watcher's canonical ignore list, pre-shaped so the -// macOS daemon-side exclusion-path cap (8) is respected — over the cap ALL -// exclusions silently fail and fseventsd delivers full node_modules churn. +import { + forgetRuntimeWatcherProcessRoot, + resetRuntimeWatcherProcessForTest, + subscribeViaRuntimeWatcherProcess, + type WatcherProcessEvent, + type WatcherProcessSubscription +} from '../ipc/parcel-watcher-process' +import { isWatcherProcessFailure } from '../ipc/parcel-watcher-process-failure' import { WATCHER_IGNORE_DIRS, buildParcelWatcherIgnoreOptions } from '../ipc/filesystem-watcher-ignore' const RUNTIME_FILE_WATCH_IGNORE_OPTIONS = buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS) +const RUNTIME_FILE_WATCH_EVENT_LIMIT = 200 +const RUNTIME_FILE_WATCH_CRAWL_TIMEOUT_MS = 60_000 -// Why: clean teardown is async (the worker awaits subscription.unsubscribe() -// before closing its port and exiting). Wait this long for the worker to exit on -// its own before force-terminating, so the native watcher thread isn't freed -// mid-flight. -const WORKER_TEARDOWN_TIMEOUT_MS = 5000 -type WorkerExitWaitResult = 'exit' | 'timeout' - -function getFileWatcherWorkerPath(): string { - if (app.isPackaged) { - return join(process.resourcesPath, 'app.asar', 'out', 'main', 'file-watcher-worker.js') - } - return join(__dirname, 'file-watcher-worker.js') +type RuntimeFileWatchSubscriber = { + onEvents: (events: FsChangeEvent[]) => void + onTerminalError: (error: Error) => void } -function waitForWorkerExit(worker: Worker, timeoutMs: number): Promise { - return new Promise((resolve) => { +type RuntimeRootWatch = { + rootPath: string + subscribers: Set + start: Promise + subscription: WatcherProcessSubscription | null + abortController: AbortController + generation: number + closed: boolean +} + +// Why: paired clients watching the same worktree share one native subscription. +// Different roots are assigned across the bounded crash-isolated child pool. +const runtimeRootWatches = new Map() + +function overflowEvent(rootPath: string): FsChangeEvent[] { + return [{ kind: 'overflow', absolutePath: rootPath }] +} + +function mapWatcherEvents(events: readonly WatcherProcessEvent[]): FsChangeEvent[] { + return events.map((event) => ({ + kind: event.type, + absolutePath: event.path, + isDirectory: event.isDirectory + })) +} + +function emitToSubscribers(root: RuntimeRootWatch, events: FsChangeEvent[]): void { + if (root.closed) { + return + } + for (const subscriber of root.subscribers) { + subscriber.onEvents(events) + } +} + +function terminateRootWatch(root: RuntimeRootWatch, error: Error): void { + if (root.closed) { + return + } + root.closed = true + root.generation++ + root.abortController.abort() + if (runtimeRootWatches.get(root.rootPath) === root) { + runtimeRootWatches.delete(root.rootPath) + } + forgetRuntimeWatcherProcessRoot(root.rootPath) + const subscribers = Array.from(root.subscribers) + root.subscribers.clear() + for (const subscriber of subscribers) { + subscriber.onTerminalError(error) + } +} + +function closeInitialRootWatch(root: RuntimeRootWatch): void { + root.closed = true + root.generation++ + root.abortController.abort() + if (runtimeRootWatches.get(root.rootPath) === root) { + runtimeRootWatches.delete(root.rootPath) + } + forgetRuntimeWatcherProcessRoot(root.rootPath) + root.subscribers.clear() +} + +function shouldRetryInitialWatch(error: unknown): boolean { + return ( + isWatcherProcessFailure(error) && + error.code !== 'entry_missing' && + error.code !== 'subscribe_aborted' && + error.code !== 'supervisor_disposed' && + (error.scope === 'supervisor' || error.code === 'subscribe_timeout') + ) +} + +function subscribeRuntimeRootWatch(root: RuntimeRootWatch): Promise { + const generation = ++root.generation + const emitOverflow = (): void => { + if (root.generation === generation) { + emitToSubscribers(root, overflowEvent(root.rootPath)) + } + } + return subscribeViaRuntimeWatcherProcess( + root.rootPath, + (err, events) => { + if (root.generation !== generation || root.closed) { + return + } + if (err) { + console.error('[runtime-files.watch] watcher error', { + rootPath: root.rootPath, + error: err.message + }) + emitOverflow() + return + } + emitToSubscribers(root, mapWatcherEvents(events)) + }, + RUNTIME_FILE_WATCH_IGNORE_OPTIONS, + { + delivery: { + includeDirectoryMetadata: true, + maxEventsPerBatch: RUNTIME_FILE_WATCH_EVENT_LIMIT + }, + onInterruption: emitOverflow, + onOverflow: emitOverflow, + onTerminalError: (error) => recoverRuntimeRootWatch(root, generation, error), + // Why: the transport bounds initial setup at 15 seconds, but a later + // crash-resubscribe has no request deadline and must not freeze its shard forever. + subscribeTimeoutMs: RUNTIME_FILE_WATCH_CRAWL_TIMEOUT_MS, + signal: root.abortController.signal + } + ).then(async (subscription) => { + if (root.closed || root.generation !== generation) { + await subscription.unsubscribe() + return + } + root.subscription = subscription + }) +} + +async function startInitialRuntimeRootWatch(root: RuntimeRootWatch): Promise { + try { + await subscribeRuntimeRootWatch(root) + } catch (firstError) { + if (root.closed || !shouldRetryInitialWatch(firstError)) { + closeInitialRootWatch(root) + throw firstError + } + try { + await subscribeRuntimeRootWatch(root) + emitToSubscribers(root, overflowEvent(root.rootPath)) + } catch (isolatedError) { + closeInitialRootWatch(root) + throw isolatedError + } + } +} + +function recoverRuntimeRootWatch( + root: RuntimeRootWatch, + failedGeneration: number, + terminalError: Error +): void { + if (root.closed || root.generation !== failedGeneration) { + return + } + root.subscription = null + emitToSubscribers(root, overflowEvent(root.rootPath)) + root.start = subscribeRuntimeRootWatch(root).catch((recoveryError: unknown) => { + if (!root.closed) { + terminateRootWatch(root, recoveryError instanceof Error ? recoveryError : terminalError) + } + }) +} + +function createRuntimeRootWatch(rootPath: string): RuntimeRootWatch { + const root: RuntimeRootWatch = { + rootPath, + subscribers: new Set(), + start: Promise.resolve(), + subscription: null, + abortController: new AbortController(), + generation: 0, + closed: false + } + runtimeRootWatches.set(rootPath, root) + root.start = startInitialRuntimeRootWatch(root) + return root +} + +async function releaseRuntimeRootWatch( + root: RuntimeRootWatch, + subscriber: RuntimeFileWatchSubscriber +): Promise { + root.subscribers.delete(subscriber) + if (root.closed || root.subscribers.size > 0) { + return + } + root.closed = true + root.generation++ + root.abortController.abort() + if (runtimeRootWatches.get(root.rootPath) === root) { + runtimeRootWatches.delete(root.rootPath) + } + await root.subscription?.unsubscribe() +} + +/** + * Start a recursive runtime-file watch in the crash-isolated process pool. + * The child owns crawl, event collapse, and stat metadata so neither native + * faults nor event storms can consume the serve process's libuv pool. + */ +export async function watchFileExplorerInWatcherProcess( + rootPath: string, + callback: (events: FsChangeEvent[]) => void, + onTerminalError: (error: Error) => void = () => undefined, + signal?: AbortSignal +): Promise<() => Promise> { + const root = runtimeRootWatches.get(rootPath) ?? createRuntimeRootWatch(rootPath) + const subscriber: RuntimeFileWatchSubscriber = { onEvents: callback, onTerminalError } + root.subscribers.add(subscriber) + try { + await waitForRuntimeRootStart(root, subscriber, signal) + } catch (error) { + root.subscribers.delete(subscriber) + throw error + } + if (root.closed) { + throw new Error('file watcher closed during setup') + } + + let releasePromise: Promise | undefined + return (): Promise => { + releasePromise ??= releaseRuntimeRootWatch(root, subscriber) + return releasePromise + } +} + +function waitForRuntimeRootStart( + root: RuntimeRootWatch, + subscriber: RuntimeFileWatchSubscriber, + signal: AbortSignal | undefined +): Promise { + if (!signal) { + return root.start + } + if (signal.aborted) { + void releaseRuntimeRootWatch(root, subscriber) + return Promise.reject(new Error('file watcher subscription aborted')) + } + return new Promise((resolve, reject) => { let settled = false - let timer: ReturnType | undefined - let onExit: (() => void) | undefined - const finish = (result: WorkerExitWaitResult): void => { + const finish = (callback: () => void): void => { if (settled) { return } settled = true - if (timer) { - clearTimeout(timer) - } - if (onExit) { - worker.off('exit', onExit) - } - resolve(result) + signal.removeEventListener('abort', handleAbort) + callback() } - - onExit = () => finish('exit') - worker.once('exit', onExit) - timer = setTimeout(() => finish('timeout'), timeoutMs) + const handleAbort = (): void => { + void releaseRuntimeRootWatch(root, subscriber) + finish(() => reject(new Error('file watcher subscription aborted'))) + } + signal.addEventListener('abort', handleAbort, { once: true }) + root.start.then( + () => finish(resolve), + (error: unknown) => finish(() => reject(error)) + ) }) } -/** Start a recursive file watch in a worker thread. Resolves to an unsubscribe - * function once the worker reports the crawl is live; rejects if the worker - * fails to start the watch. */ -export function watchFileExplorerInWorker( - rootPath: string, - callback: (events: FsChangeEvent[]) => void -): Promise<() => Promise> { - return new Promise((resolve, reject) => { - const worker = new Worker(getFileWatcherWorkerPath(), { - workerData: { rootPath, ignoreOptions: RUNTIME_FILE_WATCH_IGNORE_OPTIONS } - }) - - let ready = false - let disposed = false - let exited = false - let disposePromise: Promise | undefined - - const runDispose = async (): Promise => { - if (disposed) { - return - } - disposed = true - if (exited) { - return - } - // Ask the worker to unsubscribe its native watcher and exit on its own. - // Why: worker.terminate() force-frees the worker's V8 env while - // @parcel/watcher's native watch thread / inflight async work is still - // live, which faults inside napi (Watcher::findCallback, - // PromiseRunner::onWorkComplete). Only terminate as a backstop if the - // worker wedges and never exits. - try { - worker.postMessage({ type: 'unsubscribe' } satisfies FileWatcherHostMessage) - } catch { - // Worker already gone — the exit wait and timeout backstop cover it. - } - const exitResult = await waitForWorkerExit(worker, WORKER_TEARDOWN_TIMEOUT_MS) - if (exitResult === 'timeout' && !exited) { - await worker.terminate().then( - () => undefined, - () => undefined - ) - } - } - - // Why: racing dispose callers must share the same worker-exit drain instead - // of letting later calls resolve while teardown is still in flight. - const dispose = (): Promise => { - disposePromise ??= runDispose() - return disposePromise - } - - worker.on('message', (message: FileWatcherWorkerMessage) => { - if (message.type === 'ready') { - ready = true - resolve(dispose) - return - } - if (message.type === 'events') { - if (!disposed) { - callback(message.events) - } - return - } - if (message.type === 'error') { - if (!ready) { - // The crawl never went live — fail the watch so the caller knows. - disposed = true - void worker.terminate() - reject(new Error(message.message)) - return - } - // Already live: a mid-stream watcher error. Tell the renderer to - // refresh; the worker also emits an overflow event alongside this. - console.error('[runtime-files.watch] worker error', { rootPath, error: message.message }) - } - }) - - worker.on('error', (err) => { - if (!ready) { - disposed = true - reject(err) - return - } - // A live worker crashed: surface an overflow so the renderer re-reads, - // rather than silently going stale. - console.error('[runtime-files.watch] worker crashed', { rootPath, err }) - if (!disposed) { - callback([{ kind: 'overflow', absolutePath: rootPath }]) - } - }) - - worker.on('exit', (code) => { - exited = true - if (!ready && !disposed) { - disposed = true - reject(new Error(`file watcher worker exited before ready (code ${code})`)) - } - }) - }) +export function resetRuntimeRootWatchersForTest(): void { + for (const root of runtimeRootWatches.values()) { + root.closed = true + root.generation++ + root.abortController.abort() + root.subscribers.clear() + void root.subscription?.unsubscribe() + } + runtimeRootWatches.clear() + resetRuntimeWatcherProcessForTest() } diff --git a/src/main/runtime/file-watcher-worker.ts b/src/main/runtime/file-watcher-worker.ts deleted file mode 100644 index 9fd8e17cb77..00000000000 --- a/src/main/runtime/file-watcher-worker.ts +++ /dev/null @@ -1,141 +0,0 @@ -// Why: on Linux/Windows @parcel/watcher uses a brute-force backend that -// recursively walks the whole tree on a libuv threadpool thread before -// subscribe() resolves. On a huge tree backed by slow storage (a home dir on -// NFS opened as a worktree) that crawl can run for minutes. Running it here, in -// a dedicated worker thread, keeps it off the main/`serve` process's libuv pool -// so it can never starve static-asset serving, RPC crypto, or other clients -// (issue #5308). The worker owns the subscribe, the per-event stat fanout, and -// the event batching; the main thread only relays results. -import { stat } from 'node:fs/promises' -import { parentPort, workerData } from 'node:worker_threads' -import type * as ParcelWatcher from '@parcel/watcher' -import type { FsChangeEvent } from '../../shared/types' -import type { ParcelWatcherIgnoreOptions } from '../ipc/filesystem-watcher-ignore' - -const RUNTIME_FILE_WATCH_EVENT_STAT_LIMIT = 200 -const RUNTIME_FILE_WATCH_STAT_CONCURRENCY = 8 - -type FileWatcherWorkerData = { - rootPath: string - ignoreOptions: ParcelWatcherIgnoreOptions -} - -// Messages the worker sends back to the host. -export type FileWatcherWorkerMessage = - | { type: 'ready' } - | { type: 'events'; events: FsChangeEvent[] } - | { type: 'error'; message: string } - -// Messages the host sends to the worker. -export type FileWatcherHostMessage = { type: 'unsubscribe' } - -const data = workerData as FileWatcherWorkerData - -if (!parentPort) { - throw new Error('File watcher worker must run with a parent port.') -} - -const port = parentPort - -/** Report a watcher failure to the host and ask the renderer to refresh from - * scratch (the overflow event), so a mid-stream error never leaves the - * explorer silently stale. */ -function reportWatchError(err: unknown): void { - port.postMessage({ - type: 'error', - message: err instanceof Error ? err.message : String(err) - } satisfies FileWatcherWorkerMessage) - port.postMessage({ - type: 'events', - events: [{ kind: 'overflow', absolutePath: data.rootPath }] - } satisfies FileWatcherWorkerMessage) -} - -/** Run an async mapper over items with a bounded number in flight at once, so a - * large batch can't occupy every libuv threadpool thread in this worker. */ -async function mapWithConcurrency( - items: readonly T[], - limit: number, - mapper: (item: T) => Promise -): Promise { - const results = Array.from({ length: items.length }) - let cursor = 0 - const worker = async (): Promise => { - while (cursor < items.length) { - const index = cursor++ - results[index] = await mapper(items[index]) - } - } - await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)) - return results -} - -async function main(): Promise { - let watcher: typeof ParcelWatcher - try { - watcher = await import('@parcel/watcher') - } catch (err) { - port.postMessage({ - type: 'error', - message: err instanceof Error ? err.message : String(err) - } satisfies FileWatcherWorkerMessage) - return - } - - const subscription = await watcher.subscribe( - data.rootPath, - (err, events) => { - if (err) { - reportWatchError(err) - return - } - // Why: large watcher batches usually mean a generated directory or branch - // switch. Avoid stat fanout and ask the renderer to refresh. - if (events.length > RUNTIME_FILE_WATCH_EVENT_STAT_LIMIT) { - port.postMessage({ - type: 'events', - events: [{ kind: 'overflow', absolutePath: data.rootPath }] - } satisfies FileWatcherWorkerMessage) - return - } - void mapWithConcurrency( - events, - RUNTIME_FILE_WATCH_STAT_CONCURRENCY, - async (event): Promise => { - let isDirectory = false - try { - isDirectory = (await stat(event.path)).isDirectory() - } catch { - isDirectory = false - } - return { kind: event.type, absolutePath: event.path, isDirectory } - } - ) - .then((mapped) => { - port.postMessage({ type: 'events', events: mapped } satisfies FileWatcherWorkerMessage) - }) - // Why: without this, a throwing postMessage / stat becomes an unhandled - // rejection that crashes the worker silently. Surface it instead. - .catch((err: unknown) => reportWatchError(err)) - }, - data.ignoreOptions as ParcelWatcher.Options - ) - - // The crawl finished and the subscription is live. - port.postMessage({ type: 'ready' } satisfies FileWatcherWorkerMessage) - - port.on('message', (message: FileWatcherHostMessage) => { - if (message.type === 'unsubscribe') { - void subscription.unsubscribe().finally(() => { - port.close() - }) - } - }) -} - -void main().catch((err: unknown) => { - port.postMessage({ - type: 'error', - message: err instanceof Error ? err.message : String(err) - } satisfies FileWatcherWorkerMessage) -}) diff --git a/src/main/runtime/mobile-notification-replay.test.ts b/src/main/runtime/mobile-notification-replay.test.ts new file mode 100644 index 00000000000..cfee0f199f8 --- /dev/null +++ b/src/main/runtime/mobile-notification-replay.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest' +import { + MobileNotificationReplayBuffer, + type ReplayableMobileNotification +} from './mobile-notification-replay' +import type { MobileNotificationDispatchEvent } from './orca-runtime' + +function dispatch( + buffer: MobileNotificationReplayBuffer, + partial: Partial = {} +): ReplayableMobileNotification { + const event: MobileNotificationDispatchEvent = { + type: 'notification', + source: 'agent-task-complete', + title: 'Done', + body: 'Finished.', + ...partial + } + buffer.record(event) + return buffer.getMissedSince(0).at(-1) as ReplayableMobileNotification +} + +describe('MobileNotificationReplayBuffer', () => { + it('assigns a strictly increasing monotonic notificationSeq to every recorded event', () => { + const buffer = new MobileNotificationReplayBuffer() + const a = dispatch(buffer, { notificationId: 'agent:one' }) + const b = dispatch(buffer, { notificationId: 'agent:two' }) + const c = dispatch(buffer, { notificationId: 'agent:three' }) + expect(a.notificationSeq).toBe(1) + expect(b.notificationSeq).toBe(2) + expect(c.notificationSeq).toBe(3) + expect(c.notificationSeq).toBeGreaterThan(b.notificationSeq) + expect(b.notificationSeq).toBeGreaterThan(a.notificationSeq) + }) + + // Why: the client watermarks + dedups on `notificationSeq` (the same field + // the live fan-out tags). Replayed events must expose that exact field — + // returning a bare `seq` here silently breaks watermark advance on a + // replay-only delivery (regression guard for the #8129 field mismatch). + it('exposes the client-facing `notificationSeq` field on replayed events', () => { + const buffer = new MobileNotificationReplayBuffer() + const a = dispatch(buffer, { notificationId: 'agent:one' }) + expect(a.notificationSeq).toBe(1) + expect((a as Record).seq).toBeUndefined() + }) + + it('returns missed notifications that were dispatched after the watermark', () => { + const buffer = new MobileNotificationReplayBuffer() + dispatch(buffer, { notificationId: 'agent:one' }) + const b = dispatch(buffer, { notificationId: 'agent:two' }) + dispatch(buffer, { notificationId: 'agent:three' }) + + // Client reconnected having delivered up to seq 1. + const missed = buffer.getMissedSince(b.notificationSeq - 1) + expect(missed.map((e) => e.notificationId)).toEqual(['agent:two', 'agent:three']) + }) + + it('does NOT return already-delivered notifications (dedupe regression)', () => { + const buffer = new MobileNotificationReplayBuffer() + const a = dispatch(buffer, { notificationId: 'agent:one' }) + dispatch(buffer, { notificationId: 'agent:two' }) + dispatch(buffer, { notificationId: 'agent:three' }) + + // Client already delivered everything up to seq 3, then reconnects again. + const missed = buffer.getMissedSince(a.notificationSeq + 2) + expect(missed).toEqual([]) + }) + + it('is idempotent: the same watermark always yields the same set', () => { + const buffer = new MobileNotificationReplayBuffer() + dispatch(buffer, { notificationId: 'agent:one' }) + dispatch(buffer, { notificationId: 'agent:two' }) + dispatch(buffer, { notificationId: 'agent:three' }) + + const first = buffer.getMissedSince(1) + const second = buffer.getMissedSince(1) + expect(second).toEqual(first) + expect(second.map((e) => e.notificationId)).toEqual(['agent:two', 'agent:three']) + }) + + it('does not duplicate an event that arrived on both live stream and replay', () => { + const buffer = new MobileNotificationReplayBuffer() + const a = dispatch(buffer, { notificationId: 'agent:one' }) + dispatch(buffer, { notificationId: 'agent:two' }) + + // Live stream delivered seq 1 (agent:one) during a brief liveness spell. + // Reconnect asks for everything after 0 — must include agent:one exactly + // once, never twice. + const missed = buffer.getMissedSince(a.notificationSeq - 1) + const ids = missed.map((e) => e.notificationId) + expect(ids).toEqual(['agent:one', 'agent:two']) + expect(ids.filter((id) => id === 'agent:one')).toHaveLength(1) + }) + + it('returns the whole buffer when the watermark is 0 (cold open)', () => { + const buffer = new MobileNotificationReplayBuffer() + dispatch(buffer, { notificationId: 'agent:one' }) + dispatch(buffer, { notificationId: 'agent:two' }) + expect(buffer.getMissedSince(0).map((e) => e.notificationId)).toEqual([ + 'agent:one', + 'agent:two' + ]) + }) + + it('evicts oldest entries once the capacity is exceeded', () => { + const buffer = new MobileNotificationReplayBuffer(2) + dispatch(buffer, { notificationId: 'agent:one' }) + dispatch(buffer, { notificationId: 'agent:two' }) + dispatch(buffer, { notificationId: 'agent:three' }) + + // Capacity is 2, so the first entry is evicted; even from watermark 0 we + // only get the two retained plus the newest. + const retained = buffer.getMissedSince(0).map((e) => e.notificationId) + expect(retained).toEqual(['agent:two', 'agent:three']) + expect(buffer.size).toBe(2) + }) +}) diff --git a/src/main/runtime/mobile-notification-replay.ts b/src/main/runtime/mobile-notification-replay.ts new file mode 100644 index 00000000000..d71b4d83125 --- /dev/null +++ b/src/main/runtime/mobile-notification-replay.ts @@ -0,0 +1,70 @@ +import type { MobileNotificationEvent } from './orca-runtime' + +// Why: when a mobile client's socket is reaped (background/sleep, or a warm +// proxy that delays the heartbeat reap), notifications dispatched in that +// window are lost — there is no queue. This buffer is the catch-up source of +// truth on the desktop: every notification that flows through +// dispatchMobileNotification is recorded with a monotonic seq, and a reconnecting +// client asks for everything after the seq it last acknowledged. +// +// Idempotency (the adversarial-review gate for #8129): replay is keyed by a +// global monotonic seq, not by wall-clock. getMissedSince(lastSeenSeq) returns +// exactly the events with seq > lastSeenSeq. Re-requesting with the same +// watermark returns the same set; requesting with the client's true watermark +// can never return an already-delivered event. The client additionally tracks +// seen ids, so even a buffer-cap overlap cannot double-push. +// +// Why the field is `notificationSeq` (not `seq`): the live fan-out in +// dispatchMobileNotification tags each event with `notificationSeq`, and the +// mobile client watermarks + dedups on that exact field. Replayed events MUST +// carry the same field name or the client can't advance its watermark from a +// replay-only delivery (it would re-fetch/re-push across a restart). Keeping a +// single field name end-to-end is what makes live and replay interchangeable. +export type ReplayableMobileNotification = MobileNotificationEvent & { + notificationSeq: number +} + +// Why: bound the buffer. 256 completes a few minutes of real agent activity and +// tracks the client's existing MAX_SCHEDULED_NOTIFICATIONS (256) cap, far beyond +// any background window a reconnect can still salvage. Beyond the cap we evict +// oldest-first; a reconnect after that long is effectively a cold open and the +// live stream resumes from current, so dropping older entries is acceptable. +const DEFAULT_CAPACITY = 256 + +export class MobileNotificationReplayBuffer { + private readonly capacity: number + private seq = 0 + private readonly buffer: ReplayableMobileNotification[] = [] + + constructor(capacity: number = DEFAULT_CAPACITY) { + this.capacity = capacity + } + + // Records a dispatched event and returns the monotonic seq assigned to it. + // Callers surface the seq so clients can watermark their last-seen position + // (both on the live fan-out and on explicit catch-up requests). + record(event: MobileNotificationEvent): number { + const seq = ++this.seq + this.buffer.push({ ...event, notificationSeq: seq }) + if (this.buffer.length > this.capacity) { + // Why: insertion-order array; oldest entries sit at the front. + this.buffer.splice(0, this.buffer.length - this.capacity) + } + return seq + } + + // Returns every recorded event with seq strictly greater than lastSeenSeq. + // Because seq is monotonic and global, this is an exact, idempotent cut: + // the same lastSeenSeq always yields the same result. + getMissedSince(lastSeenSeq: number): ReplayableMobileNotification[] { + if (lastSeenSeq >= this.seq) { + return [] + } + return this.buffer.filter((entry) => entry.notificationSeq > lastSeenSeq) + } + + // Test/inspection helper: number of events currently retained. + get size(): number { + return this.buffer.length + } +} diff --git a/src/main/runtime/orca-runtime-files-watch.test.ts b/src/main/runtime/orca-runtime-files-watch.test.ts index 014b7b8e774..7986e0fc9b0 100644 --- a/src/main/runtime/orca-runtime-files-watch.test.ts +++ b/src/main/runtime/orca-runtime-files-watch.test.ts @@ -4,11 +4,18 @@ import type * as FsPromises from 'node:fs/promises' import type * as FilesystemAuth from '../ipc/filesystem-auth' import type { FsChangeEvent } from '../../shared/types' -const { resolveAuthorizedPathMock, statMock, watchMock, watchInWorkerMock } = vi.hoisted(() => ({ +const { + resolveAuthorizedPathMock, + statMock, + watchMock, + watchInWatcherProcessMock, + getSshFilesystemProviderMock +} = vi.hoisted(() => ({ resolveAuthorizedPathMock: vi.fn(), statMock: vi.fn(), watchMock: vi.fn(), - watchInWorkerMock: vi.fn() + watchInWatcherProcessMock: vi.fn(), + getSshFilesystemProviderMock: vi.fn() })) vi.mock('fs', async () => { @@ -27,9 +34,9 @@ vi.mock('fs/promises', async () => { } }) -// The local (non-Windows, non-SSH) watch path now delegates to a worker thread. +// The local (non-Windows, non-SSH) watch path delegates to the isolated watcher process. vi.mock('./file-watcher-host', () => ({ - watchFileExplorerInWorker: watchInWorkerMock + watchFileExplorerInWatcherProcess: watchInWatcherProcessMock })) vi.mock('../ipc/filesystem-auth', async () => { @@ -40,6 +47,10 @@ vi.mock('../ipc/filesystem-auth', async () => { } }) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: getSshFilesystemProviderMock +})) + import { awaitRuntimeFileWatcherUnsubscribes, RuntimeFileCommands } from './orca-runtime-files' function createRuntimeFileCommands(rootPath: string) { @@ -67,15 +78,16 @@ function createRuntimeFileCommands(rootPath: string) { describe('RuntimeFileCommands file watching', () => { const originalPlatform = process.platform - // Why: Windows runtime watches intentionally use fs.watch instead of the worker path. - const posixWorkerIt = process.platform === 'win32' ? it.skip : it + // Why: Windows runtime watches intentionally use fs.watch instead of the watcher child. + const posixWatcherProcessIt = process.platform === 'win32' ? it.skip : it beforeEach(() => { vi.useFakeTimers() resolveAuthorizedPathMock.mockReset() statMock.mockReset() watchMock.mockReset() - watchInWorkerMock.mockReset() + watchInWatcherProcessMock.mockReset() + getSshFilesystemProviderMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform @@ -112,8 +124,8 @@ describe('RuntimeFileCommands file watching', () => { const unsubscribe = await commands.watchFileExplorer('id:wt-1', onEvents) expect(watchMock).toHaveBeenCalledWith('C:\\repo', { recursive: true }, expect.any(Function)) - // Windows path does not go through the worker. - expect(watchInWorkerMock).not.toHaveBeenCalled() + // Windows path does not go through the watcher child. + expect(watchInWatcherProcessMock).not.toHaveBeenCalled() const emit = listener as (() => void) | null expect(emit).not.toBeNull() @@ -130,48 +142,61 @@ describe('RuntimeFileCommands file watching', () => { expect(close).toHaveBeenCalledTimes(1) }) - // Issue #5308: the local recursive watch runs in a worker thread so - // @parcel/watcher's blocking initial crawl can't starve the serve runtime. - posixWorkerIt('delegates local recursive watching to the worker thread', async () => { + // Issues #5308/#8212: the local recursive watch runs out of process so the + // blocking initial crawl and native faults cannot take down the serve runtime. + posixWatcherProcessIt('delegates local recursive watching to the watcher process', async () => { resolveAuthorizedPathMock.mockResolvedValue('/home5/Brian') statMock.mockResolvedValue({ isDirectory: () => true }) const captured: { cb?: (events: FsChangeEvent[]) => void } = {} - const workerDispose = vi.fn() - watchInWorkerMock.mockImplementation((_rootPath, cb) => { + const watcherDispose = vi.fn() + watchInWatcherProcessMock.mockImplementation((_rootPath, cb) => { captured.cb = cb - return Promise.resolve(workerDispose) + return Promise.resolve(watcherDispose) }) const onEvents = vi.fn() const { commands } = createRuntimeFileCommands('/home5/Brian') - const unsubscribe = await commands.watchFileExplorer('id:wt-1', onEvents) + const controller = new AbortController() + const unsubscribe = await commands.watchFileExplorer( + 'id:wt-1', + onEvents, + vi.fn(), + controller.signal + ) - expect(watchInWorkerMock).toHaveBeenCalledWith('/home5/Brian', expect.any(Function)) + expect(watchInWatcherProcessMock).toHaveBeenCalledWith( + '/home5/Brian', + expect.any(Function), + expect.any(Function), + controller.signal + ) - // Events surfaced by the worker reach the caller. + // Events surfaced by the watcher process reach the caller. captured.cb?.([{ kind: 'update', absolutePath: '/home5/Brian/a.txt', isDirectory: false }]) expect(onEvents).toHaveBeenCalledWith([ { kind: 'update', absolutePath: '/home5/Brian/a.txt', isDirectory: false } ]) - // Unsubscribe tears the worker down (dispose runs on the shutdown-drain + // Unsubscribe tears the subscription down (dispose runs on the shutdown-drain // microtask, so await the drain before asserting). unsubscribe() await awaitRuntimeFileWatcherUnsubscribes() - expect(workerDispose).toHaveBeenCalledTimes(1) + expect(watcherDispose).toHaveBeenCalledTimes(1) }) - posixWorkerIt('propagates a worker watch failure to the caller', async () => { + posixWatcherProcessIt('propagates a watcher process failure to the caller', async () => { resolveAuthorizedPathMock.mockResolvedValue('/repo') statMock.mockResolvedValue({ isDirectory: () => true }) - watchInWorkerMock.mockRejectedValue(new Error('worker_failed')) + watchInWatcherProcessMock.mockRejectedValue(new Error('watcher_process_failed')) const { commands } = createRuntimeFileCommands('/repo') - await expect(commands.watchFileExplorer('id:wt-1', vi.fn())).rejects.toThrow('worker_failed') + await expect(commands.watchFileExplorer('id:wt-1', vi.fn())).rejects.toThrow( + 'watcher_process_failed' + ) }) - posixWorkerIt('tracks worker unsubscribe work so shutdown can await it', async () => { + posixWatcherProcessIt('tracks watcher unsubscribe work so shutdown can await it', async () => { resolveAuthorizedPathMock.mockResolvedValue('/repo') statMock.mockResolvedValue({ isDirectory: () => true }) @@ -182,7 +207,7 @@ describe('RuntimeFileCommands file watching', () => { resolveDispose = resolve }) ) - watchInWorkerMock.mockResolvedValue(disposeMock) + watchInWatcherProcessMock.mockResolvedValue(disposeMock) const { commands } = createRuntimeFileCommands('/repo') const unsubscribe = await commands.watchFileExplorer('id:wt-1', vi.fn()) @@ -201,4 +226,37 @@ describe('RuntimeFileCommands file watching', () => { await drainPromise expect(drained).toBe(true) }) + + it('forwards the abort signal into SSH-backed file explorer watches', async () => { + const watch = vi.fn(async () => () => {}) + getSshFilesystemProviderMock.mockReturnValue({ watch }) + const store = { getRepo: vi.fn(() => ({ connectionId: 'ssh-1' })) } + const commands = new RuntimeFileCommands({ + getRuntimeId: () => 'runtime-1', + requireStore: () => store, + resolveWorktreeSelector: vi.fn(async () => ({ + id: 'wt-1', + repoId: 'repo-1', + path: '/remote/repo' + })), + resolveRuntimeFileTarget: vi.fn(async () => ({ + worktree: { + id: 'wt-1', + repoId: 'repo-1', + path: '/remote/repo' + }, + connectionId: 'ssh-1' + })), + resolveRuntimeGitTarget: vi.fn(), + openFile: vi.fn() + } as never) + const controller = new AbortController() + + await commands.watchFileExplorer('id:wt-1', vi.fn(), vi.fn(), controller.signal) + + expect(watch).toHaveBeenCalledWith('/remote/repo', expect.any(Function), { + signal: controller.signal + }) + expect(watchInWatcherProcessMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/runtime/orca-runtime-files.test.ts b/src/main/runtime/orca-runtime-files.test.ts index 8412a7740be..d43e48c4695 100644 --- a/src/main/runtime/orca-runtime-files.test.ts +++ b/src/main/runtime/orca-runtime-files.test.ts @@ -18,7 +18,7 @@ const { renameMock, resolveAuthorizedPathMock, statMock, - watchInWorkerMock, + watchInWatcherProcessMock, checkRgAvailableMock, getLocalGitOptionsForRegisteredWorktreeMock, wslAwareSpawnMock, @@ -32,7 +32,7 @@ const { renameMock: vi.fn(), resolveAuthorizedPathMock: vi.fn(), statMock: vi.fn(), - watchInWorkerMock: vi.fn(), + watchInWatcherProcessMock: vi.fn(), wslAwareSpawnMock: vi.fn(), watchMock: vi.fn() })) @@ -61,7 +61,7 @@ vi.mock('fs/promises', async () => { }) vi.mock('./file-watcher-host', () => ({ - watchFileExplorerInWorker: watchInWorkerMock + watchFileExplorerInWatcherProcess: watchInWatcherProcessMock })) vi.mock('../ipc/filesystem-auth', async () => { @@ -199,7 +199,7 @@ describe('RuntimeFileCommands', () => { renameMock.mockReset() resolveAuthorizedPathMock.mockReset() statMock.mockReset() - watchInWorkerMock.mockReset() + watchInWatcherProcessMock.mockReset() watchMock.mockReset() checkRgAvailableMock.mockReset() vi.mocked(getSshFilesystemProvider).mockReset() @@ -441,7 +441,7 @@ describe('RuntimeFileCommands', () => { expect(close).toHaveBeenCalledTimes(1) }) - it('delegates local recursive watching to the worker thread', async () => { + it('delegates local recursive watching to the watcher process', async () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' @@ -449,11 +449,16 @@ describe('RuntimeFileCommands', () => { resolveAuthorizedPathMock.mockResolvedValue('/repo') statMock.mockResolvedValue({ isDirectory: () => true }) const dispose = vi.fn() - watchInWorkerMock.mockResolvedValue(dispose) + watchInWatcherProcessMock.mockResolvedValue(dispose) const { commands } = createRuntimeFileCommands() const unsubscribe = await commands.watchFileExplorer('id:wt-1', vi.fn()) - expect(watchInWorkerMock).toHaveBeenCalledWith('/repo', expect.any(Function)) + expect(watchInWatcherProcessMock).toHaveBeenCalledWith( + '/repo', + expect.any(Function), + expect.any(Function), + undefined + ) unsubscribe() await awaitRuntimeFileWatcherUnsubscribes() diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index e8a74e3591c..acdf3616f47 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -44,7 +44,7 @@ import type { RuntimeFileReadResult, RuntimeTerminalPathResolution } from '../../shared/runtime-types' -import { watchFileExplorerInWorker } from './file-watcher-host' +import { watchFileExplorerInWatcherProcess } from './file-watcher-host' import { wslAwareSpawn } from '../git/runner' import { parseWslPath, toWindowsWslPath } from '../wsl' import { isENOENT, resolveAuthorizedPath } from '../ipc/filesystem-auth' @@ -921,7 +921,9 @@ export class RuntimeFileCommands { async watchFileExplorer( worktreeSelector: string, - callback: (events: FsChangeEvent[]) => void + callback: (events: FsChangeEvent[]) => void, + onTerminalError: (error: Error) => void = () => undefined, + signal?: AbortSignal ): Promise<() => void> { const target = await this.resolveFileExplorerPath(worktreeSelector, '') const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null @@ -929,7 +931,9 @@ export class RuntimeFileCommands { if (!provider) { throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) } - return provider.watch(target.path, callback) + // Why: the RPC layer already threads AbortSignal for local watches; SSH + // must cancel the remote fs.watch request instead of waiting it out. + return provider.watch(target.path, callback, { signal }) } const rootPath = await resolveAuthorizedPath(target.path, this.host.requireStore()) @@ -940,9 +944,14 @@ export class RuntimeFileCommands { if (process.platform === 'win32') { return watchWindowsRuntimeFileExplorer(rootPath, callback) } - // Why: the watcher runs in a worker thread so @parcel/watcher's blocking - // recursive crawl can't starve the main/`serve` process (issue #5308). - const dispose = await watchFileExplorerInWorker(rootPath, callback) + // Why: the forked watcher keeps the blocking crawl and native faults out + // of the main/`serve` process (issues #5308 and #8212). + const dispose = await watchFileExplorerInWatcherProcess( + rootPath, + callback, + onTerminalError, + signal + ) return () => { trackRuntimeFileWatcherUnsubscribe(rootPath, dispose) } diff --git a/src/main/runtime/orca-runtime-terminal-cwd.test.ts b/src/main/runtime/orca-runtime-terminal-cwd.test.ts index 976b8169fdc..f8868848ea3 100644 --- a/src/main/runtime/orca-runtime-terminal-cwd.test.ts +++ b/src/main/runtime/orca-runtime-terminal-cwd.test.ts @@ -109,6 +109,9 @@ describe('OrcaRuntimeService terminal startup cwd', () => { it('materializes restored headless mobile tabs in the persisted startup cwd', async () => { const store = { + // wt-1 is a worktree id, not a registered repo, so getRepo returns null; + // the selector validator calls it to reject repo ids passed as worktree ids. + getRepo: () => null, getWorkspaceSession: () => ({ activeRepoId: null, activeWorktreeId: 'wt-1', diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index b8d9eee175f..53c99b72270 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -8,7 +8,7 @@ import { execFileSync } from 'node:child_process' import { mkdirSync } from 'node:fs' import { lstat, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, win32 } from 'node:path' import { ipcMain } from 'electron' import type { FolderWorkspace, @@ -30,10 +30,7 @@ import { removeWorktree } from '../git/worktree' import * as gitRunner from '../git/runner' -import { - clearSubmodulePathsCacheForTests, - listSubmodulePaths -} from '../git/status' +import { clearSubmodulePathsCacheForTests, listSubmodulePaths } from '../git/status' import { createSetupRunnerScript, getEffectiveHooks, @@ -59,7 +56,10 @@ import { type RuntimeTerminalAgentStatusEvent } from './orca-runtime' import { HeadlessEmulator } from '../daemon/headless-emulator' -import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' +import { + HEADLESS_RUNTIME_WINDOW_ID, + type RuntimeMobileSessionTabsResult +} from '../../shared/runtime-types' import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' import { TERMINAL_INPUT_CHUNK_MAX_BYTES, @@ -78,6 +78,7 @@ import { unregisterSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' +import * as worktreePathComparison from '../ipc/worktree-path-comparison' import * as localWorktreeFilesystem from '../local-worktree-filesystem' import { DEFAULT_REPO_BADGE_COLOR, @@ -946,7 +947,8 @@ class InMemoryOrchestrationMessages { read: 0, sequence: this.sequence, created_at: '1970-01-01 00:00:00', - delivered_at: null + delivered_at: null, + sender_pane_key: null } this.messages.push(row) return row @@ -1179,6 +1181,67 @@ const store = { getProjects: () => [] } +async function createExplicitAgentStatusHarness(options: { + getForegroundProcess: (ptyId: string) => Promise + confirmForegroundProcess?: (ptyId: string) => Promise + title?: string +}): Promise<{ + runtime: OrcaRuntimeService + handle: string + syncPty: (ptyId: string | null) => void +}> { + const leafId = '11111111-1111-4111-8111-111111111111' + const paneKey = makePaneKey('tab-1', leafId) + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey, + state: 'working', + prompt: '', + agentType: 'codex', + connectionId: null, + receivedAt: Date.now(), + stateStartedAt: Date.now(), + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID + } + ] + }) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }), + write: () => true, + kill: () => true, + getForegroundProcess: options.getForegroundProcess, + confirmForegroundProcess: options.confirmForegroundProcess + }) + runtime.attachWindow(1) + const syncPty = (ptyId: string | null): void => { + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + title: options.title ?? 'repo terminal', + activeLeafId: leafId, + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + leafId, + paneRuntimeId: 1, + ptyId + } + ] + }) + } + syncPty('pty-1') + const [terminal] = (await runtime.listTerminals()).terminals + return { runtime, handle: terminal.handle, syncPty } +} + function makeHeadlessTerminalLayout( ptyIdsByLeafId: Record ): TerminalLayoutSnapshot { @@ -1394,6 +1457,7 @@ describe('OrcaRuntimeService', () => { expect(runtime.getStatus()).toMatchObject({ graphStatus: 'unavailable', authoritativeWindowId: null, + desktopWindowStatus: 'openable', rendererGraphEpoch: 0 }) expect(runtime.getRuntimeId()).toBeTruthy() @@ -1488,6 +1552,118 @@ describe('OrcaRuntimeService', () => { expect(runtime.getStatus().authoritativeWindowId).toBe(TEST_WINDOW_ID) }) + it('transfers authority from the headless sentinel to the first real window', () => { + const runtime = createRuntime() + electronMocks.BrowserWindow.fromId.mockImplementation((windowId: number) => + windowId === TEST_WINDOW_ID ? ({ isDestroyed: () => false } as never) : null + ) + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + + runtime.attachWindow(TEST_WINDOW_ID) + runtime.attachWindow(2) + + expect(runtime.getStatus()).toMatchObject({ + authoritativeWindowId: TEST_WINDOW_ID, + desktopWindowStatus: 'available', + graphStatus: 'reloading', + rendererGraphEpoch: 1 + }) + }) + + it('marks live headless PTYs for renderer reattach before desktop promotion', () => { + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + activeWorktreeIdsOnShutdown: [] + }) + ) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + runtime.registerPty('persisted-pty', TEST_WORKTREE_ID, null, { + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID + }) + + runtime.attachWindow(TEST_WINDOW_ID) + + expect(getSession().activeWorktreeIdsOnShutdown).toEqual([TEST_WORKTREE_ID]) + }) + + it('marks live bindings again when reopening after a promoted window closes', () => { + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + activeWorktreeIdsOnShutdown: [] + }) + ) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + runtime.registerPty('persisted-pty', TEST_WORKTREE_ID, null, { + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID + }) + runtime.attachWindow(TEST_WINDOW_ID) + ;(runtimeStore.setWorkspaceSession as unknown as (next: WorkspaceSessionState) => void)({ + ...getSession(), + activeWorktreeIdsOnShutdown: [] + }) + runtime.markGraphUnavailable(TEST_WINDOW_ID) + + runtime.attachWindow(2) + + expect(getSession().activeWorktreeIdsOnShutdown).toEqual([TEST_WORKTREE_ID]) + }) + + it('preserves live SSH session identities when promoting a headless runtime', () => { + const remotePtyId = 'ssh:ssh-1@@persisted-pty' + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + activeWorktreeIdsOnShutdown: [], + activeConnectionIdsAtShutdown: [], + remoteSessionIdsByTabId: {}, + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'host-tab', + ptyId: remotePtyId, + worktreeId: TEST_WORKTREE_ID, + title: 'Remote Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: remotePtyId }) + } + }) + ) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + runtime.registerPty(remotePtyId, TEST_WORKTREE_ID, 'ssh-1', { + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID + }) + + runtime.attachWindow(TEST_WINDOW_ID) + + expect(getSession()).toMatchObject({ + activeWorktreeIdsOnShutdown: [TEST_WORKTREE_ID], + activeConnectionIdsAtShutdown: ['ssh-1'], + remoteSessionIdsByTabId: { 'host-tab': remotePtyId } + }) + }) + + it('reports the activation gate state while no desktop window is available', () => { + const runtime = new OrcaRuntimeService(store, undefined, { + getDesktopWindowStatus: () => 'blocked' + }) + + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + + expect(runtime.getStatus().desktopWindowStatus).toBe('blocked') + }) + it('bumps the epoch and enters reloading when the authoritative window reloads', () => { const runtime = createRuntime() @@ -2199,6 +2375,20 @@ describe('OrcaRuntimeService', () => { expect(internals.ptysById.has('cwd-only-pty')).toBe(false) }) + it('keeps unknown bare terminal-list ids on the no-scan exact-id path', async () => { + vi.mocked(listWorktrees).mockClear() + vi.mocked(listWorktrees).mockRejectedValue( + new Error('unknown explicit ids should not rescan worktrees') + ) + const runtime = createRuntime() + + await expect(runtime.listTerminals('id:not-a-repo')).resolves.toMatchObject({ + terminals: [], + totalCount: 0 + }) + expect(listWorktrees).not.toHaveBeenCalled() + }) + it('matches explicit-id cwd PTYs for folder workspace instance IDs', async () => { const folderWorktreeId = `${TEST_REPO_ID}::${TEST_FOLDER_WORKSPACE_PATH}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}11111111-1111-4111-8111-111111111111` vi.mocked(listWorktrees).mockClear() @@ -2521,6 +2711,72 @@ describe('OrcaRuntimeService', () => { ).rejects.toThrow('selector_not_found') }) + it('guides bare repo-id worktree selectors to the full id shape', async () => { + vi.mocked(listWorktrees).mockClear() + vi.mocked(listWorktrees).mockRejectedValue(new Error('bare repo ids should not scan worktrees')) + const runtime = new OrcaRuntimeService(store) + + await expect(runtime.showManagedWorktree(`id:${TEST_REPO_ID}`)).rejects.toThrow( + 'Worktree id selectors must use the full :: value.' + ) + expect(listWorktrees).not.toHaveBeenCalled() + }) + + it('guides registered SSH repo ids without probing the provider', async () => { + const remoteRepo = { ...store.getRepos()[0], connectionId: 'ssh-1' } + const remoteStore = { + ...store, + getRepos: () => [remoteRepo], + getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined) + } + vi.mocked(listWorktrees).mockClear() + getSshGitProviderMock.mockClear() + const runtime = new OrcaRuntimeService(remoteStore) + + await expect(runtime.showManagedWorktree(`id:${TEST_REPO_ID}`)).rejects.toMatchObject({ + code: 'worktree_id_requires_full_path' + }) + expect(listWorktrees).not.toHaveBeenCalled() + expect(getSshGitProviderMock).not.toHaveBeenCalled() + }) + + it('rejects bare repo ids through terminal list RPC with the structured code', async () => { + vi.mocked(listWorktrees).mockClear() + const runtime = new OrcaRuntimeService(store) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRpcRequest('terminal.list', { worktree: `id:${TEST_REPO_ID}` }) + ) + + expect(response).toMatchObject({ + ok: false, + error: { + code: 'worktree_id_requires_full_path', + message: expect.stringContaining('full :: value') + } + }) + expect(listWorktrees).not.toHaveBeenCalled() + }) + + it('rejects bare repo ids through the mobile session exact-id fast path', async () => { + vi.mocked(listWorktrees).mockClear() + const runtime = new OrcaRuntimeService(store) + + await expect(runtime.listMobileSessionTabs(`id:${TEST_REPO_ID}`)).rejects.toMatchObject({ + code: 'worktree_id_requires_full_path' + }) + expect(listWorktrees).not.toHaveBeenCalled() + }) + + it('still resolves the full worktree id selector', async () => { + const runtime = new OrcaRuntimeService(store) + + await expect(runtime.showManagedWorktree(`id:${TEST_WORKTREE_ID}`)).resolves.toMatchObject({ + id: TEST_WORKTREE_ID + }) + }) + it('still throws selector_not_found for an unknown id selector', async () => { const runtime = new OrcaRuntimeService(store) @@ -2529,6 +2785,27 @@ describe('OrcaRuntimeService', () => { ) }) + it('still throws selector_not_found for unknown bare ids', async () => { + const runtime = new OrcaRuntimeService(store) + + await expect(runtime.showManagedWorktree('id:not-a-repo')).rejects.toThrow('selector_not_found') + }) + + it('does not treat partial repo ids as full worktree ids', async () => { + const runtime = new OrcaRuntimeService(store) + + await expect(runtime.showManagedWorktree('id:repo')).rejects.toThrow('selector_not_found') + }) + + it('preserves full-id guidance for explicit parent-worktree lineage selectors', async () => { + const runtime = new OrcaRuntimeService(store) + const resolveLineage = runtime['resolveLineageForWorktreeCreate'].bind(runtime) + + await expect(resolveLineage({ parentWorktree: `id:${TEST_REPO_ID}` })).rejects.toThrow( + 'Worktree id selectors must use the full :: value.' + ) + }) + it('does not reuse stale in-flight worktree scans after creating a worktree', async () => { const runtime = new OrcaRuntimeService(store) const staleScan = deferred() @@ -6714,6 +6991,34 @@ describe('OrcaRuntimeService', () => { return { runtime, batches } } + it('defers desktop-only output scanners until a headless runtime is promoted', () => { + const { runtime, batches } = createSideEffectRuntime() + const trackerEntries = ( + runtime as unknown as { + ptyTitleTrackersByPtyId: Map + } + ).ptyTitleTrackersByPtyId + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + + runtime.onPtyData('pty-1', '\x07', 100) + + expect(batches).toEqual([]) + expect(trackerEntries.get('pty-1')?.commandCodeDetector).toBeNull() + + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + runtime.onPtyData('pty-1', '\x07', 101) + + expect(batches.flatMap((batch) => batch.facts)).toEqual([{ kind: 'bell' }]) + expect(trackerEntries.get('pty-1')?.commandCodeDetector).not.toBeNull() + + runtime.markGraphUnavailable(1) + runtime.onPtyData('pty-1', '\x07', 102) + + expect(batches).toHaveLength(1) + expect(trackerEntries.get('pty-1')?.commandCodeDetector).toBeNull() + }) + it('emits one batched event per chunk with facts in byte order and attribution', () => { const { runtime, batches } = createSideEffectRuntime() syncSinglePty(runtime) @@ -8486,6 +8791,203 @@ describe('OrcaRuntimeService', () => { }) }) + it('uses strong provider confirmation to authorize fresh hook state over a shell foreground', async () => { + const getForegroundProcess = vi.fn(async () => 'powershell.exe') + const confirmForegroundProcess = vi.fn(async () => 'claude') + const { runtime, handle } = await createExplicitAgentStatusHarness({ + getForegroundProcess, + confirmForegroundProcess + }) + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toEqual({ + handle, + isRunningAgent: true, + status: 'working' + }) + expect(getForegroundProcess).toHaveBeenCalledOnce() + expect(getForegroundProcess).toHaveBeenCalledWith('pty-1') + expect(confirmForegroundProcess).toHaveBeenCalledOnce() + expect(confirmForegroundProcess).toHaveBeenCalledWith('pty-1') + }) + + it('calls foreground confirmation with its controller receiver', async () => { + const getForegroundProcess = vi.fn(async () => 'powershell.exe') + const confirmForegroundProcess = vi.fn( + async function (this: { getForegroundProcess: typeof getForegroundProcess }) { + return this.getForegroundProcess === getForegroundProcess ? 'codex' : null + } + ) + const { runtime, handle } = await createExplicitAgentStatusHarness({ + getForegroundProcess, + confirmForegroundProcess + }) + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'working' + }) + expect(confirmForegroundProcess).toHaveBeenCalledOnce() + }) + + it.each([ + ['shell', async () => 'pwsh.exe'], + ['non-agent', async () => 'vim'], + ['unavailable', async () => null], + [ + 'failure', + async () => { + throw new Error('provider unavailable') + } + ] + ])('fails closed when shell-conflict confirmation returns %s', async (_case, confirm) => { + const confirmForegroundProcess = vi.fn(confirm) + const { runtime, handle } = await createExplicitAgentStatusHarness({ + getForegroundProcess: async () => 'zsh', + confirmForegroundProcess + }) + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toEqual({ + handle, + isRunningAgent: false, + status: null + }) + expect(confirmForegroundProcess).toHaveBeenCalledOnce() + }) + + it('fails closed on a shell conflict when the controller cannot confirm it', async () => { + const { runtime, handle } = await createExplicitAgentStatusHarness({ + getForegroundProcess: async () => 'zsh' + }) + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toEqual({ + handle, + isRunningAgent: false, + status: null + }) + }) + + it('skips strong confirmation when ordinary foreground evidence recognizes an agent', async () => { + const getForegroundProcess = vi.fn(async () => 'codex') + const confirmForegroundProcess = vi.fn(async () => 'codex') + const { runtime, handle } = await createExplicitAgentStatusHarness({ + getForegroundProcess, + confirmForegroundProcess + }) + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'working' + }) + expect(getForegroundProcess).toHaveBeenCalledOnce() + expect(confirmForegroundProcess).not.toHaveBeenCalled() + }) + + it('skips both foreground reads when current title evidence blocks explicit hook state', async () => { + const getForegroundProcess = vi.fn(async () => 'zsh') + const confirmForegroundProcess = vi.fn(async () => 'codex') + const { runtime, handle } = await createExplicitAgentStatusHarness({ + getForegroundProcess, + confirmForegroundProcess, + title: 'zsh' + }) + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: false, + status: null + }) + expect(getForegroundProcess).not.toHaveBeenCalled() + expect(confirmForegroundProcess).not.toHaveBeenCalled() + }) + + it('skips foreground reads for permission title and blocked wait evidence', async () => { + for (const blocked of ['title', 'wait'] as const) { + const getForegroundProcess = vi.fn(async () => 'zsh') + const confirmForegroundProcess = vi.fn(async () => 'codex') + const { runtime, handle } = await createExplicitAgentStatusHarness({ + getForegroundProcess, + confirmForegroundProcess + }) + runtime.onPtyData( + 'pty-1', + blocked === 'title' + ? '\x1b]0;Codex waiting for permission\x07' + : 'Hooks need review. Press enter to confirm\n', + Date.now() + 1000 + ) + getForegroundProcess.mockClear() + confirmForegroundProcess.mockClear() + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'permission' + }) + expect(getForegroundProcess).not.toHaveBeenCalled() + expect(confirmForegroundProcess).not.toHaveBeenCalled() + } + }) + + it('rejects foreground evidence when the handle rebinds during the ordinary read', async () => { + const foreground = deferred() + const getForegroundProcess = vi.fn(() => foreground.promise) + const confirmForegroundProcess = vi.fn(async () => 'codex') + const { runtime, handle, syncPty } = await createExplicitAgentStatusHarness({ + getForegroundProcess, + confirmForegroundProcess + }) + + const status = runtime.getTerminalAgentStatus(handle) + await vi.waitFor(() => expect(getForegroundProcess).toHaveBeenCalledWith('pty-1')) + syncPty('pty-2') + foreground.resolve('zsh') + + await expect(status).rejects.toThrow('terminal_handle_stale') + expect(confirmForegroundProcess).not.toHaveBeenCalled() + }) + + it('rejects a handle rebind while a controller-less status check yields', async () => { + const { runtime, handle, syncPty } = await createExplicitAgentStatusHarness({ + getForegroundProcess: async () => 'zsh' + }) + runtime.setPtyController(null) + + const status = runtime.getTerminalAgentStatus(handle) + syncPty('pty-2') + + await expect(status).rejects.toThrow('terminal_handle_stale') + }) + + it('rejects confirmation evidence when the handle rebinds during the fresh read', async () => { + const confirmation = deferred() + const confirmForegroundProcess = vi.fn(() => confirmation.promise) + const { runtime, handle, syncPty } = await createExplicitAgentStatusHarness({ + getForegroundProcess: async () => 'powershell.exe', + confirmForegroundProcess + }) + + const status = runtime.getTerminalAgentStatus(handle) + await vi.waitFor(() => expect(confirmForegroundProcess).toHaveBeenCalledWith('pty-1')) + syncPty('pty-2') + confirmation.resolve('codex') + + await expect(status).rejects.toThrow('terminal_handle_stale') + }) + + it('rejects confirmation evidence when the owning PTY exits', async () => { + const confirmation = deferred() + const confirmForegroundProcess = vi.fn(() => confirmation.promise) + const { runtime, handle } = await createExplicitAgentStatusHarness({ + getForegroundProcess: async () => 'powershell.exe', + confirmForegroundProcess + }) + + const status = runtime.getTerminalAgentStatus(handle) + await vi.waitFor(() => expect(confirmForegroundProcess).toHaveBeenCalledWith('pty-1')) + runtime.onPtyExit('pty-1', 0) + confirmation.resolve('codex') + + await expect(status).rejects.toThrow('terminal_exited') + }) + it('reports permission from a title-derived action-required agent state', async () => { const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ @@ -9574,11 +10076,37 @@ describe('OrcaRuntimeService', () => { }) expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ - worktreeId: TEST_WORKTREE_ID + worktreeId: TEST_WORKTREE_ID, + persistHostSessionBinding: true }) ) }) + it('keeps ordinary desktop background terminal persistence opt-in', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) + const runtime = new OrcaRuntimeService(store) + const webContents = { send: vi.fn() } + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents + } as never) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + + const spawnOptions = spawn.mock.calls[0]?.[0] as + | { persistHostSessionBinding?: boolean } + | undefined + expect(spawnOptions?.persistHostSessionBinding).toBeUndefined() + }) + it('falls back to background terminal creation for renderer-backed requests without a renderer window', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) const runtime = new OrcaRuntimeService(store) @@ -16708,6 +17236,16 @@ describe('OrcaRuntimeService', () => { it('creates mobile session terminals in a headless runtime server', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'pty-headless' }) const runtime = new OrcaRuntimeService(store) + const persistViewMode = vi.spyOn( + runtime as unknown as { + persistHeadlessSessionTabProps: ( + worktreeId: string, + tabId: string, + props: { viewMode: 'terminal' | 'chat' } + ) => void + }, + 'persistHeadlessSessionTabProps' + ) runtime.setPtyController({ spawn, write: () => true, @@ -16716,7 +17254,9 @@ describe('OrcaRuntimeService', () => { }) runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) - const result = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`) + const result = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + viewMode: 'chat' + }) expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ @@ -16732,8 +17272,12 @@ describe('OrcaRuntimeService', () => { type: 'terminal', status: 'ready', terminal: expect.stringMatching(/^term_/), + viewMode: 'chat', isActive: true }) + expect(persistViewMode).toHaveBeenCalledWith(TEST_WORKTREE_ID, result.tab.parentTabId, { + viewMode: 'chat' + }) const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) expect(listed.tabs).toEqual([ @@ -17333,12 +17877,13 @@ describe('OrcaRuntimeService', () => { it('operates PTY-backed mobile session terminals without a renderer graph', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' }) - const kill = vi.fn(() => true) + const stopAndWait = vi.fn(async () => true) const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ spawn, write: () => true, - kill, + kill: () => false, + stopAndWait, getForegroundProcess: async () => null }) @@ -17362,7 +17907,29 @@ describe('OrcaRuntimeService', () => { tabId: 'laptop-tab', ptyKilled: true }) - expect(kill).toHaveBeenCalledWith('laptop-created-pty') + expect(stopAndWait).toHaveBeenCalledWith('laptop-created-pty') + }) + + it('rejects remote terminal close until provider shutdown is verified', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'laptop-created-pty' }), + write: () => true, + kill: () => true, + stopAndWait: async () => false, + getForegroundProcess: async () => null + }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'laptop-tab', + leafId: HEADLESS_LEAF_ID + }) + + await expect(runtime.closeTerminal(terminal.handle)).rejects.toThrow( + 'terminal_verified_stop_failed' + ) + await expect(runtime.readTerminal(terminal.handle)).resolves.toMatchObject({ + status: 'running' + }) }) it('lists PTY-backed mobile session terminals without a renderer graph', async () => { @@ -19067,6 +19634,49 @@ describe('OrcaRuntimeService', () => { expect(getSession().terminalLayoutsByTabId['host-tab']).toBeUndefined() }) + it('keeps a headless terminal persisted when teardown ownership is not retained', async () => { + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal() + ) + const kill = vi.fn(() => false) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.setPtyController({ + write: () => true, + kill, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + title: 'Persisted Terminal', + activeLeafId: HEADLESS_LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + leafId: HEADLESS_LEAF_ID, + paneRuntimeId: 1, + ptyId: 'persisted-pty', + paneTitle: 'A' + } + ] + }) + + await expect( + runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab') + ).rejects.toThrow('terminal_close_not_retained') + + expect(kill).toHaveBeenCalledWith('persisted-pty') + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toHaveLength(1) + expect(getSession().terminalLayoutsByTabId['host-tab']).toBeDefined() + }) + it('closes persisted headless terminal parents before any prior list call', async () => { const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( makeWorkspaceSessionWithHeadlessTerminal() @@ -19780,7 +20390,8 @@ describe('OrcaRuntimeService', () => { }) const result = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { - activate: false + activate: false, + viewMode: 'chat' }) expect(send).toHaveBeenCalledWith( @@ -19788,7 +20399,8 @@ describe('OrcaRuntimeService', () => { expect.objectContaining({ worktreeId: TEST_WORKTREE_ID, activate: false, - source: 'runtime-session' + source: 'runtime-session', + viewMode: 'chat' }) ) expect(focusTerminal).not.toHaveBeenCalled() @@ -20146,7 +20758,8 @@ describe('OrcaRuntimeService', () => { }) const create = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { - activate: true + activate: true, + viewMode: 'terminal' }) let settled = false const settledCreate = create.finally(() => { @@ -20192,6 +20805,7 @@ describe('OrcaRuntimeService', () => { leafId: pendingLeafId, status: 'ready', terminal: expect.stringMatching(/^term_/), + viewMode: 'terminal', isActive: true }) expect(spawn).toHaveBeenCalledWith( @@ -20209,7 +20823,8 @@ describe('OrcaRuntimeService', () => { expect.objectContaining({ ptyId: 'pty-materialized', tabId: 'tab-pending', - leafId: pendingLeafId + leafId: pendingLeafId, + viewMode: 'terminal' }) ) expect(closeTerminal).not.toHaveBeenCalled() @@ -20403,7 +21018,8 @@ describe('OrcaRuntimeService', () => { }) const create = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { - activate: true + activate: true, + viewMode: 'chat' }) let settled = false const settledCreate = create.finally(() => { @@ -20411,8 +21027,35 @@ describe('OrcaRuntimeService', () => { }) await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1)) + // A shell-only renderer snapshot can win the first race but still omit + // launch props. The later PTY rescue must fill the explicit mode. + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'renderer-shell', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: `tab-alive::${leafId}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `tab-alive::${leafId}`, + parentTabId: 'tab-alive', + leafId, + title: 'Terminal', + isActive: true + } + ] + } + ] + }) + // The renderer's own PTY spawn registers with the tab binding — the same - // call the pty IPC layer now makes — without any mobileSessionTabs sync. + // call the pty IPC layer now makes — after the shell-only snapshot. runtime.registerPty('pty-alive', TEST_WORKTREE_ID, null, { tabId: 'tab-alive', leafId @@ -20428,7 +21071,8 @@ describe('OrcaRuntimeService', () => { parentTabId: 'tab-alive', leafId, status: 'ready', - terminal: expect.stringMatching(/^term_/) + terminal: expect.stringMatching(/^term_/), + viewMode: 'chat' }) expect(closeTerminal).not.toHaveBeenCalled() } finally { @@ -21336,25 +21980,41 @@ describe('OrcaRuntimeService', () => { }) }) - it('reports the resolved terminal platform for WSL project mobile summaries', async () => { + it('resolves WSL platforms only for repos represented in mobile summaries', async () => { await withPlatform('win32', async () => { + const primaryRepo = store.getRepos()[0]! + let repos = [ + primaryRepo, + ...Array.from({ length: 100 }, (_, index) => ({ + ...primaryRepo, + id: `repo-represented-${index}`, + path: `C:\\repo-represented-${index}`, + displayName: `repo-represented-${index}` + })) + ] + const getProjects = vi.fn(() => + repos.slice(0, 101).map((repo, index) => ({ + id: `project-${index}`, + displayName: repo.displayName, + badgeColor: 'blue', + sourceRepoIds: [repo.id], + localWindowsRuntimePreference: + index === 0 + ? ({ kind: 'wsl' as const, distro: 'Ubuntu' } as const) + : ({ kind: 'windows-host' as const } as const), + createdAt: 0, + updatedAt: 0 + })) + ) + const getSettings = vi.fn(() => ({ + ...store.getSettings(), + localWindowsRuntimeDefault: { kind: 'windows-host' as const } + })) const runtime = new OrcaRuntimeService({ ...store, - getProjects: () => [ - { - id: 'project-1', - displayName: 'repo', - badgeColor: 'blue', - sourceRepoIds: [TEST_REPO_ID], - localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, - createdAt: 0, - updatedAt: 0 - } - ], - getSettings: () => ({ - ...store.getSettings(), - localWindowsRuntimeDefault: { kind: 'windows-host' } - }) + getRepos: () => repos, + getProjects, + getSettings } as never) const { worktrees } = await runtime.getWorktreePs() @@ -21363,6 +22023,70 @@ describe('OrcaRuntimeService', () => { repoId: TEST_REPO_ID, terminalPlatform: 'linux' }) + expect(getProjects).toHaveBeenCalledTimes(1) + expect(getSettings).toHaveBeenCalledTimes(2) + getProjects.mockClear() + getSettings.mockClear() + repos = [ + ...repos, + ...Array.from({ length: 2_000 }, (_, index) => ({ + ...repos[0]!, + id: `repo-unresolved-${index}`, + path: `C:\\repo-unresolved-${index}`, + displayName: `repo-unresolved-${index}` + })) + ] + + await runtime.getWorktreePs() + + // Why: the cache already owns the batch-resolved platforms for its 101 + // worktree repos; newly persisted repos must not trigger another scan. + expect(getProjects).not.toHaveBeenCalled() + expect(getSettings).toHaveBeenCalledTimes(1) + }) + }) + + it('keeps each worktree poll paired with its platform generation', async () => { + await withPlatform('win32', async () => { + let runtimePreference: { kind: 'wsl'; distro: string } | { kind: 'windows-host' } = { + kind: 'wsl', + distro: 'Ubuntu' + } + const runtimeStore = { + ...store, + getProjects: () => [ + { + id: 'project-generation', + displayName: 'generation', + badgeColor: 'blue', + sourceRepoIds: [TEST_REPO_ID], + localWindowsRuntimePreference: runtimePreference, + createdAt: 0, + updatedAt: 0 + } + ], + getSettings: () => ({ + ...store.getSettings(), + localWindowsRuntimeDefault: { kind: 'windows-host' as const } + }) + } + const staleScan = deferred() + vi.mocked(listWorktrees) + .mockImplementationOnce(() => staleScan.promise) + .mockResolvedValueOnce(MOCK_GIT_WORKTREES) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + const stalePoll = runtime.getWorktreePs() + runtimePreference = { kind: 'windows-host' } + runtime.notifyBranchRenamed(TEST_REPO_ID) + const freshPoll = await runtime.getWorktreePs() + staleScan.resolve(MOCK_GIT_WORKTREES) + const staleResult = await stalePoll + + // Why: invalidation can let a newer scan finish first. Each result must + // retain the platform map computed with its own worktree generation. + expect(staleResult.worktrees[0]).toMatchObject({ terminalPlatform: 'linux' }) + expect(freshPoll.worktrees[0]).toMatchObject({ terminalPlatform: 'win32' }) }) }) @@ -21512,6 +22236,7 @@ describe('OrcaRuntimeService', () => { // worktree.ps reads the hook snapshot so mobile surfaces those agents too. const leafId = '33333333-3333-4333-8333-333333333333' const paneKey = `tab-1:${leafId}` + const now = Date.now() const runtime = new OrcaRuntimeService(store, undefined, { getAgentStatusSnapshot: () => [ { @@ -21523,8 +22248,8 @@ describe('OrcaRuntimeService', () => { agentType: 'claude', lastAssistantMessage: 'on it', connectionId: null, - receivedAt: 1000, - stateStartedAt: 900 + receivedAt: now, + stateStartedAt: now - 100 } ] }) @@ -21582,12 +22307,171 @@ describe('OrcaRuntimeService', () => { taskTitle: 'Dispatch prompt work', displayName: 'Review dispatch prompts and make worker labels distinct', lastAssistantMessage: 'on it', - stateStartedAt: 900, - updatedAt: 1000 + stateStartedAt: now - 100, + updatedAt: now }) ]) + expect(summary).toMatchObject({ hasHostSidebarActivity: true, status: 'working' }) }) + it('uses mirrored tab ownership after a workspace rename instead of stale hook attribution', async () => { + const renamedPath = '/tmp/worktree-renamed' + const renamedWorktreeId = `${TEST_REPO_ID}::${renamedPath}` + vi.mocked(listWorktrees).mockResolvedValue([ + ...MOCK_GIT_WORKTREES, + { + path: renamedPath, + head: 'def', + branch: 'feature/renamed', + isBare: false, + isMainWorktree: false + } + ]) + const metaById = { + ...store.getAllWorktreeMeta(), + [renamedWorktreeId]: makeWorktreeMeta({ displayName: 'renamed' }) + } + const session = makeWorkspaceSessionWithHeadlessTerminal({ + activeWorktreeId: renamedWorktreeId, + activeTabIdByWorktree: { [renamedWorktreeId]: 'host-tab' }, + tabsByWorktree: { + [renamedWorktreeId]: [ + { + id: 'host-tab', + ptyId: null, + worktreeId: renamedWorktreeId, + title: 'Codex', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + }) + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + getWorkspaceSession: () => session + } + const now = Date.now() + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: `host-tab:${HEADLESS_LEAF_ID}`, + worktreeId: TEST_WORKTREE_ID, + state: 'working', + prompt: 'continue after rename', + agentType: 'codex', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 100 + } + ] + }) + + const { worktrees } = await runtime.getWorktreePs() + const oldSummary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + const renamedSummary = worktrees.find((worktree) => worktree.worktreeId === renamedWorktreeId) + + expect(oldSummary?.agents).toEqual([]) + expect(renamedSummary).toMatchObject({ + hasHostSidebarActivity: true, + status: 'working', + agents: [expect.objectContaining({ prompt: 'continue after rename' })] + }) + }) + + it('keeps a fresh OSC row when the cached hook row for the same pane is older', async () => { + const now = Date.now() + const leafId = '44444444-4444-4444-8444-444444444444' + const paneKey = `tab-1:${leafId}` + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey, + worktreeId: TEST_WORKTREE_ID, + tabId: 'tab-1', + state: 'working', + prompt: 'stale hook row', + agentType: 'claude', + connectionId: null, + receivedAt: now - AGENT_STATUS_STALE_AFTER_MS - 1, + stateStartedAt: now - AGENT_STATUS_STALE_AFTER_MS - 100 + } + ] + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + title: 'Codex', + activeLeafId: leafId, + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + leafId, + paneRuntimeId: 1, + ptyId: 'pty-1' + } + ] + }) + runtime.onPtyData( + 'pty-1', + '\x1b]9999;{"state":"working","prompt":"fresh OSC row","agentType":"codex"}\x07', + 321 + ) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary).toMatchObject({ hasHostSidebarActivity: true, status: 'working' }) + expect(summary?.agents).toEqual([ + expect.objectContaining({ paneKey, prompt: 'fresh OSC row', agentType: 'codex' }) + ]) + }) + + it.each([ + ['blocked', 0, true, 'permission'], + ['waiting', 0, true, 'permission'], + ['done', 0, false, 'inactive'], + ['working', -AGENT_STATUS_STALE_AFTER_MS - 1, false, 'inactive'] + ] as const)( + 'projects %s agent activity to mobile at freshness offset %s', + async (state, updatedAtOffset, hasHostSidebarActivity, status) => { + const now = Date.now() + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: 'tab-1:33333333-3333-4333-8333-333333333333', + worktreeId: TEST_WORKTREE_ID, + tabId: 'tab-1', + state, + prompt: 'mobile parity', + agentType: 'codex', + connectionId: null, + receivedAt: now + updatedAtOffset, + stateStartedAt: now - 100 + } + ] + }) + + const { worktrees } = await runtime.getWorktreePs() + + expect(worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)).toMatchObject({ + hasHostSidebarActivity, + status + }) + } + ) + it('marks the desktop-active worktree as isActive', async () => { const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( makeWorkspaceSessionWithHeadlessTerminal() @@ -21621,10 +22505,11 @@ describe('OrcaRuntimeService', () => { displayName: 'Remote mobile' }) } + const getRepo = vi.fn((id: string) => (id === remoteRepo.id ? remoteRepo : undefined)) const runtimeStore = { ...store, getRepos: () => [remoteRepo], - getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), + getRepo, getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], setWorktreeMeta: (worktreeId: string, meta: Partial) => { @@ -21636,18 +22521,459 @@ describe('OrcaRuntimeService', () => { listWorktrees: vi.fn().mockResolvedValue([remoteWorktree]) } as never) - const runtime = new OrcaRuntimeService(runtimeStore as never) + const now = Date.now() + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot: () => + Array.from({ length: 100 }, (_, index) => ({ + paneKey: `remote-tab:${String(index).padStart(8, '0')}-5555-4555-8555-555555555555`, + worktreeId: `${remoteRepo.id}::${remoteWorktree.path}/`, + tabId: 'remote-tab', + state: 'working', + prompt: 'remote agent without a PTY', + agentType: 'codex', + connectionId: 'ssh-1', + receivedAt: now, + stateStartedAt: now - 100 + })) + }) const summaries = await runtime.getWorktreePs() + // Why: worktree.ps is polled; equal keys prove projected rows can share the + // per-request index instead of repeating compatibility path scans. + expect(worktreePathComparison.worktreePathComparisonKey(remoteWorktree.path, 'linux')).toBe( + worktreePathComparison.worktreePathComparisonKey(`${remoteWorktree.path}/`, 'linux') + ) + expect(summaries.worktrees).toEqual([ expect.objectContaining({ worktreeId: `${remoteRepo.id}::${remoteWorktree.path}`, repoId: remoteRepo.id, repo: 'remote-vm', path: remoteWorktree.path, - displayName: 'Remote mobile' + displayName: 'Remote mobile', + hasHostSidebarActivity: true, + status: 'working', + agents: expect.arrayContaining([ + expect.objectContaining({ + prompt: 'remote agent without a PTY', + agentType: 'codex' + }) + ]) }) ]) + expect(summaries.worktrees[0]?.agents).toHaveLength(100) + expect(getRepo).toHaveBeenCalledTimes(1) + }) + + it.each([ + ['relative', 'project', 'feature\\name', 'feature/name', 'feature\\name', true], + [ + 'absolute', + 'C:\\remote', + '/remote/feature\\name', + '/remote/feature/name', + '/remote/feature\\name', + true + ], + ['Windows SSH alias', 'C:\\remote', 'feature\\name', 'feature/name', 'feature/name', false] + ] as const)( + 'handles %s worktree paths when projecting mobile agents', + async (_kind, repoPath, backslashPath, slashPath, projectedPath, includeSlashWorktree) => { + setPlatform('win32') + const remoteRepo = { + id: 'repo-relative-ssh', + path: repoPath, + displayName: 'relative-vm', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-relative' + } + const backslashWorktree = { + path: backslashPath, + head: 'abc', + branch: 'refs/heads/backslash', + isBare: false, + isMainWorktree: false + } + const slashWorktree = { + ...backslashWorktree, + path: slashPath, + branch: 'refs/heads/slash' + } + const runtimeStore = { + ...store, + getRepos: () => [remoteRepo], + getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined + } + registerSshGitProvider('ssh-relative', { + listWorktrees: vi + .fn() + .mockResolvedValue( + includeSlashWorktree ? [backslashWorktree, slashWorktree] : [backslashWorktree] + ) + } as never) + + const now = Date.now() + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot: () => + Array.from({ length: 100 }, (_, index) => ({ + paneKey: `relative-tab:${String(index).padStart(8, '0')}-5555-4555-8555-555555555555`, + worktreeId: `${remoteRepo.id}::${projectedPath}/`, + tabId: 'relative-tab', + state: 'working', + prompt: 'relative path agent', + agentType: 'codex', + connectionId: 'ssh-relative', + receivedAt: now, + stateStartedAt: now - 100 + })) + }) + + const summaries = await runtime.getWorktreePs() + const backslashSummary = summaries.worktrees.find( + (worktree) => worktree.path === backslashWorktree.path + ) + const slashSummary = summaries.worktrees.find( + (worktree) => worktree.path === slashWorktree.path + ) + + expect(backslashSummary).toMatchObject({ hasHostSidebarActivity: true, status: 'working' }) + expect(backslashSummary?.agents).toHaveLength(100) + expect(slashSummary?.agents).toEqual(includeSlashWorktree ? [] : undefined) + const comparisonPlatform = repoPath.startsWith('C:') ? 'win32' : 'linux' + const backslashKey = worktreePathComparison.worktreePathComparisonKey( + backslashPath, + comparisonPlatform + ) + const slashKey = worktreePathComparison.worktreePathComparisonKey( + slashPath, + comparisonPlatform + ) + expect(backslashKey === slashKey).toBe(!includeSlashWorktree) + } + ) + + it('projects 100 distinct pair-aware paths without rescanning the worktree list', async () => { + const remoteRepo = { + id: 'repo-pair-aware-scale', + path: '/remote', + displayName: 'pair-aware-scale-vm', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-pair-aware-scale' + } + let pathReadCount = 0 + const remoteWorktrees = Array.from({ length: 100 }, (_, index) => { + const path = `C:relative\\feature-${String(index).padStart(3, '0')}` + return { + get path() { + pathReadCount += 1 + return path + }, + head: `head-${index}`, + branch: `refs/heads/feature-${index}`, + isBare: false, + isMainWorktree: false + } + }) + const runtimeStore = { + ...store, + getRepos: () => [remoteRepo], + getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined + } + registerSshGitProvider('ssh-pair-aware-scale', { + listWorktrees: vi.fn().mockResolvedValue(remoteWorktrees) + } as never) + const now = Date.now() + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot: () => + remoteWorktrees.map((worktree, index) => ({ + paneKey: `pair-aware-tab:${String(index).padStart(8, '0')}-9999-4999-8999-999999999999`, + worktreeId: `${remoteRepo.id}::${win32.resolve(worktree.path)}`, + tabId: 'pair-aware-tab', + state: 'working' as const, + prompt: `pair-aware agent ${index}`, + agentType: 'codex', + connectionId: 'ssh-pair-aware-scale', + receivedAt: now, + stateStartedAt: now - 100 + })) + }) + + const summaries = await runtime.getWorktreePs() + + expect(summaries.worktrees).toHaveLength(100) + expect(summaries.worktrees.every((worktree) => worktree.agents?.length === 1)).toBe(true) + // Why: property reads make the scaling assertion mutation-sensitive without + // relying on a wall-clock threshold that varies across CI machines. + expect(pathReadCount).toBeLessThan(2_000) + }) + + it('bounds 2000 distinct malformed path misses per mobile poll', async () => { + const remoteRepo = { + id: 'repo-malformed-scale', + path: '/remote', + displayName: 'malformed-scale-vm', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-malformed-scale' + } + let pathReadCount = 0 + const remoteWorktrees = Array.from({ length: 2_000 }, (_, index) => { + const path = `/remote/worktree-${String(index).padStart(4, '0')}` + return { + get path() { + pathReadCount += 1 + return path + }, + head: `head-${index}`, + branch: `refs/heads/worktree-${index}`, + isBare: false, + isMainWorktree: false + } + }) + const getRepo = vi.fn((id: string) => (id === remoteRepo.id ? remoteRepo : undefined)) + const runtimeStore = { + ...store, + getRepos: () => [remoteRepo], + getRepo, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined + } + registerSshGitProvider('ssh-malformed-scale', { + listWorktrees: vi.fn().mockResolvedValue(remoteWorktrees) + } as never) + const now = Date.now() + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot: () => + Array.from({ length: 2_000 }, (_, index) => ({ + paneKey: `malformed-tab:${String(index).padStart(8, '0')}-aaaa-4aaa-8aaa-aaaaaaaaaaaa`, + worktreeId: `${remoteRepo.id}::relative/./missing-${String(index).padStart(4, '0')}\\leaf`, + tabId: 'malformed-tab', + state: 'working' as const, + prompt: `missing agent ${index}`, + agentType: 'codex', + connectionId: 'ssh-malformed-scale', + receivedAt: now, + stateStartedAt: now - 100 + })) + }) + + const summaries = await runtime.getWorktreePs() + + expect(summaries).toMatchObject({ totalCount: 2_000, truncated: true }) + expect(summaries.worktrees.every((worktree) => worktree.agents?.length === 0)).toBe(true) + expect(getRepo).toHaveBeenCalledTimes(remoteWorktrees.length) + // Why: the prior fallback read every worktree path for every distinct miss, + // exceeding the three-second worktree.ps polling interval at this scale. + expect(pathReadCount).toBeLessThan(40_000) + }) + + it('caches repeated malformed path misses before normalizing them again', async () => { + const remoteRepo = { + id: 'repo-repeated-miss', + path: '/remote', + displayName: 'repeated-miss-vm', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-repeated-miss' + } + const remoteWorktree = { + path: '/remote/existing', + head: 'head-existing', + branch: 'refs/heads/existing', + isBare: false, + isMainWorktree: false + } + const runtimeStore = { + ...store, + getRepos: () => [remoteRepo], + getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined + } + registerSshGitProvider('ssh-repeated-miss', { + listWorktrees: vi.fn().mockResolvedValue([remoteWorktree]) + } as never) + const now = Date.now() + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot: () => + Array.from({ length: 2_000 }, (_, index) => ({ + paneKey: `repeated-miss-tab:${String(index).padStart(8, '0')}-bbbb-4bbb-8bbb-bbbbbbbbbbbb`, + worktreeId: `${remoteRepo.id}::relative/./missing\\leaf`, + tabId: 'repeated-miss-tab', + state: 'working' as const, + prompt: `repeated missing agent ${index}`, + agentType: 'codex', + connectionId: 'ssh-repeated-miss', + receivedAt: now, + stateStartedAt: now - 100 + })) + }) + const cwdSpy = vi.spyOn(process, 'cwd') + + try { + const summaries = await runtime.getWorktreePs() + + expect(summaries.worktrees[0]?.agents).toEqual([]) + // Why: resolving a relative comparison key consults cwd. A raw miss must + // do that once per poll, not once for every agent row carrying the ID. + expect(cwdSpy.mock.calls.length).toBeLessThan(50) + } finally { + cwdSpy.mockRestore() + } + }) + + it('keeps no-PTY agent worktrees in the truncated mobile summary', async () => { + const remoteRepo = { + id: 'repo-truncated-ssh', + path: '/remote', + displayName: 'truncated-vm', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-truncated' + } + const targetPath = '/remote/zzz-live-agent' + const remoteWorktrees = [ + ...Array.from({ length: 200 }, (_, index) => ({ + path: `/remote/inactive-${String(index).padStart(3, '0')}`, + head: `head-${index}`, + branch: `refs/heads/inactive-${index}`, + isBare: false, + isMainWorktree: false + })), + { + path: targetPath, + head: 'live-agent', + branch: 'refs/heads/live-agent', + isBare: false, + isMainWorktree: false + } + ] + const runtimeStore = { + ...store, + getRepos: () => [remoteRepo], + getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined + } + registerSshGitProvider('ssh-truncated', { + listWorktrees: vi.fn().mockResolvedValue(remoteWorktrees) + } as never) + const now = Date.now() + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: 'truncated-tab:77777777-7777-4777-8777-777777777777', + worktreeId: `${remoteRepo.id}::${targetPath}/`, + tabId: 'truncated-tab', + state: 'working', + prompt: 'live beyond the default limit', + agentType: 'codex', + connectionId: 'ssh-truncated', + receivedAt: now, + stateStartedAt: now - 100 + } + ] + }) + + const summaries = await runtime.getWorktreePs() + const target = summaries.worktrees.find((worktree) => worktree.path === targetPath) + + expect(summaries).toMatchObject({ totalCount: 201, truncated: true }) + expect(summaries.worktrees).toHaveLength(200) + expect(target).toMatchObject({ hasHostSidebarActivity: true, status: 'working' }) + expect(target?.agents).toHaveLength(1) + }) + + it('keeps pinned and unread worktrees when active rows fill the mobile summary limit', async () => { + setPlatform('win32') + const remoteRepo = { + id: 'repo-pinned-limit', + path: '/remote', + displayName: 'pinned-limit-vm', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-pinned-limit' + } + const activeWorktrees = Array.from({ length: 199 }, (_, index) => ({ + path: `relative/active-${String(index).padStart(3, '0')}`, + head: `head-${index}`, + branch: `refs/heads/active-${index}`, + isBare: false, + isMainWorktree: false + })) + const pinnedPath = 'relative/zzz-pinned' + const pinnedWorktree = { + path: pinnedPath, + head: 'pinned', + branch: 'refs/heads/pinned', + isBare: false, + isMainWorktree: false + } + const unreadPath = 'relative/zzz-unread' + const unreadWorktree = { + ...pinnedWorktree, + path: unreadPath, + head: 'unread', + branch: 'refs/heads/unread' + } + const pinnedId = `${remoteRepo.id}::${pinnedPath}` + const unreadId = `${remoteRepo.id}::${unreadPath}` + const metaById: Record = { + [pinnedId]: makeWorktreeMeta({ isPinned: true }), + [unreadId]: makeWorktreeMeta({ isUnread: true }) + } + const runtimeStore = { + ...store, + getRepos: () => [remoteRepo], + getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId] + } + registerSshGitProvider('ssh-pinned-limit', { + listWorktrees: vi.fn().mockResolvedValue([...activeWorktrees, pinnedWorktree, unreadWorktree]) + } as never) + const now = Date.now() + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot: () => + activeWorktrees.map((worktree, index) => ({ + paneKey: `active-tab:${String(index).padStart(8, '0')}-8888-4888-8888-888888888888`, + worktreeId: `${remoteRepo.id}::${worktree.path.replace('relative/', 'relative/./')}`, + tabId: 'active-tab', + state: 'working' as const, + prompt: 'active row', + agentType: 'codex', + connectionId: 'ssh-pinned-limit', + receivedAt: now, + stateStartedAt: now - 100 + })) + }) + + const summaries = await runtime.getWorktreePs() + + expect(summaries).toMatchObject({ totalCount: 201, truncated: true }) + expect(summaries.worktrees).toHaveLength(200) + expect(summaries.worktrees.find((worktree) => worktree.worktreeId === pinnedId)).toMatchObject({ + isPinned: true, + hasHostSidebarActivity: false + }) + expect(summaries.worktrees.find((worktree) => worktree.worktreeId === unreadId)).toMatchObject({ + unread: true, + hasHostSidebarActivity: false + }) + expect( + worktreePathComparison.worktreePathComparisonKey(activeWorktrees[0]!.path, 'linux') + ).toBe( + worktreePathComparison.worktreePathComparisonKey( + activeWorktrees[0]!.path.replace('relative/', 'relative/./'), + 'linux' + ) + ) }) it('clears stale working status after the agent exits and the shell takes over the title', async () => { @@ -21845,6 +23171,765 @@ describe('OrcaRuntimeService', () => { expect(killed).toBe(false) }) + it('awaits provider shutdown and verifies liveness before terminal.stop returns', async () => { + const runtime = new OrcaRuntimeService(store) + const stopped: string[] = [] + const processLists = [ + [ + { id: 'pty-1', cwd: '/tmp/worktree-a', title: 'Shell' }, + { id: 'pty-unrelated', cwd: '/tmp/unrelated', title: 'Other' } + ], + [{ id: 'pty-unrelated', cwd: '/tmp/unrelated', title: 'Other' }] + ] + const getForegroundProcess = vi.fn().mockResolvedValue(null) + runtime.setPtyController({ + write: () => true, + kill: () => false, + stopAndWait: async (ptyId) => { + stopped.push(ptyId) + runtime.onPtyExit(ptyId, -1) + return true + }, + getForegroundProcess, + listProcesses: async () => processLists.shift() ?? [] + }) + syncSinglePty(runtime, 'pty-1') + + await expect(runtime.stopTerminalsForWorktree('id:repo-1::/tmp/worktree-a')).resolves.toEqual({ + stopped: 1 + }) + expect(stopped).toEqual(['pty-1']) + expect(getForegroundProcess).not.toHaveBeenCalledWith('pty-unrelated') + }) + + it('scopes host-owned terminal stop liveness away from unrelated providers', async () => { + const runtime = new OrcaRuntimeService(store) + const processLists = [[{ id: 'pty-1', cwd: '/tmp/worktree-a', title: 'Shell' }], []] + const listProcesses = vi.fn(async (connectionId?: string | null) => { + if (connectionId !== null) { + throw new Error('unrelated SSH provider unavailable') + } + return processLists.shift() ?? [] + }) + runtime.setPtyController({ + write: () => true, + kill: () => false, + stopAndWait: async (ptyId) => { + runtime.onPtyExit(ptyId, -1) + return true + }, + getForegroundProcess: async () => null, + listProcesses + }) + syncSinglePty(runtime, 'pty-1') + runtime.registerPty('pty-ssh-unrelated', 'repo-1::/tmp/ssh-worktree', 'target-b') + + await expect( + runtime.stopTerminalsForWorktree('id:repo-1::/tmp/worktree-a', { hostId: 'local' }) + ).resolves.toEqual({ stopped: 1 }) + expect(listProcesses.mock.calls).toEqual([[null], [null]]) + expect(runtime['ptysById'].get('pty-ssh-unrelated')).toMatchObject({ + connected: true, + connectionId: 'target-b' + }) + }) + + it('blocks paired terminal admission until project stop and removal are atomic', async () => { + const removeProject = vi.fn() + const runtime = new OrcaRuntimeService({ ...store, removeProject } as never) + const stop = deferred() + const spawn = vi.fn().mockResolvedValue({ id: 'pty-late' }) + const processLists = [[{ id: 'pty-1', cwd: '/tmp/worktree-a', title: 'Shell' }], []] + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => false, + stopAndWait: () => stop.promise, + getForegroundProcess: async () => null, + listProcesses: async () => processLists.shift() ?? [] + }) + syncSinglePty(runtime, 'pty-1') + + const removal = runtime.removeProject(`id:${TEST_REPO_ID}`) + await vi.waitFor(() => expect(stop.promise).toBeDefined()) + await vi.waitFor(() => { + expect(processLists).toHaveLength(1) + }) + + await expect(runtime.createTerminal(`id:${TEST_WORKTREE_ID}`)).rejects.toThrow( + 'terminal_admission_blocked_for_project_removal' + ) + await expect( + runtime.createManagedWorktree({ repoSelector: `id:${TEST_REPO_ID}`, name: 'too-late' }) + ).rejects.toThrow('project_removal_in_progress') + expect(spawn).not.toHaveBeenCalled() + expect(addWorktree).not.toHaveBeenCalled() + + runtime.onPtyExit('pty-1', -1) + stop.resolve(true) + await expect(removal).resolves.toEqual({ removed: true }) + expect(removeProject).toHaveBeenCalledWith(TEST_REPO_ID) + }) + + it('drains create-first work before host-scoped project removal mutates persistence', async () => { + const removeProjectForHost = vi.fn() + const localRepo = store.getRepos()[0] + const hostStore = { + ...store, + getRepos: () => [localRepo], + removeProjectForHost + } + const runtime = new OrcaRuntimeService(hostStore as never) + const create = deferred() + vi.mocked(listWorktrees).mockResolvedValue([]) + + const admittedCreate = runtime.runWithWorktreeCreateAdmission( + TEST_REPO_ID, + () => create.promise + ) + const removal = runtime.removeProjectForHost(TEST_REPO_ID, 'local') + await Promise.resolve() + expect(removeProjectForHost).not.toHaveBeenCalled() + + create.resolve() + await admittedCreate + await expect(removal).resolves.toEqual({ removed: true }) + expect(removeProjectForHost).toHaveBeenCalledWith(TEST_REPO_ID, 'local') + }) + + it('rejects removal-first worktree creation for a host-scoped project removal', async () => { + const removeProjectForHost = vi.fn() + const localRepo = store.getRepos()[0] + const hostStore = { + ...store, + getRepos: () => [localRepo], + removeProjectForHost + } + const runtime = new OrcaRuntimeService(hostStore as never) + const scan = deferred>>() + vi.mocked(listWorktrees).mockImplementation(() => scan.promise) + + const removal = runtime.removeProjectForHost(TEST_REPO_ID, 'local') + await vi.waitFor(() => expect(listWorktrees).toHaveBeenCalled()) + await expect( + runtime.runWithWorktreeCreateAdmission(TEST_REPO_ID, async () => undefined) + ).rejects.toThrow('project_removal_in_progress') + + scan.resolve([]) + await expect(removal).resolves.toEqual({ removed: true }) + expect(removeProjectForHost).toHaveBeenCalledWith(TEST_REPO_ID, 'local') + }) + + it('does not drain or block a duplicate repo admission owned by another host', async () => { + const removeProjectForHost = vi.fn() + const runtime = new OrcaRuntimeService({ ...store, removeProjectForHost } as never) + const sshCreate = deferred() + vi.mocked(listWorktrees).mockResolvedValue([]) + + const admittedSshCreate = runtime.runWithWorktreeCreateAdmission( + TEST_REPO_ID, + () => sshCreate.promise, + 'ssh:target-1' + ) + await expect(runtime.removeProjectForHost(TEST_REPO_ID, 'local')).resolves.toEqual({ + removed: true + }) + expect(removeProjectForHost).toHaveBeenCalledWith(TEST_REPO_ID, 'local') + + sshCreate.resolve() + await admittedSshCreate + }) + + it('keeps whole-project removal conservative across every host scope', async () => { + const removeProject = vi.fn() + const runtime = new OrcaRuntimeService({ ...store, removeProject } as never) + const sshCreate = deferred() + vi.mocked(listWorktrees).mockResolvedValue([]) + + const admittedSshCreate = runtime.runWithWorktreeCreateAdmission( + TEST_REPO_ID, + () => sshCreate.promise, + 'ssh:target-1' + ) + const removal = runtime.removeProject(`id:${TEST_REPO_ID}`) + await Promise.resolve() + expect(removeProject).not.toHaveBeenCalled() + + sshCreate.resolve() + await admittedSshCreate + await expect(removal).resolves.toEqual({ removed: true }) + expect(removeProject).toHaveBeenCalledWith(TEST_REPO_ID) + }) + + it('blocks removal-first creates only on the owning execution host', async () => { + const localRemoval = deferred() + const runtime = new OrcaRuntimeService(store) + const removal = runtime.runWithWorktreeRemovalAdmission( + TEST_WORKTREE_ID, + () => localRemoval.promise, + 'local' + ) + await Promise.resolve() + + await expect( + runtime.runWithTerminalCreateAdmission(TEST_WORKTREE_ID, async () => undefined, 'local') + ).rejects.toThrow('terminal_admission_blocked_for_project_removal') + await expect( + runtime.runWithTerminalCreateAdmission( + TEST_WORKTREE_ID, + async () => 'ssh-created', + 'ssh:target-1' + ) + ).resolves.toBe('ssh-created') + + localRemoval.resolve() + await removal + }) + + it('filters duplicate worktree PTY ownership by execution host', () => { + const runtime = new OrcaRuntimeService(store) + runtime.registerPty('pty-local', TEST_WORKTREE_ID, null) + runtime.registerPty('pty-ssh', TEST_WORKTREE_ID, 'target-1') + const fresh = new Set(['pty-local', 'pty-ssh']) + + expect(runtime['getLivePtyIdsForWorktree'](TEST_WORKTREE_ID, fresh, null)).toEqual( + new Set(['pty-local']) + ) + expect(runtime['getLivePtyIdsForWorktree'](TEST_WORKTREE_ID, fresh, 'target-1')).toEqual( + new Set(['pty-ssh']) + ) + }) + + it('drains a create-first spawn before project removal snapshots liveness', async () => { + const removeProject = vi.fn() + const runtime = new OrcaRuntimeService({ ...store, removeProject } as never) + const spawnResult = deferred<{ id: string }>() + const stopAndWait = vi.fn(async (ptyId: string) => { + runtime.onPtyExit(ptyId, -1) + return true + }) + const processLists = [[{ id: 'pty-late', cwd: '/tmp/worktree-a', title: 'Shell' }], []] + const spawn = vi.fn(() => spawnResult.promise) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => false, + stopAndWait, + getForegroundProcess: async () => null, + listProcesses: async () => processLists.shift() ?? [] + }) + syncSinglePty(runtime, 'pty-late') + + const create = runtime.createTerminal(`id:${TEST_WORKTREE_ID}`) + await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce()) + const removal = runtime.removeProject(`id:${TEST_REPO_ID}`) + await Promise.resolve() + expect(removeProject).not.toHaveBeenCalled() + expect(processLists).toHaveLength(2) + + spawnResult.resolve({ id: 'pty-late' }) + await expect(create).resolves.toMatchObject({ ptyId: 'pty-late' }) + await expect(removal).resolves.toEqual({ removed: true }) + + expect(stopAndWait).toHaveBeenCalledWith('pty-late') + expect(removeProject).toHaveBeenCalledWith(TEST_REPO_ID) + }) + + it('drains pre-spawn agent preparation before project removal can finish', async () => { + const removeProject = vi.fn() + const runtime = new OrcaRuntimeService({ ...store, removeProject } as never) + const preparation = deferred>() + const runtimeWithPreparation = runtime as unknown as { + resolveAgentTerminalCreateOptions: () => Promise> + } + vi.spyOn(runtimeWithPreparation, 'resolveAgentTerminalCreateOptions').mockReturnValue( + preparation.promise + ) + const processLists = [ + [{ id: 'pty-after-preparation', cwd: TEST_WORKTREE_PATH, title: 'Shell' }], + [] + ] + const stopAndWait = vi.fn(async (ptyId: string) => { + runtime.onPtyExit(ptyId, -1) + return true + }) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-after-preparation' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => false, + stopAndWait, + getForegroundProcess: async () => null, + listProcesses: async () => processLists.shift() ?? [] + }) + + const create = runtime.createTerminal(`id:${TEST_WORKTREE_ID}`) + await vi.waitFor(() => + expect(runtimeWithPreparation.resolveAgentTerminalCreateOptions).toHaveBeenCalledOnce() + ) + const removal = runtime.removeProject(`id:${TEST_REPO_ID}`) + await Promise.resolve() + expect(spawn).not.toHaveBeenCalled() + expect(removeProject).not.toHaveBeenCalled() + + preparation.resolve({}) + await expect(create).resolves.toMatchObject({ ptyId: 'pty-after-preparation' }) + await expect(removal).resolves.toEqual({ removed: true }) + expect(stopAndWait).toHaveBeenCalledWith('pty-after-preparation') + expect(removeProject).toHaveBeenCalledWith(TEST_REPO_ID) + }) + + it('drains workspace resolution before worktree removal and rejects the stale create', async () => { + const runtime = new OrcaRuntimeService(store) + const resolution = deferred<{ + id: string + path: string + hostId: 'local' + connectionId: null + repo: ReturnType + folderWorkspace: null + }>() + const runtimeWithResolver = runtime as unknown as { + resolveTerminalWorkspaceLaunchScope: () => typeof resolution.promise + } + const resolveSpy = vi + .spyOn(runtimeWithResolver, 'resolveTerminalWorkspaceLaunchScope') + .mockReturnValue(resolution.promise) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-too-late' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => false, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + + const create = runtime.createTerminal(`id:${TEST_WORKTREE_ID}`) + await vi.waitFor(() => expect(resolveSpy).toHaveBeenCalledOnce()) + const remove = vi.fn() + const removal = runtime.runWithWorktreeRemovalAdmission(TEST_WORKTREE_ID, async () => { + remove() + }) + await Promise.resolve() + expect(remove).not.toHaveBeenCalled() + + resolution.resolve({ + id: TEST_WORKTREE_ID, + path: TEST_WORKTREE_PATH, + hostId: 'local', + connectionId: null, + repo: store.getRepo(TEST_REPO_ID), + folderWorkspace: null + }) + await expect(create).rejects.toThrow('terminal_admission_blocked_for_project_removal') + await expect(removal).resolves.toBeUndefined() + expect(remove).toHaveBeenCalledOnce() + expect(spawn).not.toHaveBeenCalled() + }) + + it('allows workspace resolution on an unrelated host during host-scoped removal', async () => { + const runtime = new OrcaRuntimeService(store) + const runtimeWithResolver = runtime as unknown as { + resolveTerminalWorkspaceLaunchScope: () => Promise<{ + id: string + path: string + hostId: 'ssh:target-1' + connectionId: 'target-1' + repo: ReturnType + folderWorkspace: null + }> + } + vi.spyOn(runtimeWithResolver, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({ + id: TEST_WORKTREE_ID, + path: '/home/dev/orca', + hostId: 'ssh:target-1', + connectionId: 'target-1', + repo: store.getRepo(TEST_REPO_ID), + folderWorkspace: null + }) + const spawn = vi.fn().mockResolvedValue({ id: 'ssh-pty' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => false, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + const holdRemoval = deferred() + const removal = runtime.runWithWorktreeRemovalAdmission( + TEST_WORKTREE_ID, + () => holdRemoval.promise, + 'local' + ) + await Promise.resolve() + + await expect(runtime.createTerminal(`id:${TEST_WORKTREE_ID}`)).resolves.toMatchObject({ + ptyId: 'ssh-pty' + }) + expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ connectionId: 'target-1' })) + + holdRemoval.resolve() + await removal + }) + + it('rejects a resolved create when its host removal starts and finishes during resolution', async () => { + const runtime = new OrcaRuntimeService(store) + const resolution = deferred<{ + id: string + path: string + hostId: 'ssh:target-1' + connectionId: 'target-1' + repo: ReturnType + folderWorkspace: null + }>() + const runtimeWithResolver = runtime as unknown as { + resolveTerminalWorkspaceLaunchScope: () => typeof resolution.promise + } + const resolveSpy = vi + .spyOn(runtimeWithResolver, 'resolveTerminalWorkspaceLaunchScope') + .mockReturnValue(resolution.promise) + const spawn = vi.fn().mockResolvedValue({ id: 'ssh-pty-too-late' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => false, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + const holdLocalRemoval = deferred() + const localRemoval = runtime.runWithWorktreeRemovalAdmission( + TEST_WORKTREE_ID, + () => holdLocalRemoval.promise, + 'local' + ) + await Promise.resolve() + + const create = runtime.createTerminal(`id:${TEST_WORKTREE_ID}`) + await vi.waitFor(() => expect(resolveSpy).toHaveBeenCalledOnce()) + await runtime.runWithWorktreeRemovalAdmission( + TEST_WORKTREE_ID, + async () => undefined, + 'ssh:target-1' + ) + resolution.resolve({ + id: TEST_WORKTREE_ID, + path: '/home/dev/orca', + hostId: 'ssh:target-1', + connectionId: 'target-1', + repo: store.getRepo(TEST_REPO_ID), + folderWorkspace: null + }) + + await expect(create).rejects.toThrow('terminal_admission_blocked_for_project_removal') + expect(spawn).not.toHaveBeenCalled() + holdLocalRemoval.resolve() + await localRemoval + }) + + it('allows a resolved create when only another host removal starts during resolution', async () => { + const runtime = new OrcaRuntimeService(store) + const resolution = deferred<{ + id: string + path: string + hostId: 'ssh:target-1' + connectionId: 'target-1' + repo: ReturnType + folderWorkspace: null + }>() + const runtimeWithResolver = runtime as unknown as { + resolveTerminalWorkspaceLaunchScope: () => typeof resolution.promise + } + vi.spyOn(runtimeWithResolver, 'resolveTerminalWorkspaceLaunchScope').mockReturnValue( + resolution.promise + ) + const spawn = vi.fn().mockResolvedValue({ id: 'ssh-pty' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => false, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + const holdLocalRemoval = deferred() + const localRemoval = runtime.runWithWorktreeRemovalAdmission( + TEST_WORKTREE_ID, + () => holdLocalRemoval.promise, + 'local' + ) + await Promise.resolve() + + const create = runtime.createTerminal(`id:${TEST_WORKTREE_ID}`) + await runtime.runWithWorktreeRemovalAdmission( + TEST_WORKTREE_ID, + async () => undefined, + 'ssh:target-2' + ) + resolution.resolve({ + id: TEST_WORKTREE_ID, + path: '/home/dev/orca', + hostId: 'ssh:target-1', + connectionId: 'target-1', + repo: store.getRepo(TEST_REPO_ID), + folderWorkspace: null + }) + + await expect(create).resolves.toMatchObject({ ptyId: 'ssh-pty' }) + expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ connectionId: 'target-1' })) + holdLocalRemoval.resolve() + await localRemoval + }) + + it('drains a create-first managed worktree before project removal snapshots ownership', async () => { + const removeProject = vi.fn() + const runtime = new OrcaRuntimeService({ ...store, removeProject } as never) + const runCreate = deferred() + const runtimeWithCreate = runtime as unknown as { + runCreateManagedWorktree: () => Promise + } + const runCreateSpy = vi + .spyOn(runtimeWithCreate, 'runCreateManagedWorktree') + .mockReturnValue(runCreate.promise) + runtime.setPtyController({ + write: () => true, + kill: () => false, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + + const create = runtime.createManagedWorktree({ + repoSelector: `id:${TEST_REPO_ID}`, + name: 'create-first' + }) + await vi.waitFor(() => expect(runCreateSpy).toHaveBeenCalledOnce()) + const removal = runtime.removeProject(`id:${TEST_REPO_ID}`) + await Promise.resolve() + expect(removeProject).not.toHaveBeenCalled() + + runCreate.resolve(undefined as never) + await expect(create).resolves.toBeUndefined() + await expect(removal).resolves.toEqual({ removed: true }) + expect(removeProject).toHaveBeenCalledWith(TEST_REPO_ID) + }) + + it.each([ + ['renderer-backed mobile create', 'mobile'], + ['selector-less renderer create', 'global'] + ] as const)('drains a create-first %s before project removal', async (_name, kind) => { + const removeProject = vi.fn() + const runtime = new OrcaRuntimeService({ ...store, removeProject } as never) + const processLists = [ + [{ id: 'pty-renderer-late', cwd: TEST_WORKTREE_PATH, title: 'Shell' }], + [] + ] + const stopAndWait = vi.fn(async (ptyId: string) => { + runtime.onPtyExit(ptyId, -1) + return true + }) + runtime.setPtyController({ + write: () => true, + kill: () => false, + stopAndWait, + getForegroundProcess: async () => null, + listProcesses: async () => processLists.shift() ?? [] + }) + runtime.setNotifier({ focusTerminal: vi.fn(), reposChanged: vi.fn() } as never) + const webContents = { send: vi.fn() } + const send = vi.fn((_channel: string, payload: { requestId: string }) => { + ipcMain.emit( + 'terminal:tabCreateReply', + { sender: webContents }, + { requestId: payload.requestId, tabId: 'tab-renderer-late', title: 'Terminal' } + ) + }) + webContents.send = send + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents + }) + + const create = + kind === 'mobile' + ? runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { activate: false }) + : runtime.createTerminal() + await vi.waitFor(() => expect(send).toHaveBeenCalledOnce()) + const removal = runtime.removeProject(`id:${TEST_REPO_ID}`) + await Promise.resolve() + expect(processLists).toHaveLength(2) + expect(removeProject).not.toHaveBeenCalled() + + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [ + { + tabId: 'tab-renderer-late', + worktreeId: TEST_WORKTREE_ID, + leafId: 'pane:late', + paneRuntimeId: 1, + ptyId: 'pty-renderer-late' + } + ], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: `admission-${kind}`, + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: 'tab-renderer-late::pane:late', + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: 'tab-renderer-late::pane:late', + parentTabId: 'tab-renderer-late', + leafId: 'pane:late', + title: 'Terminal', + isActive: true + } + ] + } + ] + }) + + await expect(create).resolves.toBeDefined() + await expect(removal).resolves.toEqual({ removed: true }) + expect(stopAndWait).toHaveBeenCalledWith('pty-renderer-late') + expect(removeProject).toHaveBeenCalledWith(TEST_REPO_ID) + }) + + it('drains a renderer-owned split before project removal snapshots liveness', async () => { + const removeProject = vi.fn() + const splitTerminal = vi.fn() + const runtime = new OrcaRuntimeService({ ...store, removeProject } as never) + const processLists = [ + [ + { id: 'pty-left', cwd: TEST_WORKTREE_PATH, title: 'Shell' }, + { id: 'pty-right', cwd: TEST_WORKTREE_PATH, title: 'Shell' } + ], + [] + ] + const stopAndWait = vi.fn(async (ptyId: string) => { + runtime.onPtyExit(ptyId, -1) + return true + }) + runtime.setPtyController({ + write: () => true, + kill: () => false, + stopAndWait, + getForegroundProcess: async () => null, + listProcesses: async () => processLists.shift() ?? [] + }) + runtime.setNotifier({ splitTerminal, reposChanged: vi.fn() } as never) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-split', + worktreeId: TEST_WORKTREE_ID, + title: 'Terminal', + activeLeafId: 'pane:left', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-split', + worktreeId: TEST_WORKTREE_ID, + leafId: 'pane:left', + paneRuntimeId: 1, + ptyId: 'pty-left' + } + ] + }) + const handle = await runtime.resolveActiveTerminal(`id:${TEST_WORKTREE_ID}`) + expect(handle).not.toBeNull() + + const split = runtime.splitTerminal(handle!) + await vi.waitFor(() => expect(splitTerminal).toHaveBeenCalledOnce()) + const removal = runtime.removeProject(`id:${TEST_REPO_ID}`) + await Promise.resolve() + expect(processLists).toHaveLength(2) + + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-split', + worktreeId: TEST_WORKTREE_ID, + title: 'Terminal', + activeLeafId: 'pane:right', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-split', + worktreeId: TEST_WORKTREE_ID, + leafId: 'pane:left', + paneRuntimeId: 1, + ptyId: 'pty-left' + }, + { + tabId: 'tab-split', + worktreeId: TEST_WORKTREE_ID, + leafId: 'pane:right', + paneRuntimeId: 2, + ptyId: 'pty-right' + } + ] + }) + + await expect(split).resolves.toBeDefined() + await expect(removal).resolves.toEqual({ removed: true }) + expect(stopAndWait).toHaveBeenCalledWith('pty-left') + expect(stopAndWait).toHaveBeenCalledWith('pty-right') + }) + + it('releases failed and concurrent terminal admissions without deadlocking removal', async () => { + const removeProject = vi.fn() + const runtime = new OrcaRuntimeService({ ...store, removeProject } as never) + runtime.setPtyController({ + write: () => true, + kill: () => false, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const first = deferred() + const second = deferred() + const firstAdmission = runtime['withTerminalAdmission'](TEST_WORKTREE_ID, () => first.promise) + const secondAdmission = runtime['withTerminalAdmission'](TEST_WORKTREE_ID, () => second.promise) + const removal = runtime.removeProject(`id:${TEST_REPO_ID}`) + await Promise.resolve() + expect(removeProject).not.toHaveBeenCalled() + + first.reject(new Error('spawn failed')) + second.resolve() + await expect(firstAdmission).rejects.toThrow('spawn failed') + await expect(secondAdmission).resolves.toBeUndefined() + await expect(removal).resolves.toEqual({ removed: true }) + expect(removeProject).toHaveBeenCalledWith(TEST_REPO_ID) + }) + + it('rejects terminal.stop when provider shutdown is not accepted', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => false, + stopAndWait: async () => false, + getForegroundProcess: async () => null, + listProcesses: async () => [{ id: 'pty-1', cwd: '/tmp/worktree-a', title: 'Shell' }] + }) + syncSinglePty(runtime, 'pty-1') + + await expect(runtime.stopTerminalsForWorktree('id:repo-1::/tmp/worktree-a')).rejects.toThrow( + 'terminal_verified_stop_failed' + ) + }) + it('stops exactly the expected live PTYs for a worktree', async () => { const runtime = new OrcaRuntimeService(store) const stopped: string[] = [] @@ -27321,6 +29406,7 @@ describe('OrcaRuntimeService', () => { const worktreeId = `${TEST_REPO_ID}::${missingWorktreePath}` const { runtimeStore, removeWorktreeMeta } = createStaleRuntimeWorktreeStore(worktreeId) const runtime = new OrcaRuntimeService(runtimeStore as never) + const verifyStopped = vi.spyOn(runtime, 'verifyTerminalsStoppedForRemoval') const registeredWorktrees = [ { path: TEST_REPO_PATH, @@ -27357,6 +29443,7 @@ describe('OrcaRuntimeService', () => { }) expect(runHook).not.toHaveBeenCalled() expect(removeWorktree).not.toHaveBeenCalled() + expect(verifyStopped).toHaveBeenCalledWith(worktreeId, 'local') expect(gitSpy).toHaveBeenCalledWith(['worktree', 'prune'], { cwd: TEST_REPO_PATH }) @@ -27738,6 +29825,7 @@ describe('OrcaRuntimeService', () => { const worktreeId = `${TEST_REPO_ID}::${missingWorktreePath}` const { runtimeStore, removeWorktreeMeta } = createStaleRuntimeWorktreeStore(worktreeId) const runtime = new OrcaRuntimeService(runtimeStore as never) + const verifyStopped = vi.spyOn(runtime, 'verifyTerminalsStoppedForRemoval') const notifier = { worktreesChanged: vi.fn() } runtime.setNotifier(notifier as never) @@ -27747,6 +29835,7 @@ describe('OrcaRuntimeService', () => { await expect(runtime.removeManagedWorktree(worktreeId, true)).resolves.toEqual({}) expect(removeWorktree).not.toHaveBeenCalled() + expect(verifyStopped).toHaveBeenCalledWith(worktreeId, undefined) expect(removeWorktreeMeta).toHaveBeenCalledWith(worktreeId) expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId) expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled() @@ -28869,7 +30958,8 @@ describe('OrcaRuntimeService', () => { it('RPC-initiated delete kills matching PTYs before git', async () => { // Seed the runtime with a live leaf whose worktreeId matches the target. - const killSpy = vi.fn().mockReturnValue(true) + const stopSpy = vi.fn().mockResolvedValue(true) + const processLists = [[{ id: 'pty-1', cwd: TEST_WORKTREE_PATH, title: 'Shell' }], []] const localProvider = createProviderStub(async () => []) const callOrder: string[] = [] vi.mocked(assertWorktreeCleanForRemoval).mockImplementation(async () => { @@ -28888,21 +30978,23 @@ describe('OrcaRuntimeService', () => { }) runtime.setPtyController({ write: () => true, - kill: (id) => { - callOrder.push(`kill:${id}`) - return killSpy(id) as boolean + kill: () => false, + stopAndWait: async (id) => { + callOrder.push(`stop:${id}`) + return stopSpy(id) as Promise }, - getForegroundProcess: async () => null + getForegroundProcess: async () => null, + listProcesses: async () => processLists.shift() ?? [] }) syncSinglePty(runtime, 'pty-1') await runtime.removeManagedWorktree(TEST_WORKTREE_ID) - expect(killSpy).toHaveBeenCalledWith('pty-1') + expect(stopSpy).toHaveBeenCalledWith('pty-1') // The provider-prefix sweep and the git removal must happen AFTER the // runtime-graph kill. Git removal must NOT happen before any kill. const preflightIdx = callOrder.indexOf('preflight') - const killIdx = callOrder.indexOf('kill:pty-1') + const killIdx = callOrder.indexOf('stop:pty-1') const gitIdx = callOrder.indexOf('git-removeWorktree') expect(preflightIdx).toBeGreaterThanOrEqual(0) expect(killIdx).toBeGreaterThan(preflightIdx) @@ -28910,6 +31002,28 @@ describe('OrcaRuntimeService', () => { expect(gitIdx).toBeGreaterThan(killIdx) }) + it('blocks late terminal admission while worktree removal is in preflight', async () => { + const preflightStarted = deferred() + const allowPreflight = deferred() + vi.mocked(removeWorktree).mockResolvedValue({}) + vi.mocked(assertWorktreeCleanForRemoval).mockImplementation(async () => { + preflightStarted.resolve() + await allowPreflight.promise + }) + + const runtime = new OrcaRuntimeService(store) + + const removal = runtime.removeManagedWorktree(TEST_WORKTREE_ID) + await preflightStarted.promise + + await expect( + runtime.runWithTerminalCreateAdmission(TEST_WORKTREE_ID, async () => undefined) + ).rejects.toThrow('terminal_admission_blocked_for_project_removal') + + allowPreflight.resolve() + await expect(removal).resolves.toEqual({}) + }) + it('thunk resolves the installed provider lazily, not at construction time', async () => { // Simulates the daemon adapter being installed AFTER OrcaRuntimeService // construction (setLocalPtyProvider(routedAdapter) in daemon-init). diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 84f1954d194..7142635f1dc 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -4,6 +4,7 @@ import { detectAgentStatusFromTitle, isClaudeManagementTitle, + isCursorAgentTitle, isCursorNativeAgentTitle, isShellProcess, normalizeTerminalTitle @@ -26,6 +27,7 @@ import type { import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector' import { AGENT_STATUS_STALE_AFTER_MS, + isFreshNonDoneAgentStatus, type AgentStatusIpcPayload, type ParsedAgentStatusPayload, type AgentStatusOrchestrationContext, @@ -142,8 +144,11 @@ import type { } from '../../shared/types' import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree-removal' import { + LOCAL_EXECUTION_HOST_ID, getRepoExecutionHostId, + normalizeExecutionHostId, parseExecutionHostId, + toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' @@ -169,6 +174,10 @@ import type { LinearTeamStatesResult, LinearStatusSetResult } from '../../shared/linear-agent-access' +import { + HEADLESS_RUNTIME_WINDOW_ID, + type RuntimeDesktopWindowStatus +} from '../../shared/runtime-types' import { LINEAR_SEARCH_MAX_LIMIT, LINEAR_WRITE_BODY_CAP, @@ -179,6 +188,7 @@ import type { FeatureInteractionId } from '../../shared/feature-interactions' import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, + WORKTREE_ID_SEPARATOR, splitWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree-id' @@ -462,9 +472,13 @@ import { import { getLocalProjectGitExecOptions, getLocalProjectWorktreeGitOptions, - resolveLocalProjectRuntimeForRepo + getLocalProjectWorktreeGitOptionsForRuntime, + resolveLocalProjectRuntimeForRepo, + resolveLocalProjectRuntimesForRepos } from '../project-runtime-git-options' +import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' +import { resolveTerminalOrchestrationCliCommand } from './orchestration/cli-command' import { getLocalWorktreePathAccess, removeLocalWorktreePath, @@ -686,6 +700,7 @@ import { shouldSetDisplayName, areWorktreePathsEqual } from '../ipc/worktree-logic' +import { worktreePathComparisonKey } from '../ipc/worktree-path-comparison' import { assertWorktreeDoesNotContainRegisteredWorktree, canCleanupUnregisteredOrcaLeftoverDirectory, @@ -713,6 +728,10 @@ import { registerTerminalViewAttributesApplier } from './terminal-view-attribute-store' import { killAllProcessesForWorktree } from './worktree-teardown' +import { + MobileNotificationReplayBuffer, + type ReplayableMobileNotification +} from './mobile-notification-replay' import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits' import { createMobileSessionTabsNotifyCoalescer, @@ -739,6 +758,7 @@ import type { ClaudeAccountService } from '../claude-accounts/service' import type { CodexAccountService } from '../codex-accounts/service' import type { RateLimitService } from '../rate-limits/service' import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState } from '../../shared/types' +import { applyPRBotAuthorOverride } from '../../shared/pr-bot-author-overrides' import type { RateLimitState } from '../../shared/rate-limit-types' import type { VoiceSettings } from '../../shared/speech-types' import { getSpeechModelManager, getSpeechSttService } from '../speech/speech-runtime-service' @@ -802,6 +822,7 @@ type RuntimeStore = { updateFolderWorkspace?: Store['updateFolderWorkspace'] removeFolderWorkspace?: Store['removeFolderWorkspace'] removeProject?: Store['removeProject'] + removeProjectForHost?: Store['removeProjectForHost'] reorderRepos?: Store['reorderRepos'] getAllWorktreeMeta: Store['getAllWorktreeMeta'] getWorktreeMeta: Store['getWorktreeMeta'] @@ -852,8 +873,8 @@ type RuntimeStore = { compactWorktreeCards?: GlobalSettings['compactWorktreeCards'] minimaxGroupId?: GlobalSettings['minimaxGroupId'] minimaxUsageModels?: GlobalSettings['minimaxUsageModels'] + prBotAuthorOverrides?: GlobalSettings['prBotAuthorOverrides'] gitlabProjects?: GlobalSettings['gitlabProjects'] - experimentalWorktreeSymlinks?: boolean mobileAutoRestoreFitMs?: number | null mobileEmulatorEnabled?: boolean mobileEmulatorDefaultDeviceUdid?: string | null @@ -965,28 +986,13 @@ function isCursorAgentOrchestrationTarget( return [leaf.lastOscTitle, leaf.paneTitle, tabTitle].some(isCursorAgentTitle) } -function isCursorAgentTitle(title: string | null | undefined): boolean { - if (typeof title !== 'string') { - return false - } - const trimmed = title.trim() - const lower = trimmed.toLowerCase() - if ( - lower === 'cursor agent' || - lower === 'cursor ready' || - lower === 'cursor - action required' - ) { - return true - } - // Why: display labels can mention Cursor in another agent's task text. Only - // treat the controlled synthetic Cursor spinner title as Cursor identity. - return /^[\u2800-\u28ff] Cursor Agent$/u.test(trimmed) -} - type RuntimePtyWorktreeRecord = { ptyId: string worktreeId: string connectionId: string | null + // Why: a Windows host can own both native and WSL panes; preamble command + // selection must follow the pane that executes it, not process.platform. + isWsl: boolean | null // Why: background CLI PTYs can outlive a failed renderer reveal. Preserve the // spawn-time tab/pane identity so later reveals can adopt under the env key. tabId: string | null @@ -1026,6 +1032,7 @@ type TerminalCreateOptions = { launchConfig?: WorktreeStartupLaunch['launchConfig'] launchToken?: string launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] telemetry?: WorktreeStartupLaunch['telemetry'] title?: string @@ -1206,10 +1213,11 @@ type RuntimePtyController = { stopAndWait?(ptyId: string, opts?: { keepHistory?: boolean }): Promise getCwd?(ptyId: string): Promise getForegroundProcess(ptyId: string): Promise + confirmForegroundProcess?(ptyId: string): Promise hasChildProcesses?(ptyId: string): Promise clearBuffer?(ptyId: string): Promise resize?(ptyId: string, cols: number, rows: number): boolean - listProcesses?(): Promise + listProcesses?(connectionId?: string | null): Promise serializeBuffer?( ptyId: string, opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } @@ -1257,6 +1265,7 @@ const BRACKETED_PASTE_END = '\x1b[201~' const BRACKETED_PASTE_QUIET_MS = 1500 const DRAFT_PASTE_READY_TIMEOUT_MS = 8000 const MOBILE_TERMINAL_SURFACE_TIMEOUT_MS = 10_000 +const GLOBAL_TERMINAL_ADMISSION_KEY = '__global_renderer_terminal_admission__' const MOBILE_TERMINAL_READY_FALLBACK_MS = 1000 const RECENT_PTY_OUTPUT_LIMIT = 64 * 1024 const RECENT_PTY_PATH_CANDIDATE_LIMIT = 1024 @@ -1323,6 +1332,7 @@ type RuntimeNotifier = { launchConfig?: SleepingAgentLaunchConfig launchToken?: string launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' activate?: boolean presentation?: RuntimeTerminalPresentation tabId?: string @@ -1482,6 +1492,7 @@ type RuntimeWorktreeRemovalTarget = { id: string repoId: string path: string + hostId?: ExecutionHostId pushTarget?: GitPushTarget } @@ -1937,6 +1948,7 @@ function labelsForIds( type TerminalWorkspaceLaunchScope = { id: string path: string + hostId: ExecutionHostId connectionId: string | null repo: Repo | null folderWorkspace: FolderWorkspace | null @@ -2015,16 +2027,34 @@ class RuntimeLineageError extends Error { } } -type ResolvedWorktreeCache = { - expiresAt: number +class WorktreeIdRequiresFullPathError extends Error { + readonly code = 'worktree_id_requires_full_path' + + constructor() { + super( + 'Worktree id selectors must use the full :: value. Use the id from `orca worktree list --json`, or target by path:, branch:, or issue:.' + ) + } +} + +type ResolvedWorktreeSnapshot = { worktrees: ResolvedWorktree[] + platformByRepoId: ReadonlyMap +} + +type ResolvedWorktreeCache = ResolvedWorktreeSnapshot & { + expiresAt: number } type ResolvedWorktreeInFlight = { generation: number - promise: Promise + promise: Promise } +// Why: notificationSeq is the desktop-assigned monotonic sequence used for +// mobile reconnect catch-up (#8129). It is added on dispatch (and replay) so a +// client can watermark the last event it delivered and request exactly the +// events after it — idempotent, no duplicate local pushes. export type MobileNotificationDispatchEvent = { type: 'notification' source: 'agent-task-complete' | 'terminal-bell' | 'test' @@ -2032,11 +2062,13 @@ export type MobileNotificationDispatchEvent = { body: string worktreeId?: string notificationId?: string + notificationSeq?: number } export type MobileNotificationDismissEvent = { type: 'dismiss' notificationId: string + notificationSeq?: number } export type MobileNotificationEvent = @@ -2108,6 +2140,43 @@ async function hasLocalWorktreeBaseRef( ) } +export type CreateManagedWorktreeArgs = { + repoSelector: string + name: string + baseBranch?: string + compareBaseRef?: string + branchNameOverride?: string + linkedIssue?: number | null + linkedPR?: number | null + linkedLinearIssue?: string + linkedLinearIssueWorkspaceId?: string | null + linkedLinearIssueOrganizationUrlKey?: string | null + linkedGitLabMR?: number | null + linkedGitLabIssue?: number | null + linkedBitbucketPR?: number | null + linkedAzureDevOpsPR?: number | null + linkedGiteaPR?: number | null + comment?: string + displayName?: string + telemetrySource?: WorkspaceCreateTelemetrySource + workspaceStatus?: string + manualOrder?: number + sparseCheckout?: { directories: string[]; presetId?: string } + pushTarget?: GitPushTarget + runHooks?: boolean + activate?: boolean + setupDecision?: 'run' | 'skip' | 'inherit' + createdWithAgent?: TuiAgent + startupAgent?: TuiAgent + startupPrompt?: string + pendingFirstAgentMessageRename?: boolean + automationProvenance?: AutomationWorkspaceProvenance + startup?: WorktreeStartupLaunch + startupDraft?: string + startupDraftPaste?: WorktreeStartupDraftPaste + lineage?: WorktreeLineageInput +} + export class OrcaRuntimeService { private readonly runtimeId = randomUUID() private readonly startedAt = Date.now() @@ -2130,7 +2199,11 @@ export class OrcaRuntimeService { // creates so ordinary renderer spawns never publish here. private pendingMobileTerminalCreatesByKey = new Map< string, - { activate: boolean; selectIfNoActiveTab: boolean } + { + activate: boolean + selectIfNoActiveTab: boolean + viewMode?: 'terminal' | 'chat' + } >() private mobileSessionTabListeners = new Set<(snapshot: RuntimeMobileSessionTabsResult) => void>() // Why: coalesces title/status-driven session.tabs emits so spinner churn @@ -2211,6 +2284,15 @@ export class OrcaRuntimeService { // iterates them all. Listeners are cleaned up via subscriptionCleanups. private notificationListeners = new Set<(event: MobileNotificationEvent) => void>() private ptysById = new Map() + // Why: repo removal must make verified stop + metadata removal atomic with + // respect to paired/mobile terminal creation, or a late spawn is orphaned. + private terminalAdmissionBlockedWorktreeIds = new Set() + private terminalRemovalGeneration = 0 + private terminalRemovalGenerationByScope = new Map() + private unresolvedTerminalAdmissionCount = 0 + private terminalAdmissionsByWorktreeId = new Map>>() + private projectRemovalBlockedRepoIds = new Set() + private worktreeCreateAdmissionsByRepoId = new Map>>() private titleObservationSequence = 0 private headlessTerminals = new Map() private ptyOutputSequenceById = new Map() @@ -2487,8 +2569,10 @@ export class OrcaRuntimeService { private readonly onPtyStopped: ((ptyId: string) => void) | null private readonly onTerminalAgentStatus: ((event: RuntimeTerminalAgentStatusEvent) => void) | null private readonly onTerminalSideEffects: ((batch: TerminalSideEffectBatch) => void) | null + private terminalSideEffectConsumerAvailable = false private readonly getAgentStatusSnapshotFn: (() => AgentStatusIpcPayload[]) | null private readonly buildAgentHookPtyEnv: (() => Record) | null + private readonly getDesktopWindowStatusFn: () => RuntimeDesktopWindowStatus private accountServices: RuntimeAccountServices | null = null private commitMessageAgentEnv: CommitMessageAgentEnvironmentResolvers | null = null private automationService: AutomationService | null = null @@ -2522,6 +2606,7 @@ export class OrcaRuntimeService { // managed-Codex sessions. The runtime ctor runs in BOTH window and serve. getAdditionalAiVaultCodexHomePaths?: () => readonly string[] buildAgentHookPtyEnv?: () => Record + getDesktopWindowStatus?: () => RuntimeDesktopWindowStatus } ) { this.store = store @@ -2548,6 +2633,7 @@ export class OrcaRuntimeService { this.onPtyStopped = deps?.onPtyStopped ?? null this.onTerminalAgentStatus = deps?.onTerminalAgentStatus ?? null this.buildAgentHookPtyEnv = deps?.buildAgentHookPtyEnv ?? null + this.getDesktopWindowStatusFn = deps?.getDesktopWindowStatus ?? (() => 'openable') this.onTerminalSideEffects = deps?.onTerminalSideEffects ?? null // Why: the ConPTY spawn mark can land after daemon stream data already // created this PTY's emulator; the mark retrofits the DA1 override here @@ -2619,6 +2705,7 @@ export class OrcaRuntimeService { | 'compactWorktreeCards' | 'minimaxGroupId' | 'minimaxUsageModels' + | 'prBotAuthorOverrides' > { if (!this.store?.getSettings) { throw new Error('runtime_unavailable') @@ -2640,7 +2727,8 @@ export class OrcaRuntimeService { experimentalNewWorktreeCardStyle: settings.experimentalNewWorktreeCardStyle === true, compactWorktreeCards: settings.compactWorktreeCards === true, minimaxGroupId: settings.minimaxGroupId ?? '', - minimaxUsageModels: settings.minimaxUsageModels ?? 'general' + minimaxUsageModels: settings.minimaxUsageModels ?? 'general', + prBotAuthorOverrides: settings.prBotAuthorOverrides ?? [] } } @@ -2662,6 +2750,7 @@ export class OrcaRuntimeService { | 'compactWorktreeCards' | 'minimaxGroupId' | 'minimaxUsageModels' + | 'prBotAuthorOverrides' > ): Pick< GlobalSettings, @@ -2681,6 +2770,7 @@ export class OrcaRuntimeService { | 'compactWorktreeCards' | 'minimaxGroupId' | 'minimaxUsageModels' + | 'prBotAuthorOverrides' > { if (!this.store?.getSettings || !this.store.updateSettings) { throw new Error('runtime_unavailable') @@ -2696,6 +2786,18 @@ export class OrcaRuntimeService { return this.getClientSettings() } + updateClientPRBotAuthorOverride(args: { author: string; isBot: boolean }) { + if (!this.store?.getSettings || !this.store.updateSettings) { + throw new Error('runtime_unavailable') + } + const current = this.store.getSettings().prBotAuthorOverrides + this.store.updateSettings( + { prBotAuthorOverrides: applyPRBotAuthorOverride(current, args.author, args.isBot) }, + { notifyListeners: true } + ) + return this.getClientSettings() + } + listAutomations(): Automation[] { if (!this.store?.listAutomations) { throw new Error('runtime_unavailable') @@ -2934,6 +3036,7 @@ export class OrcaRuntimeService { rendererGraphEpoch: this.rendererGraphEpoch, graphStatus: this.graphStatus, authoritativeWindowId: this.authoritativeWindowId, + desktopWindowStatus: hasRenderer ? 'available' : this.getDesktopWindowStatusFn(), liveTabCount: this.tabs.size, liveLeafCount: this.leaves.size, runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, @@ -3048,11 +3151,78 @@ export class OrcaRuntimeService { } attachWindow(windowId: number): void { + if (this.authoritativeWindowId === HEADLESS_RUNTIME_WINDOW_ID) { + // Why: promotion is a renderer reload of the same graph owner, not a new + // runtime; stale handles must transition before the real window publishes. + this.persistWindowlessPtyBindingsForDesktopAttach() + this.markRendererReloading(HEADLESS_RUNTIME_WINDOW_ID) + this.authoritativeWindowId = windowId + return + } if (this.authoritativeWindowId === null) { + // Why: a promoted serve can close and later reopen its window while new + // background PTYs keep arriving; every windowless gap needs this handoff. + this.persistWindowlessPtyBindingsForDesktopAttach() this.authoritativeWindowId = windowId } } + private persistWindowlessPtyBindingsForDesktopAttach(): void { + const session = this.store?.getWorkspaceSession?.() + if (!session || !this.store?.setWorkspaceSession) { + return + } + const promotablePtys = [...this.ptysById.values()].filter((pty) => { + if (!pty.connected || !pty.tabId) { + return false + } + const tab = session.tabsByWorktree[pty.worktreeId]?.find( + (candidate) => candidate.id === pty.tabId + ) + if (!tab) { + return false + } + const layoutPtyIds = Object.values( + session.terminalLayoutsByTabId[pty.tabId]?.ptyIdsByLeafId ?? {} + ) + return tab.ptyId === pty.ptyId || layoutPtyIds.includes(pty.ptyId) + }) + if (promotablePtys.length === 0) { + return + } + + // Why: renderer hydration treats an explicitly-present shutdown list as + // authoritative. A windowless owner has no renderer shutdown pass, so seed + // that existing reattach contract before its next desktop window loads. + const activeWorktreeIdsOnShutdown = [ + ...new Set([ + ...(session.activeWorktreeIdsOnShutdown ?? []), + ...promotablePtys.map((pty) => pty.worktreeId) + ]) + ] + const activeConnectionIdsAtShutdown = [ + ...new Set([ + ...(session.activeConnectionIdsAtShutdown ?? []), + ...promotablePtys + .map((pty) => pty.connectionId) + .filter((connectionId): connectionId is string => connectionId !== null) + ]) + ] + const remoteSessionIdsByTabId = { ...session.remoteSessionIdsByTabId } + for (const pty of promotablePtys) { + if (pty.connectionId && pty.tabId) { + remoteSessionIdsByTabId[pty.tabId] = pty.ptyId + } + } + + this.store.setWorkspaceSession({ + ...session, + activeWorktreeIdsOnShutdown, + ...(activeConnectionIdsAtShutdown.length > 0 ? { activeConnectionIdsAtShutdown } : {}), + ...(Object.keys(remoteSessionIdsByTabId).length > 0 ? { remoteSessionIdsByTabId } : {}) + }) + } + syncWindowGraph(windowId: number, graph: RuntimeSyncWindowGraph): RuntimeSyncWindowGraphResult { if (this.authoritativeWindowId === null) { this.authoritativeWindowId = windowId @@ -3177,6 +3347,7 @@ export class OrcaRuntimeService { this.rebuildLeafPtyIndex() this.notifyMobileSessionTabSnapshots() this.graphStatus = 'ready' + this.setTerminalSideEffectConsumerAvailable(windowId !== HEADLESS_RUNTIME_WINDOW_ID) this.refreshWritableFlags() for (const leaf of this.leaves.values()) { this.adoptPreAllocatedHandle(leaf) @@ -3196,7 +3367,7 @@ export class OrcaRuntimeService { } async listMobileSessionTabs(worktreeSelector: string): Promise { - const explicitWorktreeId = getExplicitWorktreeIdSelector(worktreeSelector) + const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) if (explicitWorktreeId) { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(explicitWorktreeId) await this.refreshMobileSessionPtyRecords() @@ -3638,6 +3809,7 @@ export class OrcaRuntimeService { activate: boolean selectIfNoActiveTab?: boolean startupCwd?: string + viewMode?: 'terminal' | 'chat' split?: { splitFromLeafId: string; direction: 'horizontal' | 'vertical' } } ): void { @@ -3669,6 +3841,17 @@ export class OrcaRuntimeService { baseLayout, args.split ) + // Why: a main-side PTY rescue or split publication must not erase the + // host's explicit tab mode before the renderer graph catches up. + const viewMode = + args.viewMode ?? + existingTab?.viewMode ?? + existing?.tabs.find( + (candidate): candidate is RuntimeMobileSessionTerminalTab => + candidate.type === 'terminal' && + candidate.parentTabId === args.tabId && + candidate.viewMode !== undefined + )?.viewMode const tab: RuntimeMobileSessionTerminalTab = { type: 'terminal', id: `${args.tabId}::${args.leafId}`, @@ -3678,6 +3861,7 @@ export class OrcaRuntimeService { title, ...(pty.launchAgent ? { launchAgent: pty.launchAgent } : {}), ...(args.startupCwd ? { startupCwd: args.startupCwd } : {}), + ...(viewMode ? { viewMode } : {}), parentLayout, isActive: args.activate || (args.selectIfNoActiveTab !== false && existing?.activeTabId == null) @@ -4180,7 +4364,7 @@ export class OrcaRuntimeService { leafId?: string, opts: { notifyClients?: boolean } = {} ): Promise { - const explicitWorktreeId = getExplicitWorktreeIdSelector(worktreeSelector) + const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) const worktreeId = explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) @@ -4456,7 +4640,7 @@ export class OrcaRuntimeService { } async closeMobileSessionTab(worktreeSelector: string, tabId: string): Promise<{ closed: true }> { - const explicitWorktreeId = getExplicitWorktreeIdSelector(worktreeSelector) + const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) const worktreeId = explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) @@ -4475,7 +4659,9 @@ export class OrcaRuntimeService { } if (tab.type === 'terminal') { if (!this.notifier?.closeTerminal) { - this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab) + if (!this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab)) { + throw new Error('terminal_close_not_retained') + } return { closed: true } } // Why: a runtime-owned headless tab whose whole parent is being closed must @@ -4489,14 +4675,18 @@ export class OrcaRuntimeService { ).length const closingWholeParent = tab.id !== tabId || parentLeafCount <= 1 if (closingWholeParent && this.isRuntimeOwnedHeadlessMobileTab(worktreeId, tab)) { - this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab) + if (!this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab)) { + throw new Error('terminal_close_not_retained') + } this.notifier?.closeTerminal(tab.parentTabId) return { closed: true } } if (tab.id === tabId) { const pty = this.findPtyForMobileTerminalTab(worktreeId, tab) if (pty) { - this.ptyController?.kill(pty.ptyId) + if (this.ptyController?.kill(pty.ptyId) !== true) { + throw new Error('terminal_close_not_retained') + } } else { this.notifier?.closeTerminal(tab.parentTabId) } @@ -4608,25 +4798,28 @@ export class OrcaRuntimeService { worktreeId: string, snapshot: RuntimeMobileSessionTabsSnapshot, tab: RuntimeMobileSessionTerminalTab - ): void { + ): boolean { const closedParentTabId = tab.parentTabId - const nextTabs = snapshot.tabs.filter((candidate) => { - if (candidate.type !== 'terminal' || candidate.parentTabId !== closedParentTabId) { - return true - } + const closingTabs = snapshot.tabs.filter( + (candidate): candidate is RuntimeMobileSessionTerminalTab => + candidate.type === 'terminal' && candidate.parentTabId === closedParentTabId + ) + for (const candidate of closingTabs) { const pty = this.findPtyForMobileTerminalTab(worktreeId, candidate) - if (pty?.connected) { - this.ptyController?.kill(pty.ptyId) - } else { - const persistedSshPtyId = this.getPersistedSshPtyIdForMobileTerminalTab(candidate) - if (persistedSshPtyId) { - // Why: close is an explicit deletion. Hydrated SSH PTYs can be known - // only by durable id before reconnect repopulates pane metadata. - this.ptyController?.kill(persistedSshPtyId) - } + // Why: close is an explicit deletion. Hydrated SSH PTYs can be known + // only by durable id before reconnect repopulates pane metadata. + const ptyId = pty?.connected + ? pty.ptyId + : this.getPersistedSshPtyIdForMobileTerminalTab(candidate) + // Why: depersisting is safe after durable owner acceptance, not after a + // best-effort provider call that can fail after this method returns. + if (ptyId && this.ptyController?.kill(ptyId) !== true) { + return false } - return false - }) + } + const nextTabs = snapshot.tabs.filter( + (candidate) => candidate.type !== 'terminal' || candidate.parentTabId !== closedParentTabId + ) this.removePersistedHeadlessTerminalTab(worktreeId, closedParentTabId) const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { @@ -4645,13 +4838,14 @@ export class OrcaRuntimeService { } this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) this.emitMobileSessionTabsSnapshot(nextSnapshot) + return true } async moveMobileSessionTab( worktreeSelector: string, move: RuntimeMobileSessionTabMove ): Promise { - const explicitWorktreeId = getExplicitWorktreeIdSelector(worktreeSelector) + const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) const worktreeId = explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) @@ -4707,7 +4901,7 @@ export class OrcaRuntimeService { titlesByLeafId?: Record } ): Promise<{ updated: true }> { - const explicitWorktreeId = getExplicitWorktreeIdSelector(worktreeSelector) + const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) const worktreeId = explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id // Why: when a renderer is authoritative (desktop host reached via shared @@ -4740,7 +4934,7 @@ export class OrcaRuntimeService { viewMode?: 'terminal' | 'chat' } ): Promise<{ updated: true }> { - const explicitWorktreeId = getExplicitWorktreeIdSelector(worktreeSelector) + const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) const worktreeId = explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id // Why: a renderer-authoritative host owns + republishes tab props, so a @@ -5509,7 +5703,8 @@ export class OrcaRuntimeService { ptyId: string, worktreeId: string, connectionId: string | null = null, - binding?: { tabId: string; leafId: string } + binding?: { tabId: string; leafId: string }, + isWsl?: boolean ): void { // Why: record the renderer pane identity at spawn time so a stalled graph // sync can't hide that a live PTY already backs a pending mobile create. @@ -5520,6 +5715,7 @@ export class OrcaRuntimeService { this.recordPtyWorktree(ptyId, worktreeId, { connected: true, connectionId, + ...(isWsl !== undefined ? { isWsl } : {}), ...(binding && paneKey ? { tabId: binding.tabId, paneKey } : {}) }) // Why: the renderer's own PTY spawn is the reliable signal that the pending @@ -5939,7 +6135,7 @@ export class OrcaRuntimeService { /** Record one derived side-effect fact: batched per chunk while applying * bytes, emitted immediately for between-chunk facts (stale-title timer). */ private recordTerminalSideEffectFact(ptyId: string, fact: TerminalSideEffectFact): void { - if (!this.onTerminalSideEffects) { + if (!this.onTerminalSideEffects || !this.terminalSideEffectConsumerAvailable) { return } const entry = this.ptyTitleTrackersByPtyId.get(ptyId) @@ -5955,7 +6151,11 @@ export class OrcaRuntimeService { facts: TerminalSideEffectFact[], options: { replay?: boolean } = {} ): void { - if (!this.onTerminalSideEffects || facts.length === 0) { + if ( + !this.onTerminalSideEffects || + !this.terminalSideEffectConsumerAvailable || + facts.length === 0 + ) { return } const batch: TerminalSideEffectBatch = { @@ -6134,7 +6334,7 @@ export class OrcaRuntimeService { // Why: bell/command-finished/pr-link/2031 facts exist only for the // pty:sideEffect channel. Headless serve has no consumer, so skip the // per-chunk bell walk and 133/URL/2031 scans entirely. - ...(this.onTerminalSideEffects + ...(this.terminalSideEffectConsumerAvailable ? { onBell: () => { this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) @@ -6167,7 +6367,7 @@ export class OrcaRuntimeService { // headless serve skips the per-chunk scrape entirely. The detector // self-arms on the Command Code banner; the spawn command (when main // saw one) mirrors the renderer detector's startupCommand fast-arm. - commandCodeDetector: this.onTerminalSideEffects + commandCodeDetector: this.terminalSideEffectConsumerAvailable ? createCommandCodeOutputStatusDetector({ startupCommand: this.terminalSpawnCommandsByPtyId.get(ptyId) ?? null, onWorking: (prompt) => { @@ -6266,6 +6466,19 @@ export class OrcaRuntimeService { this.ptyTitleTrackersByPtyId.delete(ptyId) } + private setTerminalSideEffectConsumerAvailable(available: boolean): void { + const nextAvailable = available && this.onTerminalSideEffects !== null + if (nextAvailable === this.terminalSideEffectConsumerAvailable) { + return + } + this.terminalSideEffectConsumerAvailable = nextAvailable + // Why: optional bell/command/link scanners are selected when a tracker is + // created. Rebuild at the window boundary so pure headless output stays cheap. + for (const ptyId of [...this.ptyTitleTrackersByPtyId.keys()]) { + this.disposePtyTitleTracker(ptyId) + } + } + private extractLastOsc7CwdForPty( ptyId: string, data: string @@ -7214,6 +7427,27 @@ export class OrcaRuntimeService { return pty ? { worktreeId: pty.worktreeId, connectionId: pty.connectionId } : null } + getTerminalOrchestrationCliCommand(handle: string): 'orca' | 'orca-ide' { + let pty: RuntimePtyWorktreeRecord | null = null + try { + const ptyId = this.resolveLeafForHandle(handle)?.ptyId + pty = ptyId ? (this.ptysById.get(ptyId) ?? null) : null + } catch { + return 'orca' + } + if (!pty) { + return 'orca' + } + return resolveTerminalOrchestrationCliCommand({ + connectionId: pty.connectionId, + isWsl: pty.isWsl, + worktreeId: pty.worktreeId, + projectRuntime: this.store + ? resolveLocalProjectRuntimeForWorktreeId(this.requireStore(), pty.worktreeId) + : undefined + }) + } + hasRecentTerminalOutputPath(handle: string, pathText: string, absolutePath: string): boolean { const ptyId = this.resolveLeafForHandle(handle)?.ptyId const recentOutput = ptyId ? this.recentPtyOutputById.get(ptyId) : null @@ -7308,12 +7542,30 @@ export class OrcaRuntimeService { return this.notificationListeners.size } + // Why: bounded replay buffer for the mobile reconnect catch-up (#8129). + // Every dispatched notification is recorded with a monotonic seq so a + // reconnecting client can fetch exactly the events it missed. Kept on the + // service instance (not per-client) because the buffer is a global, + // idempotent-by-seq source of truth; clients watermark their own position. + private readonly mobileNotificationReplay = new MobileNotificationReplayBuffer() + dispatchMobileNotification(event: MobileNotificationEvent): void { + const seq = this.mobileNotificationReplay.record(event) for (const listener of this.notificationListeners) { - listener(event) + // Why: surface the desktop-assigned seq to live listeners so they can + // watermark the last event delivered and feed it back to getMissedSince + // on reconnect (idempotent catch-up, no duplicate local pushes). + listener({ ...event, notificationSeq: seq }) } } + // Returns notifications dispatched after lastSeenSeq. Idempotent: the same + // watermark always yields the same set, so a client cannot be re-pushed an + // already-delivered event (the adversarial-review gate for #8129). + getMissedNotificationsSince(lastSeenSeq: number): ReplayableMobileNotification[] { + return this.mobileNotificationReplay.getMissedSince(lastSeenSeq) + } + dismissMobileNotification(notificationId: string): void { this.dispatchMobileNotification({ type: 'dismiss', notificationId }) } @@ -9692,7 +9944,7 @@ export class OrcaRuntimeService { } const graphEpoch = this.graphStatus === 'ready' ? this.rendererGraphEpoch : null const explicitTargetWorktreeId = worktreeSelector - ? getExplicitWorktreeIdSelector(worktreeSelector) + ? this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) : null const initialResolvedWorktreeCache = this.resolvedWorktreeCache const cachedResolvedWorktrees = @@ -10081,6 +10333,13 @@ export class OrcaRuntimeService { throw new Error('no_active_terminal') } + // Why: orchestration records the pane key as the remint-stable assignee + // identity at dispatch time; null (best-effort) rather than throwing so + // dispatch still works for handles without a resolvable pane. + getTerminalPaneKey(handle: string): string | null { + return this.getPaneKeyForTerminalHandle(handle) + } + resolveTerminalPane(paneKey: string): RuntimeTerminalResolvePane { // Why: the renderer context menu only knows the stable pane key; main owns // the runtime terminal handle that agents and CLI commands can address. @@ -10227,7 +10486,8 @@ export class OrcaRuntimeService { } async getTerminalAgentStatus(handle: string): Promise { - const terminal = this.getTerminalAgentStatusSnapshot(handle) + const ptyId = this.getTerminalAgentStatusPtyId(handle) + const terminal = this.getTerminalAgentStatusSnapshot(handle, ptyId) const explicitStatus = this.getFreshExplicitAgentStatusForHandle(handle) const blockedByWaitText = detectTerminalWaitBlockedReason(terminal.waitText) const liveTitleClearsBlockedText = @@ -10251,7 +10511,8 @@ export class OrcaRuntimeService { // Fresh hook state is tighter, but current shell/management evidence wins. const isRunningAgent = !terminalTitleBlocksExplicitAgentStatus(terminal.title) && - !(await this.terminalHasShellForegroundProcess(handle)) + !(await this.terminalHasShellForegroundProcess(handle, ptyId)) + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) return { handle, isRunningAgent, @@ -10262,14 +10523,42 @@ export class OrcaRuntimeService { return { handle, isRunningAgent: true, status: terminal.titleStatus } } - return { - handle, - isRunningAgent: await this.isTerminalRunningAgent(handle), - status: null - } + const isRunningAgent = await this.isTerminalRunningAgent(handle) + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) + return { handle, isRunningAgent, status: null } } - private getTerminalAgentStatusSnapshot(handle: string): { + private getTerminalAgentStatusPtyId(handle: string): string { + const pty = this.getLivePtyForHandle(handle) + if (pty) { + if (!pty.pty.connected) { + throw new Error('terminal_gone') + } + return pty.pty.ptyId + } + const { leaf } = this.getLiveLeafForHandle(handle) + if (getTerminalState(leaf) !== 'running') { + throw new Error('terminal_exited') + } + if (!leaf.ptyId) { + throw new Error('terminal_gone') + } + return leaf.ptyId + } + + private assertTerminalAgentStatusPtyBinding(handle: string, expectedPtyId: string): void { + if (this.getTerminalAgentStatusPtyId(handle) === expectedPtyId) { + return + } + // Why: delayed process evidence belongs only to the PTY that started the + // read, while callers still rely on the established stale-handle contract. + throw new Error('terminal_handle_stale') + } + + private getTerminalAgentStatusSnapshot( + handle: string, + expectedPtyId: string + ): { waitText: string waitBlockedAt: number | null title: string | null @@ -10278,8 +10567,8 @@ export class OrcaRuntimeService { } { const pty = this.getLivePtyForHandle(handle) if (pty) { - if (!pty.pty.connected) { - throw new Error('terminal_gone') + if (!pty.pty.connected || pty.pty.ptyId !== expectedPtyId) { + throw new Error('terminal_not_writable') } const leaf = this.getPrimaryLeafForPty(pty.pty.ptyId) const leafTitle = leaf @@ -10317,6 +10606,9 @@ export class OrcaRuntimeService { if (!leaf.ptyId) { throw new Error('terminal_gone') } + if (leaf.ptyId !== expectedPtyId) { + throw new Error('terminal_not_writable') + } const title = getLatestAgentCandidateTitleInfo( { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt }, @@ -10331,21 +10623,36 @@ export class OrcaRuntimeService { } } - private async terminalHasShellForegroundProcess(handle: string): Promise { + private async terminalHasShellForegroundProcess(handle: string, ptyId: string): Promise { if (!this.ptyController) { return false } + let foregroundProcess: string | null try { - const pty = this.getLivePtyForHandle(handle) - const ptyId = pty?.pty.ptyId ?? this.getLiveLeafForHandle(handle).leaf.ptyId - if (!ptyId) { - return false - } - const foregroundProcess = await this.ptyController.getForegroundProcess(ptyId) - return foregroundProcess !== null && isShellProcess(foregroundProcess) + foregroundProcess = await this.ptyController.getForegroundProcess(ptyId) } catch { + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) return false } + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) + if (!foregroundProcess || !isShellProcess(foregroundProcess)) { + return false + } + const confirmationController = this.ptyController + if (!confirmationController?.confirmForegroundProcess) { + return true + } + let confirmedProcess: string | null + try { + confirmedProcess = await confirmationController.confirmForegroundProcess(ptyId) + } catch { + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) + return true + } + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) + // Why: hook identity is generic; strong provider evidence only needs to + // prove that some recognized agent still owns this exact PTY. + return recognizeAgentProcess(confirmedProcess) === null } private shouldDelayPtyBackedMobileSnapshotForForegroundAgent( @@ -10852,13 +11159,15 @@ export class OrcaRuntimeService { if (!Number.isInteger(limit) || limit <= 0) { throw new Error('invalid_limit') } - const resolvedWorktrees = (await this.listResolvedWorktrees()).filter((worktree) => + const resolvedWorktreeSnapshot = await this.listResolvedWorktreeSnapshot() + const resolvedWorktrees = resolvedWorktreeSnapshot.worktrees.filter((worktree) => this.isRuntimeWorktreeVisible(worktree) ) // Why: worktree.ps backs the mobile sidebar, so it must use the same // host-owned imported-worktree visibility gate as worktree.list/desktop. await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees) const repoById = new Map((this.store?.getRepos() ?? []).map((repo) => [repo.id, repo])) + const platformByRepoId = resolvedWorktreeSnapshot.platformByRepoId const summaries = new Map() // Why: the GitHub cache is keyed by `repoPath::branch` (no refs/heads/ prefix), @@ -10887,7 +11196,7 @@ export class OrcaRuntimeService { if (!linkedPR && meta?.linkedPR != null) { linkedPR = { number: meta.linkedPR, state: 'unknown' } } - const terminalPlatform = repo ? this.getAgentLaunchPlatformForRepo(repo) : process.platform + const terminalPlatform = platformByRepoId.get(worktree.repoId) ?? process.platform // Why: use the instance-validated lineage from attachLineageToResolvedWorktrees, // not the raw store entry — shipped mobile clients trust parentWorktreeId as-is, // so a stale same-path entry would nest replacement checkouts under old parents. @@ -10987,11 +11296,18 @@ export class OrcaRuntimeService { }) } + const runtimeWorktreeSummaryPathIndex = buildRuntimeWorktreeSummaryPathIndex( + summaries, + resolvedWorktrees, + platformByRepoId + ) + const missingRuntimeWorktreeIds = new Set() const countedPtyIds = new Set() for (const leaf of this.leaves.values()) { const summary = this.getSummaryForRuntimeWorktreeId( summaries, - resolvedWorktrees, + runtimeWorktreeSummaryPathIndex, + missingRuntimeWorktreeIds, leaf.worktreeId ) if (!summary) { @@ -11025,7 +11341,8 @@ export class OrcaRuntimeService { } const summary = this.getSummaryForRuntimeWorktreeId( summaries, - resolvedWorktrees, + runtimeWorktreeSummaryPathIndex, + missingRuntimeWorktreeIds, pty.worktreeId ) if (!summary) { @@ -11049,7 +11366,12 @@ export class OrcaRuntimeService { if (tabs.length === 0) { continue } - const summary = this.getSummaryForRuntimeWorktreeId(summaries, resolvedWorktrees, worktreeId) + const summary = this.getSummaryForRuntimeWorktreeId( + summaries, + runtimeWorktreeSummaryPathIndex, + missingRuntimeWorktreeIds, + worktreeId + ) if (!summary) { continue } @@ -11075,7 +11397,8 @@ export class OrcaRuntimeService { if (session?.activeWorktreeId) { const activeSummary = this.getSummaryForRuntimeWorktreeId( summaries, - resolvedWorktrees, + runtimeWorktreeSummaryPathIndex, + missingRuntimeWorktreeIds, session.activeWorktreeId ) if (activeSummary) { @@ -11083,7 +11406,26 @@ export class OrcaRuntimeService { } } - this.attachAgentRowsToSummaries(summaries) + const mirroredWorktreeIdByTabId = new Map() + for (const [worktreeId, tabs] of Object.entries(session?.tabsByWorktree ?? {})) { + for (const tab of tabs) { + mirroredWorktreeIdByTabId.set(tab.id, worktreeId) + } + } + // Why: a live renderer graph may precede persistence, but persisted tab + // ownership wins when an automatic workspace rename has already rekeyed it. + for (const [tabId, tab] of this.tabs) { + if (!mirroredWorktreeIdByTabId.has(tabId)) { + mirroredWorktreeIdByTabId.set(tabId, tab.worktreeId) + } + } + + this.attachAgentRowsToSummaries( + summaries, + runtimeWorktreeSummaryPathIndex, + missingRuntimeWorktreeIds, + mirroredWorktreeIdByTabId + ) const sorted = [...summaries.values()].sort(compareWorktreePs) return { @@ -11097,7 +11439,12 @@ export class OrcaRuntimeService { // agent list, mirroring the desktop sidebar. Lineage parent is resolved from // the orchestration db (paneKey-keyed), not the OSC payload, since spawn // hierarchy is pane-level state tracked separately from terminal output. - private attachAgentRowsToSummaries(summaries: Map): void { + private attachAgentRowsToSummaries( + summaries: Map, + runtimeWorktreeSummaryPathIndex: RuntimeWorktreeSummaryPathIndex, + missingRuntimeWorktreeIds: Set, + mirroredWorktreeIdByTabId: ReadonlyMap + ): 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. @@ -11105,6 +11452,7 @@ export class OrcaRuntimeService { string, { paneKey: string + tabId?: string worktreeId?: string state: ParsedAgentStatusPayload['state'] agentType: string | null @@ -11121,6 +11469,7 @@ export class OrcaRuntimeService { const { payload } = snapshot rowSources.set(snapshot.paneKey, { paneKey: snapshot.paneKey, + tabId: snapshot.tabId, worktreeId: snapshot.worktreeId, state: payload.state, agentType: payload.agentType ?? null, @@ -11134,8 +11483,15 @@ export class OrcaRuntimeService { }) } for (const entry of this.getAgentStatusSnapshotFn?.() ?? []) { + const existing = rowSources.get(entry.paneKey) + // 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) { + continue + } rowSources.set(entry.paneKey, { paneKey: entry.paneKey, + tabId: entry.tabId, worktreeId: entry.worktreeId, state: entry.state, agentType: entry.agentType ?? null, @@ -11153,9 +11509,23 @@ export class OrcaRuntimeService { } const orchestrationByPaneKey = this.buildAgentOrchestrationByPaneKey() const rowsByWorktree = new Map() + const now = Date.now() for (const src of rowSources.values()) { - const worktreeId = src.worktreeId - if (!worktreeId || !summaries.has(worktreeId)) { + // Why: hooks retain launch-time attribution across automatic workspace + // renames; the tab's current mirrored owner is authoritative when present. + const tabId = src.tabId ?? parsePaneKey(src.paneKey)?.tabId + const worktreeId = + (tabId ? mirroredWorktreeIdByTabId.get(tabId) : undefined) ?? src.worktreeId + if (!worktreeId) { + continue + } + const summary = this.getSummaryForRuntimeWorktreeId( + summaries, + runtimeWorktreeSummaryPathIndex, + missingRuntimeWorktreeIds, + worktreeId + ) + if (!summary) { continue } const taskTitle = orchestrationByPaneKey?.[src.paneKey]?.taskTitle ?? null @@ -11175,11 +11545,13 @@ export class OrcaRuntimeService { stateStartedAt: src.stateStartedAt, updatedAt: src.updatedAt } - const rows = rowsByWorktree.get(worktreeId) + // Why: SSH/runtime projections can spell an equivalent path differently; + // bucket by the canonical summary id so mobile keeps the agent activity. + const rows = rowsByWorktree.get(summary.worktreeId) if (rows) { rows.push(row) } else { - rowsByWorktree.set(worktreeId, [row]) + rowsByWorktree.set(summary.worktreeId, [row]) } } for (const [worktreeId, rows] of rowsByWorktree) { @@ -11188,6 +11560,18 @@ export class OrcaRuntimeService { const summary = summaries.get(worktreeId) if (summary) { summary.agents = rows + for (const row of rows) { + if (!isFreshNonDoneAgentStatus(row, now)) { + continue + } + // 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' + ) + } } } } @@ -12113,16 +12497,394 @@ export class OrcaRuntimeService { return updated } + private admissionScopeKey(id: string, hostId?: ExecutionHostId | null): string { + return hostId ? `${hostId}\0${id}` : id + } + + private connectionIdForExecutionHost(hostId?: ExecutionHostId | null): string | null | undefined { + const parsed = parseExecutionHostId(hostId) + if (!parsed || parsed.kind === 'runtime') { + return undefined + } + return parsed.kind === 'ssh' ? parsed.targetId : null + } + + private executionHostForWorktree(worktree: Pick): ExecutionHostId { + const explicitHostId = normalizeExecutionHostId(worktree.hostId) + if (explicitHostId) { + return explicitHostId + } + const repoMatches = this.store?.getRepos().filter((repo) => repo.id === worktree.repoId) ?? [] + if (repoMatches.length === 1) { + return getRepoExecutionHostId(this.store?.getRepo(worktree.repoId) ?? repoMatches[0]) + } + return LOCAL_EXECUTION_HOST_ID + } + + private isAdmissionBlocked( + blocked: ReadonlySet, + id: string, + hostId?: ExecutionHostId | null + ): boolean { + if (blocked.has(id)) { + return true + } + if (hostId) { + return blocked.has(this.admissionScopeKey(id, hostId)) + } + const suffix = `\0${id}` + return [...blocked].some((key) => key.endsWith(suffix)) + } + + private admissionPromisesForScope( + admissionsByScope: ReadonlyMap>>, + id: string, + hostId?: ExecutionHostId | null, + allHosts = false + ): Promise[] { + const pending = new Set(admissionsByScope.get(id) ?? []) + if (hostId) { + for (const admission of admissionsByScope.get(this.admissionScopeKey(id, hostId)) ?? []) { + pending.add(admission) + } + } else if (allHosts) { + const suffix = `\0${id}` + for (const [key, admissions] of admissionsByScope) { + if (key.endsWith(suffix)) { + for (const admission of admissions) { + pending.add(admission) + } + } + } + } + return [...pending] + } + + private beginTerminalAdmission(worktreeId: string, hostId?: ExecutionHostId | null): () => void { + if ( + worktreeId === GLOBAL_TERMINAL_ADMISSION_KEY + ? this.terminalAdmissionBlockedWorktreeIds.size > 0 + : this.isAdmissionBlocked(this.terminalAdmissionBlockedWorktreeIds, worktreeId, hostId) + ) { + throw new Error('terminal_admission_blocked_for_project_removal') + } + const scopeKey = this.admissionScopeKey(worktreeId, hostId) + let resolveDone = (): void => {} + const done = new Promise((resolve) => { + resolveDone = resolve + }) + const admissions = this.terminalAdmissionsByWorktreeId.get(scopeKey) ?? new Set() + admissions.add(done) + this.terminalAdmissionsByWorktreeId.set(scopeKey, admissions) + let released = false + return () => { + if (released) { + return + } + released = true + admissions.delete(done) + if (admissions.size === 0) { + this.terminalAdmissionsByWorktreeId.delete(scopeKey) + } + resolveDone() + } + } + + private blockTerminalAdmissionScope(scopeKey: string): void { + this.terminalRemovalGeneration += 1 + // Why: unresolved selectors only need short-lived history while they await + // host discovery; clearing it at zero avoids retaining removed worktree IDs. + if (this.unresolvedTerminalAdmissionCount > 0) { + this.terminalRemovalGenerationByScope.set(scopeKey, this.terminalRemovalGeneration) + } + this.terminalAdmissionBlockedWorktreeIds.add(scopeKey) + } + + private terminalRemovalGenerationForScope(worktreeId: string, hostId: ExecutionHostId): number { + return Math.max( + this.terminalRemovalGenerationByScope.get(worktreeId) ?? 0, + this.terminalRemovalGenerationByScope.get(this.admissionScopeKey(worktreeId, hostId)) ?? 0 + ) + } + + private async drainTerminalAdmissions( + worktreeIds: readonly string[], + hostId?: ExecutionHostId | null, + allHosts = false + ): Promise { + await Promise.all( + worktreeIds.flatMap((id) => + this.admissionPromisesForScope(this.terminalAdmissionsByWorktreeId, id, hostId, allHosts) + ) + ) + } + + private async resolveTerminalCreateWorkspaceWithAdmission( + selector: string, + hostId?: ExecutionHostId | null + ): Promise<{ workspace: TerminalWorkspaceLaunchScope; release: () => void }> { + // Why: workspace resolution can await SSH/runtime discovery. Keep removal + // aware of the unresolved create, then transfer ownership without a gap. + let releaseResolutionAdmission: (() => void) | null = null + let removalsActiveAtResolutionStart: ReadonlySet | null = null + let removalGenerationAtResolutionStart: number | null = null + let tracksRemovalGeneration = false + try { + releaseResolutionAdmission = this.beginTerminalAdmission(GLOBAL_TERMINAL_ADMISSION_KEY) + } catch (error) { + if ( + !(error instanceof Error) || + error.message !== 'terminal_admission_blocked_for_project_removal' + ) { + throw error + } + // Why: an unresolved selector might belong to another execution host. + // Resolve it, but remember the active removals so a fast completion + // cannot reopen the exact stale scope before the atomic transfer. + removalsActiveAtResolutionStart = new Set(this.terminalAdmissionBlockedWorktreeIds) + removalGenerationAtResolutionStart = this.terminalRemovalGeneration + this.unresolvedTerminalAdmissionCount += 1 + tracksRemovalGeneration = true + } + try { + const workspace = await this.resolveTerminalWorkspaceLaunchScope(selector, hostId) + if ( + removalsActiveAtResolutionStart && + ((removalGenerationAtResolutionStart ?? 0) < + this.terminalRemovalGenerationForScope(workspace.id, workspace.hostId) || + this.isAdmissionBlocked(removalsActiveAtResolutionStart, workspace.id, workspace.hostId)) + ) { + throw new Error('terminal_admission_blocked_for_project_removal') + } + const releaseWorkspaceAdmission = this.beginTerminalAdmission(workspace.id, workspace.hostId) + releaseResolutionAdmission?.() + return { workspace, release: releaseWorkspaceAdmission } + } catch (error) { + releaseResolutionAdmission?.() + throw error + } finally { + if (tracksRemovalGeneration) { + this.unresolvedTerminalAdmissionCount -= 1 + if (this.unresolvedTerminalAdmissionCount === 0) { + this.terminalRemovalGenerationByScope.clear() + } + } + } + } + + private async withTerminalAdmission( + worktreeId: string, + action: () => Promise, + hostId?: ExecutionHostId | null + ): Promise { + const release = this.beginTerminalAdmission(worktreeId, hostId) + try { + return await action() + } finally { + release() + } + } + + async runWithTerminalCreateAdmission( + worktreeId: string | undefined, + action: () => Promise, + hostId?: ExecutionHostId | null + ): Promise { + return worktreeId ? this.withTerminalAdmission(worktreeId, action, hostId) : action() + } + + private beginWorktreeCreateAdmission( + repoId: string, + hostId?: ExecutionHostId | null + ): () => void { + if (this.isAdmissionBlocked(this.projectRemovalBlockedRepoIds, repoId, hostId)) { + throw new Error('project_removal_in_progress') + } + const scopeKey = this.admissionScopeKey(repoId, hostId) + let resolveDone = (): void => {} + const done = new Promise((resolve) => { + resolveDone = resolve + }) + const admissions = this.worktreeCreateAdmissionsByRepoId.get(scopeKey) ?? new Set() + admissions.add(done) + this.worktreeCreateAdmissionsByRepoId.set(scopeKey, admissions) + let released = false + return () => { + if (released) { + return + } + released = true + admissions.delete(done) + if (admissions.size === 0) { + this.worktreeCreateAdmissionsByRepoId.delete(scopeKey) + } + resolveDone() + } + } + + async runWithWorktreeCreateAdmission( + repoId: string, + action: () => Promise, + hostId?: ExecutionHostId | null + ): Promise { + const release = this.beginWorktreeCreateAdmission(repoId, hostId) + try { + return await action() + } finally { + release() + } + } + + private async drainWorktreeCreateAdmissions( + repoId: string, + hostId?: ExecutionHostId | null, + allHosts = false + ): Promise { + await Promise.all( + this.admissionPromisesForScope( + this.worktreeCreateAdmissionsByRepoId, + repoId, + hostId, + allHosts + ) + ) + } + + async runWithWorktreeRemovalAdmission( + worktreeId: string, + action: () => Promise, + hostId?: ExecutionHostId | null + ): Promise { + if (this.isAdmissionBlocked(this.terminalAdmissionBlockedWorktreeIds, worktreeId, hostId)) { + throw new Error('worktree_removal_in_progress') + } + const scopeKey = this.admissionScopeKey(worktreeId, hostId) + this.blockTerminalAdmissionScope(scopeKey) + try { + await Promise.all([ + this.drainTerminalAdmissions([GLOBAL_TERMINAL_ADMISSION_KEY]), + this.drainTerminalAdmissions([worktreeId], hostId) + ]) + return await action() + } finally { + this.terminalAdmissionBlockedWorktreeIds.delete(scopeKey) + } + } + + async verifyTerminalsStoppedForRemoval( + worktreeId: string, + hostId?: ExecutionHostId | null + ): Promise { + const connectionId = this.connectionIdForExecutionHost(hostId) + if (!this.ptyController) { + if (this.getLivePtyIdsForWorktree(worktreeId, undefined, connectionId).size > 0) { + throw new Error('terminal_verified_stop_unavailable') + } + return + } + await this.stopTerminalsForWorktree(`id:${worktreeId}`, { + allowUnavailableGraph: true, + hostId + }) + } + + private async removeProjectWithHostScope( + repo: Repo, + hostId: ExecutionHostId | null, + removePersistedProject: () => void + ): Promise<{ removed: true }> { + if (!this.store) { + throw new Error('runtime_unavailable') + } + if (this.isAdmissionBlocked(this.projectRemovalBlockedRepoIds, repo.id, hostId)) { + throw new Error('project_removal_in_progress') + } + const projectScopeKey = this.admissionScopeKey(repo.id, hostId) + this.projectRemovalBlockedRepoIds.add(projectScopeKey) + let worktreeScopes: { id: string; hostId: ExecutionHostId }[] = [] + try { + // Why: a create that already resolved this repo can publish a new + // worktree and terminal after a one-time removal snapshot. Block later + // creates and let earlier ones finish before enumerating owned worktrees. + await this.drainWorktreeCreateAdmissions(repo.id, hostId, hostId === null) + worktreeScopes = (await this.listResolvedWorktrees()) + .filter( + (worktree) => + worktree.repoId === repo.id && + (hostId === null || this.executionHostForWorktree(worktree) === hostId) + ) + .map((worktree) => ({ + id: worktree.id, + hostId: this.executionHostForWorktree(worktree) + })) + if ( + worktreeScopes.some((scope) => + this.isAdmissionBlocked( + this.terminalAdmissionBlockedWorktreeIds, + scope.id, + hostId === null ? null : scope.hostId + ) + ) + ) { + throw new Error('project_removal_in_progress') + } + for (const scope of worktreeScopes) { + this.blockTerminalAdmissionScope( + this.admissionScopeKey(scope.id, hostId === null ? null : scope.hostId) + ) + } + // Why: a create that passed admission before the block can still have a + // provider spawn in flight. Let it register first so the fresh stop + // snapshot sees and terminates it. + await Promise.all([ + this.drainTerminalAdmissions([GLOBAL_TERMINAL_ADMISSION_KEY]), + ...worktreeScopes.map((scope) => + this.drainTerminalAdmissions([scope.id], scope.hostId, hostId === null) + ) + ]) + // Why: hold admission closed across both the fresh liveness proof and + // repo mutation. A separate terminal.stop RPC leaves an inter-call gap. + for (const scope of worktreeScopes) { + await this.verifyTerminalsStoppedForRemoval(scope.id, scope.hostId) + } + removePersistedProject() + this.invalidateResolvedWorktreeCache() + invalidateAuthorizedRootsCache() + this.notifyReposChanged() + return { removed: true } + } finally { + for (const scope of worktreeScopes) { + this.terminalAdmissionBlockedWorktreeIds.delete( + this.admissionScopeKey(scope.id, hostId === null ? null : scope.hostId) + ) + } + this.projectRemovalBlockedRepoIds.delete(projectScopeKey) + } + } + async removeProject(repoSelector: string): Promise<{ removed: true }> { - if (!this.store?.removeProject) { + const store = this.store + if (!store?.removeProject) { throw new Error('runtime_unavailable') } const repo = await this.resolveRepoSelector(repoSelector) - this.store.removeProject(repo.id) - this.invalidateResolvedWorktreeCache() - invalidateAuthorizedRootsCache() - this.notifyReposChanged() - return { removed: true } + return this.removeProjectWithHostScope(repo, null, () => store.removeProject!(repo.id)) + } + + async removeProjectForHost(repoId: string, hostId: ExecutionHostId): Promise<{ removed: true }> { + const store = this.store + if (!store?.removeProjectForHost) { + throw new Error('runtime_unavailable') + } + const repo = store + .getRepos() + .find((candidate) => candidate.id === repoId && getRepoExecutionHostId(candidate) === hostId) + if (!repo) { + throw new Error('repo_not_found') + } + return this.removeProjectWithHostScope(repo, hostId, () => + store.removeProjectForHost!(repo.id, hostId) + ) } async inspectTerminalProcess( @@ -14446,42 +15208,22 @@ export class OrcaRuntimeService { }) } - async createManagedWorktree(args: { - repoSelector: string - name: string - baseBranch?: string - compareBaseRef?: string - branchNameOverride?: string - linkedIssue?: number | null - linkedPR?: number | null - linkedLinearIssue?: string - linkedLinearIssueWorkspaceId?: string | null - linkedLinearIssueOrganizationUrlKey?: string | null - linkedGitLabMR?: number | null - linkedGitLabIssue?: number | null - linkedBitbucketPR?: number | null - linkedAzureDevOpsPR?: number | null - linkedGiteaPR?: number | null - comment?: string - displayName?: string - telemetrySource?: WorkspaceCreateTelemetrySource - workspaceStatus?: string - manualOrder?: number - sparseCheckout?: { directories: string[]; presetId?: string } - pushTarget?: GitPushTarget - runHooks?: boolean - activate?: boolean - setupDecision?: 'run' | 'skip' | 'inherit' - createdWithAgent?: TuiAgent - startupAgent?: TuiAgent - startupPrompt?: string - pendingFirstAgentMessageRename?: boolean - automationProvenance?: AutomationWorkspaceProvenance - startup?: WorktreeStartupLaunch - startupDraft?: string - startupDraftPaste?: WorktreeStartupDraftPaste - lineage?: WorktreeLineageInput - }): Promise { + async createManagedWorktree(args: CreateManagedWorktreeArgs): Promise { + if (!this.store) { + throw new Error('runtime_unavailable') + } + + const repo = await this.resolveRepoSelector(args.repoSelector) + return await this.runWithWorktreeCreateAdmission( + repo.id, + () => this.runCreateManagedWorktree(args), + getRepoExecutionHostId(repo) + ) + } + + private async runCreateManagedWorktree( + args: CreateManagedWorktreeArgs + ): Promise { if (!this.store) { throw new Error('runtime_unavailable') } @@ -14570,6 +15312,7 @@ export class OrcaRuntimeService { const shouldActivate = args.activate === true || args.runHooks === true let warning: string | undefined let didSpawnStartup = false + let startupTerminal: CreateWorktreeResult['startupTerminal'] if (effectiveStartup && this.ptyController?.spawn) { try { const startupTrustAgent = effectiveDraftPaste?.agent ?? effectiveCreatedWithAgent @@ -14593,6 +15336,14 @@ export class OrcaRuntimeService { this.sendStartupFollowupWhenReady(terminal.handle, effectiveStartupFollowup) } didSpawnStartup = true + startupTerminal = { + spawned: true, + handle: terminal.handle, + ...(terminal.tabId ? { tabId: terminal.tabId } : {}), + ...(terminal.paneKey ? { paneKey: terminal.paneKey } : {}), + ...(terminal.ptyId ? { ptyId: terminal.ptyId } : {}), + surface: 'background' + } } catch (err) { const message = err instanceof Error ? err.message : String(err) warning = `Failed to create the startup terminal for ${worktree.path}: ${message}` @@ -14630,6 +15381,7 @@ export class OrcaRuntimeService { isMainWorktree: worktree.isMainWorktree } }, + ...(startupTerminal ? { startupTerminal } : {}), ...(warning ? { warning } : {}) } } @@ -15142,11 +15894,7 @@ export class OrcaRuntimeService { warnings: lineageWarnings } = this.recordCreatedWorktreeLineage(worktree, lineageResolution) - if ( - settings.experimentalWorktreeSymlinks && - repo.symlinkPaths && - repo.symlinkPaths.length > 0 - ) { + if (repo.symlinkPaths && repo.symlinkPaths.length > 0) { await createWorktreeLinkedPaths(repo.path, created.path, repo.symlinkPaths) } @@ -16634,7 +17382,8 @@ export class OrcaRuntimeService { const removalTarget = { id: worktree.id, repoId: worktree.repoId, - path: worktree.path + path: worktree.path, + hostId: this.executionHostForWorktree(worktree) } return worktree.pushTarget ? { ...removalTarget, pushTarget: worktree.pushTarget } @@ -16795,8 +17544,9 @@ export class OrcaRuntimeService { } const store = this.store const removalTarget = await this.resolveWorktreeRemovalTarget(worktreeSelector) + const removalScopeKey = this.admissionScopeKey(removalTarget.id, removalTarget.hostId) const optionsKey = getRuntimeWorktreeRemovalOptionsKey(force, runHooks) - const inFlightRemoval = this.removeManagedWorktreeInFlight.get(removalTarget.id) + const inFlightRemoval = this.removeManagedWorktreeInFlight.get(removalScopeKey) if (inFlightRemoval) { if (inFlightRemoval.optionsKey === optionsKey) { return inFlightRemoval.promise @@ -16806,142 +17556,447 @@ export class OrcaRuntimeService { // Why: runtime callers can race the same workspace through CLI/mobile // retries. Share one destructive Git/filesystem operation per worktree ID. - const removal = (async (): Promise => { - const repo = store.getRepo(removalTarget.repoId) - if (!repo) { - throw new Error('repo_not_found') - } - if (isFolderRepo(repo)) { - if (removalTarget.id === getRuntimeFolderWorkspaceRootId(repo)) { + const removal = this.runWithWorktreeRemovalAdmission( + removalTarget.id, + async () => { + const repo = + store + .getRepos() + .find( + (candidate) => + candidate.id === removalTarget.repoId && + (!removalTarget.hostId || + getRepoExecutionHostId(candidate) === removalTarget.hostId) + ) ?? + (store.getRepo(removalTarget.repoId) && + (!removalTarget.hostId || + getRepoExecutionHostId(store.getRepo(removalTarget.repoId)!) === removalTarget.hostId) + ? store.getRepo(removalTarget.repoId)! + : undefined) + if (!repo) { + throw new Error('repo_not_found') + } + if (isFolderRepo(repo)) { + if (removalTarget.id === getRuntimeFolderWorkspaceRootId(repo)) { + throw new Error( + 'Cannot delete the project root workspace. Remove the folder project instead.' + ) + } + const localProvider = this.getLocalProvider() + await this.verifyTerminalsStoppedForRemoval(removalTarget.id, removalTarget.hostId) + if (localProvider) { + // Why: folder workspace deletion has no Git removal phase where PTYs + // would otherwise be swept; tear them down before hiding the workspace. + await killAllProcessesForWorktree(removalTarget.id, { + runtime: this, + localProvider, + onPtyStopped: this.onPtyStopped ?? undefined + }).catch((err) => { + console.warn(`[worktree-teardown] failed for ${removalTarget.id}:`, err) + }) + } + this.removeWorktreeMetadataAndHistory(store, removalTarget.id) + this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) + this.invalidateResolvedWorktreeCache() + this.notifyWorktreesChanged(repo.id) + return {} + } + const provider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null + const fsProvider = repo.connectionId ? getSshFilesystemProvider(repo.connectionId) : null + const localWorktreeGitOptions = repo.connectionId + ? {} + : getLocalProjectWorktreeGitOptions(this.requireStore(), repo) + const hasLocalWorktreeGitOptions = Object.keys(localWorktreeGitOptions).length > 0 + const registeredWorktrees = repo.connectionId + ? await provider!.listWorktrees(repo.path) + : hasLocalWorktreeGitOptions + ? await listWorktreesStrict(repo.path, localWorktreeGitOptions) + : await listWorktreesStrict(repo.path) + const removedMeta = store.getWorktreeMeta(removalTarget.id) + const removedPushTarget = removedMeta?.pushTarget ?? removalTarget.pushTarget + const registeredWorktree = findRegisteredDeletableWorktree( + repo.path, + removalTarget.path, + registeredWorktrees + ) + if (!registeredWorktree) { + let canCleanOrphanedDirectory = false + if ( + canCleanupUnregisteredOrcaWorktreeDirectory({ + meta: removedMeta + }) + ) { + if (repo.connectionId) { + if (!fsProvider) { + throw new Error('SSH filesystem provider unavailable') + } + if (!fsProvider.lstat) { + throw new Error('SSH filesystem provider lstat unavailable') + } + canCleanOrphanedDirectory = await canSafelyRemoveOrphanedWorktreeDirectory( + removalTarget.path, + repo.path, + (path) => fsProvider.lstat!(path), + (path) => fsProvider.readFile(path) + ) + } else { + const access = getLocalWorktreePathAccess(localWorktreeGitOptions) + canCleanOrphanedDirectory = + !isDangerousWorktreeRemovalPath(removalTarget.path, repo.path) && + (await canSafelyRemoveOrphanedWorktreeDirectory( + toLocalWorktreeRuntimePath(removalTarget.path, localWorktreeGitOptions), + toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), + access.statPath, + access.readPath + )) + } + } + if (canCleanOrphanedDirectory) { + assertWorktreeDoesNotContainRegisteredWorktree(removalTarget.path, registeredWorktrees) + if (!force) { + throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) + } + await this.verifyTerminalsStoppedForRemoval(removalTarget.id, removalTarget.hostId) + if (repo.connectionId) { + await fsProvider!.deletePath(removalTarget.path, true) + await cleanupUnusedWorktreePushTargetRemoteSsh( + provider!, + repo.path, + removalTarget.id, + removedPushTarget, + store + ) + } else { + await removeLocalWorktreePath(removalTarget.path, localWorktreeGitOptions) + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + removalTarget.id, + removedPushTarget, + store, + localWorktreeGitOptions + ) + } + this.clearOptimisticReconcileToken(removalTarget.id) + this.removeWorktreeMetadataAndHistory(store, removalTarget.id) + this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) + this.invalidateResolvedWorktreeCache() + invalidateAuthorizedRootsCache() + this.notifyWorktreesChanged(repo.id) + return {} + } + if (!repo.connectionId) { + const access = getLocalWorktreePathAccess(localWorktreeGitOptions) + const runtimeWorktreePath = toLocalWorktreeRuntimePath( + removalTarget.path, + localWorktreeGitOptions + ) + if ( + await canCleanupUnregisteredOrcaLeftoverDirectory({ + meta: removedMeta, + worktreePath: removalTarget.path, + runtimeWorktreePath, + repo, + runtimeRepoPath: toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), + registeredWorktrees, + statPath: access.statPath, + isGitRepository: (path) => + isLocalRuntimeGitRepository(path, localWorktreeGitOptions) + }) + ) { + if (!force) { + throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) + } + await this.verifyTerminalsStoppedForRemoval(removalTarget.id, removalTarget.hostId) + await closeLocalWatcherForWorktreePath(removalTarget.path).catch((err) => { + console.warn(`[filesystem-watcher] failed to close ${removalTarget.path}:`, err) + }) + await removeLocalWorktreePath(removalTarget.path, localWorktreeGitOptions) + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + removalTarget.id, + removedPushTarget, + store, + localWorktreeGitOptions + ) + this.clearOptimisticReconcileToken(removalTarget.id) + this.removeWorktreeMetadataAndHistory(store, removalTarget.id) + this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) + this.invalidateResolvedWorktreeCache() + invalidateAuthorizedRootsCache() + this.notifyWorktreesChanged(repo.id) + return {} + } + } + if ( + await isRuntimeWorktreePathMissing(repo, removalTarget.path, localWorktreeGitOptions) + ) { + if (!force && !removedMeta) { + // Why: without persisted metadata, require the renderer recovery + // path before deleting Orca-only state for an unregistered path. + throw new Error(UNREGISTERED_MISSING_WORKTREE_MESSAGE) + } + // Why: a manually deleted worktree is already gone from Git and disk. + // Finish runtime metadata cleanup without requiring force or touching + // any unregistered path that still exists. + await this.verifyTerminalsStoppedForRemoval(removalTarget.id, removalTarget.hostId) + await (repo.connectionId + ? cleanupUnusedWorktreePushTargetRemoteSsh( + provider!, + repo.path, + removalTarget.id, + removedPushTarget, + store + ) + : cleanupUnusedWorktreePushTargetRemote( + repo.path, + removalTarget.id, + removedPushTarget, + store, + localWorktreeGitOptions + )) + this.clearOptimisticReconcileToken(removalTarget.id) + this.removeWorktreeMetadataAndHistory(store, removalTarget.id) + this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) + this.invalidateResolvedWorktreeCache() + invalidateAuthorizedRootsCache() + this.notifyWorktreesChanged(repo.id) + return {} + } + throw new Error(`Refusing to delete unregistered worktree path: ${removalTarget.path}`) + } + const canonicalWorktreePath = registeredWorktree.path + const deleteBranch = removedMeta?.preserveBranchOnDelete !== true + + // Why: a Git lock must block before archive hooks or linked-path cleanup + // mutate the workspace; dirty-file force is a separate permission. + try { + assertWorktreeUnlockedForRemoval(registeredWorktree) + } catch (error) { + throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force)) + } + + // Why: a prior forced Windows recovery can delete the directory but leave + // Git's stale registration; recover and verify it before clearing metadata. + if ( + !repo.connectionId && + force === true && + process.platform === 'win32' && + (isWindowsAbsolutePathLike(canonicalWorktreePath) || + !!localWorktreeGitOptions.wslDistro) && + removedMeta && + (await isRuntimeWorktreePathMissing(repo, canonicalWorktreePath, localWorktreeGitOptions)) + ) { + await this.verifyTerminalsStoppedForRemoval(removalTarget.id, removalTarget.hostId) + const removalResult = await removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval({ + canonicalWorktreePath, + repoPath: repo.path, + localWorktreeGitOptions, + registeredWorktree, + deleteBranch + }) + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + removalTarget.id, + removedPushTarget, + store, + localWorktreeGitOptions + ) + this.rememberPreservedBranchCleanupTarget( + removalTarget.id, + removalResult, + registeredWorktree.head, + removedPushTarget + ) + this.clearOptimisticReconcileToken(removalTarget.id) + this.removeWorktreeMetadataAndHistory(store, removalTarget.id) + this.invalidateResolvedWorktreeCache() + invalidateAuthorizedRootsCache() + this.notifyWorktreesChanged(repo.id) + return removalResult ?? {} + } + if (repo.connectionId) { + await this.verifyTerminalsStoppedForRemoval(removalTarget.id, removalTarget.hostId) + const remoteRemoveOptions = !deleteBranch ? { deleteBranch } : {} + const rawRemovalResult = await (Object.keys(remoteRemoveOptions).length > 0 + ? provider!.removeWorktree(canonicalWorktreePath, force, remoteRemoveOptions) + : provider!.removeWorktree(canonicalWorktreePath, force)) + const removalResult = this.preserveBranchHeadFallback( + rawRemovalResult, + registeredWorktree.head + ) + await cleanupUnusedWorktreePushTargetRemoteSsh( + provider!, + repo.path, + removalTarget.id, + removedPushTarget, + store + ) + this.rememberPreservedBranchCleanupTarget( + removalTarget.id, + removalResult, + registeredWorktree.head, + removedPushTarget + ) + this.clearOptimisticReconcileToken(removalTarget.id) + this.removeWorktreeMetadataAndHistory(store, removalTarget.id) + this.invalidateResolvedWorktreeCache() + invalidateAuthorizedRootsCache() + this.notifyWorktreesChanged(repo.id) + return removalResult ?? {} + } + + const hooks = getEffectiveHooks(repo) + let warning: string | undefined + if (hooks?.scripts.archive && runHooks) { + const result = await runHook( + 'archive', + canonicalWorktreePath, + repo, + undefined, + hasLocalWorktreeGitOptions ? localWorktreeGitOptions : undefined + ) + if (!result.success) { + console.error( + `[hooks] archive hook failed for ${canonicalWorktreePath}:`, + result.output + ) + } + } else if (hooks?.scripts.archive) { + // Runtime RPC calls have no renderer trust prompt, so hooks require explicit CLI opt-in. + warning = `orca.yaml archive hook skipped for ${canonicalWorktreePath}; pass --run-hooks to run it.` + console.warn(`[hooks] ${warning}`) + } + + const refreshedWorktrees = hasLocalWorktreeGitOptions + ? await listWorktreesStrict(repo.path, localWorktreeGitOptions) + : await listWorktreesStrict(repo.path) + const refreshedRegisteredWorktree = findRegisteredDeletableWorktree( + repo.path, + canonicalWorktreePath, + refreshedWorktrees + ) + if (!refreshedRegisteredWorktree) { throw new Error( - 'Cannot delete the project root workspace. Remove the folder project instead.' + `Worktree registration changed during deletion: ${canonicalWorktreePath}. Retry deletion.` ) } + try { + // Why: an archive hook can race another Git client that locks the row; + // recheck before linked-path, watcher, or terminal teardown side effects. + assertWorktreeUnlockedForRemoval(refreshedRegisteredWorktree) + } catch (error) { + throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force)) + } + + let shouldTearDownPtys = true + if (repo.symlinkPaths && repo.symlinkPaths.length > 0) { + await removeWorktreeLinkedPaths(canonicalWorktreePath, repo.symlinkPaths) + } + try { + await (hasLocalWorktreeGitOptions + ? assertWorktreeCleanForRemoval(canonicalWorktreePath, force, localWorktreeGitOptions) + : assertWorktreeCleanForRemoval(canonicalWorktreePath, force)) + } catch (error) { + if (!isOrphanCompatiblePreflightError(error)) { + throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force)) + } + // Why: orphan cleanup does not need live shells to be killed first, + // and preflight did not prove the worktree is cleanly removable. + shouldTearDownPtys = false + } + const localProvider = this.getLocalProvider() - if (localProvider) { - // Why: folder workspace deletion has no Git removal phase where PTYs - // would otherwise be swept; tear them down before hiding the workspace. + await closeLocalWatcherForWorktreePath(canonicalWorktreePath).catch((err) => { + console.warn(`[filesystem-watcher] failed to close ${canonicalWorktreePath}:`, err) + }) + await this.verifyTerminalsStoppedForRemoval(removalTarget.id, removalTarget.hostId) + if (localProvider && shouldTearDownPtys) { + // Why: once preflight proves normal deletion is clean, kill PTYs before + // git-level removal so Windows handles cannot keep the directory busy. This also + // closes the headless-CLI leak for confirmed-removable worktrees. await killAllProcessesForWorktree(removalTarget.id, { runtime: this, localProvider, onPtyStopped: this.onPtyStopped ?? undefined - }).catch((err) => { - console.warn(`[worktree-teardown] failed for ${removalTarget.id}:`, err) }) + .then((r) => { + const total = r.runtimeStopped + r.providerStopped + r.registryStopped + if (total > 0) { + // Why (design §4.4 observability): breadcrumb lets ops + // distinguish a renderer-state-induced leak (diff-path purge + // non-empty) from a backend-induced one (nothing to kill but + // memory still pinned). Emit only when the sweep actually did + // work so steady-state logs stay quiet. + console.info( + `[worktree-teardown] ${removalTarget.id} killed runtime=${r.runtimeStopped} provider=${r.providerStopped} registry=${r.registryStopped}` + ) + } + }) + .catch((err) => { + console.warn(`[worktree-teardown] failed for ${removalTarget.id}:`, err) + }) } - this.removeWorktreeMetadataAndHistory(store, removalTarget.id) - this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) - this.invalidateResolvedWorktreeCache() - this.notifyWorktreesChanged(repo.id) - return {} - } - const provider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null - const fsProvider = repo.connectionId ? getSshFilesystemProvider(repo.connectionId) : null - const localWorktreeGitOptions = repo.connectionId - ? {} - : getLocalProjectWorktreeGitOptions(this.requireStore(), repo) - const hasLocalWorktreeGitOptions = Object.keys(localWorktreeGitOptions).length > 0 - const registeredWorktrees = repo.connectionId - ? await provider!.listWorktrees(repo.path) - : hasLocalWorktreeGitOptions - ? await listWorktreesStrict(repo.path, localWorktreeGitOptions) - : await listWorktreesStrict(repo.path) - const removedMeta = store.getWorktreeMeta(removalTarget.id) - const removedPushTarget = removedMeta?.pushTarget ?? removalTarget.pushTarget - const registeredWorktree = findRegisteredDeletableWorktree( - repo.path, - removalTarget.path, - registeredWorktrees - ) - if (!registeredWorktree) { - let canCleanOrphanedDirectory = false - if ( - canCleanupUnregisteredOrcaWorktreeDirectory({ - meta: removedMeta + + let removalResult: RemoveWorktreeResult | undefined + try { + const removeOptions = { + ...(!deleteBranch ? { deleteBranch } : {}), + // Why: removal already validated the Git row under the selected + // project runtime; keep branch cleanup on that same canonical row. + knownRemovedWorktree: refreshedRegisteredWorktree, + ...localWorktreeGitOptions + } + removalResult = this.preserveBranchHeadFallback( + await removeWorktree(repo.path, canonicalWorktreePath, force, removeOptions), + refreshedRegisteredWorktree.head + ) + } catch (error) { + // Why: Git for Windows can deregister a clean worktree before its + // recursive filesystem deletion fails transiently. + const recoveredRemovalResult = await recoverLocalWindowsWorktreeRemoval({ + error, + force, + canonicalWorktreePath, + repoPath: repo.path, + localWorktreeGitOptions, + registeredWorktree: refreshedRegisteredWorktree, + deleteBranch, + closeWatcher: (worktreePath) => + closeLocalWatcherForWorktreePath(worktreePath).catch((err) => { + console.warn(`[filesystem-watcher] failed to close ${worktreePath}:`, err) + }) }) - ) { - if (repo.connectionId) { - if (!fsProvider) { - throw new Error('SSH filesystem provider unavailable') - } - if (!fsProvider.lstat) { - throw new Error('SSH filesystem provider lstat unavailable') - } - canCleanOrphanedDirectory = await canSafelyRemoveOrphanedWorktreeDirectory( - removalTarget.path, - repo.path, - (path) => fsProvider.lstat!(path), - (path) => fsProvider.readFile(path) - ) - } else { + if (recoveredRemovalResult) { + removalResult = recoveredRemovalResult + } else if (isOrphanedWorktreeError(error)) { const access = getLocalWorktreePathAccess(localWorktreeGitOptions) - canCleanOrphanedDirectory = - !isDangerousWorktreeRemovalPath(removalTarget.path, repo.path) && - (await canSafelyRemoveOrphanedWorktreeDirectory( - toLocalWorktreeRuntimePath(removalTarget.path, localWorktreeGitOptions), + if ( + await canSafelyRemoveOrphanedWorktreeDirectory( + toLocalWorktreeRuntimePath(canonicalWorktreePath, localWorktreeGitOptions), toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), access.statPath, access.readPath - )) - } - } - if (canCleanOrphanedDirectory) { - assertWorktreeDoesNotContainRegisteredWorktree(removalTarget.path, registeredWorktrees) - if (!force) { - throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) - } - if (repo.connectionId) { - await fsProvider!.deletePath(removalTarget.path, true) - await cleanupUnusedWorktreePushTargetRemoteSsh( - provider!, - repo.path, - removalTarget.id, - removedPushTarget, - store - ) - } else { - await removeLocalWorktreePath(removalTarget.path, localWorktreeGitOptions) - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - removalTarget.id, - removedPushTarget, - store, - localWorktreeGitOptions - ) - } - this.clearOptimisticReconcileToken(removalTarget.id) - this.removeWorktreeMetadataAndHistory(store, removalTarget.id) - this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) - this.invalidateResolvedWorktreeCache() - invalidateAuthorizedRootsCache() - this.notifyWorktreesChanged(repo.id) - return {} - } - if (!repo.connectionId) { - const access = getLocalWorktreePathAccess(localWorktreeGitOptions) - const runtimeWorktreePath = toLocalWorktreeRuntimePath( - removalTarget.path, - localWorktreeGitOptions - ) - if ( - await canCleanupUnregisteredOrcaLeftoverDirectory({ - meta: removedMeta, - worktreePath: removalTarget.path, - runtimeWorktreePath, - repo, - runtimeRepoPath: toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), - registeredWorktrees, - statPath: access.statPath, - isGitRepository: (path) => isLocalRuntimeGitRepository(path, localWorktreeGitOptions) - }) - ) { - if (!force) { - throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) + ) + ) { + await closeLocalWatcherForWorktreePath(canonicalWorktreePath).catch((err) => { + console.warn(`[filesystem-watcher] failed to close ${canonicalWorktreePath}:`, err) + }) + await removeLocalWorktreePath(canonicalWorktreePath, localWorktreeGitOptions).catch( + () => {} + ) + } else { + console.warn( + `[worktrees] Refusing recursive cleanup for unproven worktree directory: ${canonicalWorktreePath}` + ) } - await closeLocalWatcherForWorktreePath(removalTarget.path).catch((err) => { - console.warn(`[filesystem-watcher] failed to close ${removalTarget.path}:`, err) - }) - await removeLocalWorktreePath(removalTarget.path, localWorktreeGitOptions) + // Why: `git worktree remove` failed, so git's internal worktree tracking + // (`.git/worktrees/`) is still intact. Without pruning, `git worktree + // list` continues to show the stale entry and the branch it had checked out + // remains locked — other worktrees cannot check it out. + await gitExecFileAsync(['worktree', 'prune'], { + cwd: repo.path, + ...localWorktreeGitOptions + }).catch(() => {}) await cleanupUnusedWorktreePushTargetRemote( repo.path, removalTarget.id, @@ -16955,71 +18010,14 @@ export class OrcaRuntimeService { this.invalidateResolvedWorktreeCache() invalidateAuthorizedRootsCache() this.notifyWorktreesChanged(repo.id) - return {} + return { + ...(warning ? { warning } : {}) + } + } else { + throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force)) } } - if (await isRuntimeWorktreePathMissing(repo, removalTarget.path, localWorktreeGitOptions)) { - if (!force && !removedMeta) { - // Why: without persisted metadata, require the renderer recovery - // path before deleting Orca-only state for an unregistered path. - throw new Error(UNREGISTERED_MISSING_WORKTREE_MESSAGE) - } - // Why: a manually deleted worktree is already gone from Git and disk. - // Finish runtime metadata cleanup without requiring force or touching - // any unregistered path that still exists. - await (repo.connectionId - ? cleanupUnusedWorktreePushTargetRemoteSsh( - provider!, - repo.path, - removalTarget.id, - removedPushTarget, - store - ) - : cleanupUnusedWorktreePushTargetRemote( - repo.path, - removalTarget.id, - removedPushTarget, - store, - localWorktreeGitOptions - )) - this.clearOptimisticReconcileToken(removalTarget.id) - this.removeWorktreeMetadataAndHistory(store, removalTarget.id) - this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) - this.invalidateResolvedWorktreeCache() - invalidateAuthorizedRootsCache() - this.notifyWorktreesChanged(repo.id) - return {} - } - throw new Error(`Refusing to delete unregistered worktree path: ${removalTarget.path}`) - } - const canonicalWorktreePath = registeredWorktree.path - const deleteBranch = removedMeta?.preserveBranchOnDelete !== true - // Why: a Git lock must block before archive hooks or linked-path cleanup - // mutate the workspace; dirty-file force is a separate permission. - try { - assertWorktreeUnlockedForRemoval(registeredWorktree) - } catch (error) { - throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force)) - } - - // Why: a prior forced Windows recovery can delete the directory but leave - // Git's stale registration; recover and verify it before clearing metadata. - if ( - !repo.connectionId && - force === true && - process.platform === 'win32' && - (isWindowsAbsolutePathLike(canonicalWorktreePath) || !!localWorktreeGitOptions.wslDistro) && - removedMeta && - (await isRuntimeWorktreePathMissing(repo, canonicalWorktreePath, localWorktreeGitOptions)) - ) { - const removalResult = await removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval({ - canonicalWorktreePath, - repoPath: repo.path, - localWorktreeGitOptions, - registeredWorktree, - deleteBranch - }) await cleanupUnusedWorktreePushTargetRemote( repo.path, removalTarget.id, @@ -17030,7 +18028,7 @@ export class OrcaRuntimeService { this.rememberPreservedBranchCleanupTarget( removalTarget.id, removalResult, - registeredWorktree.head, + refreshedRegisteredWorktree.head, removedPushTarget ) this.clearOptimisticReconcileToken(removalTarget.id) @@ -17038,236 +18036,19 @@ export class OrcaRuntimeService { this.invalidateResolvedWorktreeCache() invalidateAuthorizedRootsCache() this.notifyWorktreesChanged(repo.id) - return removalResult ?? {} - } - if (repo.connectionId) { - const remoteRemoveOptions = !deleteBranch ? { deleteBranch } : {} - const rawRemovalResult = await (Object.keys(remoteRemoveOptions).length > 0 - ? provider!.removeWorktree(canonicalWorktreePath, force, remoteRemoveOptions) - : provider!.removeWorktree(canonicalWorktreePath, force)) - const removalResult = this.preserveBranchHeadFallback( - rawRemovalResult, - registeredWorktree.head - ) - await cleanupUnusedWorktreePushTargetRemoteSsh( - provider!, - repo.path, - removalTarget.id, - removedPushTarget, - store - ) - this.rememberPreservedBranchCleanupTarget( - removalTarget.id, - removalResult, - registeredWorktree.head, - removedPushTarget - ) - this.clearOptimisticReconcileToken(removalTarget.id) - this.removeWorktreeMetadataAndHistory(store, removalTarget.id) - this.invalidateResolvedWorktreeCache() - invalidateAuthorizedRootsCache() - this.notifyWorktreesChanged(repo.id) - return removalResult ?? {} - } - - const hooks = getEffectiveHooks(repo) - let warning: string | undefined - if (hooks?.scripts.archive && runHooks) { - const result = await runHook( - 'archive', - canonicalWorktreePath, - repo, - undefined, - hasLocalWorktreeGitOptions ? localWorktreeGitOptions : undefined - ) - if (!result.success) { - console.error(`[hooks] archive hook failed for ${canonicalWorktreePath}:`, result.output) + return { + ...removalResult, + ...(warning ? { warning } : {}) } - } else if (hooks?.scripts.archive) { - // Runtime RPC calls have no renderer trust prompt, so hooks require explicit CLI opt-in. - warning = `orca.yaml archive hook skipped for ${canonicalWorktreePath}; pass --run-hooks to run it.` - console.warn(`[hooks] ${warning}`) - } - - const refreshedWorktrees = hasLocalWorktreeGitOptions - ? await listWorktreesStrict(repo.path, localWorktreeGitOptions) - : await listWorktreesStrict(repo.path) - const refreshedRegisteredWorktree = findRegisteredDeletableWorktree( - repo.path, - canonicalWorktreePath, - refreshedWorktrees - ) - if (!refreshedRegisteredWorktree) { - throw new Error( - `Worktree registration changed during deletion: ${canonicalWorktreePath}. Retry deletion.` - ) - } - try { - // Why: an archive hook can race another Git client that locks the row; - // recheck before linked-path, watcher, or terminal teardown side effects. - assertWorktreeUnlockedForRemoval(refreshedRegisteredWorktree) - } catch (error) { - throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force)) - } - - let shouldTearDownPtys = true - if (repo.symlinkPaths && repo.symlinkPaths.length > 0) { - await removeWorktreeLinkedPaths(canonicalWorktreePath, repo.symlinkPaths) - } - try { - await (hasLocalWorktreeGitOptions - ? assertWorktreeCleanForRemoval(canonicalWorktreePath, force, localWorktreeGitOptions) - : assertWorktreeCleanForRemoval(canonicalWorktreePath, force)) - } catch (error) { - if (!isOrphanCompatiblePreflightError(error)) { - throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force)) - } - // Why: orphan cleanup does not need live shells to be killed first, - // and preflight did not prove the worktree is cleanly removable. - shouldTearDownPtys = false - } - - const localProvider = this.getLocalProvider() - await closeLocalWatcherForWorktreePath(canonicalWorktreePath).catch((err) => { - console.warn(`[filesystem-watcher] failed to close ${canonicalWorktreePath}:`, err) - }) - if (localProvider && shouldTearDownPtys) { - // Why: once preflight proves normal deletion is clean, kill PTYs before - // git-level removal so Windows handles cannot keep the directory busy. This also - // closes the headless-CLI leak for confirmed-removable worktrees. - await killAllProcessesForWorktree(removalTarget.id, { - runtime: this, - localProvider, - onPtyStopped: this.onPtyStopped ?? undefined - }) - .then((r) => { - const total = r.runtimeStopped + r.providerStopped + r.registryStopped - if (total > 0) { - // Why (design §4.4 observability): breadcrumb lets ops - // distinguish a renderer-state-induced leak (diff-path purge - // non-empty) from a backend-induced one (nothing to kill but - // memory still pinned). Emit only when the sweep actually did - // work so steady-state logs stay quiet. - console.info( - `[worktree-teardown] ${removalTarget.id} killed runtime=${r.runtimeStopped} provider=${r.providerStopped} registry=${r.registryStopped}` - ) - } - }) - .catch((err) => { - console.warn(`[worktree-teardown] failed for ${removalTarget.id}:`, err) - }) - } - - let removalResult: RemoveWorktreeResult | undefined - try { - const removeOptions = { - ...(!deleteBranch ? { deleteBranch } : {}), - // Why: removal already validated the Git row under the selected - // project runtime; keep branch cleanup on that same canonical row. - knownRemovedWorktree: refreshedRegisteredWorktree, - ...localWorktreeGitOptions - } - removalResult = this.preserveBranchHeadFallback( - await removeWorktree(repo.path, canonicalWorktreePath, force, removeOptions), - refreshedRegisteredWorktree.head - ) - } catch (error) { - // Why: Git for Windows can deregister a clean worktree before its - // recursive filesystem deletion fails transiently. - const recoveredRemovalResult = await recoverLocalWindowsWorktreeRemoval({ - error, - force, - canonicalWorktreePath, - repoPath: repo.path, - localWorktreeGitOptions, - registeredWorktree: refreshedRegisteredWorktree, - deleteBranch, - closeWatcher: (worktreePath) => - closeLocalWatcherForWorktreePath(worktreePath).catch((err) => { - console.warn(`[filesystem-watcher] failed to close ${worktreePath}:`, err) - }) - }) - if (recoveredRemovalResult) { - removalResult = recoveredRemovalResult - } else if (isOrphanedWorktreeError(error)) { - const access = getLocalWorktreePathAccess(localWorktreeGitOptions) - if ( - await canSafelyRemoveOrphanedWorktreeDirectory( - toLocalWorktreeRuntimePath(canonicalWorktreePath, localWorktreeGitOptions), - toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), - access.statPath, - access.readPath - ) - ) { - await closeLocalWatcherForWorktreePath(canonicalWorktreePath).catch((err) => { - console.warn(`[filesystem-watcher] failed to close ${canonicalWorktreePath}:`, err) - }) - await removeLocalWorktreePath(canonicalWorktreePath, localWorktreeGitOptions).catch( - () => {} - ) - } else { - console.warn( - `[worktrees] Refusing recursive cleanup for unproven worktree directory: ${canonicalWorktreePath}` - ) - } - // Why: `git worktree remove` failed, so git's internal worktree tracking - // (`.git/worktrees/`) is still intact. Without pruning, `git worktree - // list` continues to show the stale entry and the branch it had checked out - // remains locked — other worktrees cannot check it out. - await gitExecFileAsync(['worktree', 'prune'], { - cwd: repo.path, - ...localWorktreeGitOptions - }).catch(() => {}) - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - removalTarget.id, - removedPushTarget, - store, - localWorktreeGitOptions - ) - this.clearOptimisticReconcileToken(removalTarget.id) - this.removeWorktreeMetadataAndHistory(store, removalTarget.id) - this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) - this.invalidateResolvedWorktreeCache() - invalidateAuthorizedRootsCache() - this.notifyWorktreesChanged(repo.id) - return { - ...(warning ? { warning } : {}) - } - } else { - throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force)) - } - } - - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - removalTarget.id, - removedPushTarget, - store, - localWorktreeGitOptions - ) - this.rememberPreservedBranchCleanupTarget( - removalTarget.id, - removalResult, - refreshedRegisteredWorktree.head, - removedPushTarget - ) - this.clearOptimisticReconcileToken(removalTarget.id) - this.removeWorktreeMetadataAndHistory(store, removalTarget.id) - this.invalidateResolvedWorktreeCache() - invalidateAuthorizedRootsCache() - this.notifyWorktreesChanged(repo.id) - return { - ...removalResult, - ...(warning ? { warning } : {}) - } - })() - this.removeManagedWorktreeInFlight.set(removalTarget.id, { optionsKey, promise: removal }) + }, + removalTarget.hostId + ) + this.removeManagedWorktreeInFlight.set(removalScopeKey, { optionsKey, promise: removal }) try { return await removal } finally { - if (this.removeManagedWorktreeInFlight.get(removalTarget.id)?.promise === removal) { - this.removeManagedWorktreeInFlight.delete(removalTarget.id) + if (this.removeManagedWorktreeInFlight.get(removalScopeKey)?.promise === removal) { + this.removeManagedWorktreeInFlight.delete(removalScopeKey) } } } @@ -17373,12 +18154,12 @@ export class OrcaRuntimeService { ): Promise { const presentation = resolveTerminalPresentation(opts) const requiresRendererFocus = opts.presentation === 'focused' || opts.focus === true + const availableAuthoritativeWindow = this.getAvailableAuthoritativeWindow() // Why: pre-diff createTerminal fell back to the renderer's active worktree // when no selector was provided. The new background-spawn branch hard- // requires a resolvable selector, so route the no-selector case through // the renderer IPC path to preserve that behavior. - const rendererWindow = - opts.rendererBacked === true ? this.getAvailableAuthoritativeWindow() : null + const rendererWindow = opts.rendererBacked === true ? availableAuthoritativeWindow : null const shouldCreateInBackground = worktreeSelector !== undefined && ((!requiresRendererFocus && opts.rendererBacked !== true) || @@ -17391,187 +18172,217 @@ export class OrcaRuntimeService { if (!this.ptyController?.spawn) { throw new Error('runtime_unavailable') } - const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector) - const launchOpts = await this.resolveAgentTerminalCreateOptions(workspace, opts) - const cwd = - this.resolveWorkspaceTerminalStartupCwd(workspace, launchOpts.cwd) ?? workspace.path - const preAllocatedHandle = this.createPreAllocatedTerminalHandle() - // Why: mint tabId in main before spawn so paneKey is known at PTY env - // build time. Hook-based agent status (Claude/Codex/Cursor/Gemini) keys - // off `${tabId}:${leafId}` — without these vars set on the PTY, the - // hook payload arrives with an empty paneKey and the renderer cannot - // attribute the event. Use a stable UUID leaf because hooks reject the - // legacy numeric pane keys after the pane-id migration. - const hintedTabId = launchOpts.tabId?.trim() - const canAdoptPaneIdentity = - hintedTabId !== undefined && - isValidHostTerminalTabId(hintedTabId) && - launchOpts.leafId !== undefined && - isTerminalLeafId(launchOpts.leafId) - const tabId = canAdoptPaneIdentity ? (hintedTabId as string) : randomUUID() - const leafId = canAdoptPaneIdentity ? (launchOpts.leafId as string) : randomUUID() - const paneKey = makePaneKey(tabId, leafId) - const launchToken = launchOpts.launchConfig - ? (launchOpts.launchToken ?? randomUUID()) - : undefined - const baseEnv = { - ...launchOpts.env, - ...(launchToken ? { ORCA_AGENT_LAUNCH_TOKEN: launchToken } : {}) - } - const claudeAgentTeamsSourceCommand = - launchOpts.claudeAgentTeamsSourceCommand?.trim() || launchOpts.command?.trim() || undefined - const claudeAgentTeamsMode = this.store?.getSettings?.().claudeAgentTeamsMode - const effectiveClaudeAgentTeamsMode = inferCapturedClaudeAgentTeamsMode( - launchOpts.launchConfig, - claudeAgentTeamsSourceCommand, - claudeAgentTeamsMode - ) - const agentTeamsPlan = await buildClaudeAgentTeamsLaunchPlan({ - command: claudeAgentTeamsSourceCommand, - mode: effectiveClaudeAgentTeamsMode, - baseEnv: { - ...process.env, - ...baseEnv - }, - createTeamEnv: (shimDir, shimBin) => - this.claudeAgentTeams.createLaunchEnv({ - leaderHandle: preAllocatedHandle, - baseEnv: { - ...process.env, - ...baseEnv - }, - shimDir, - shimBin - }).env - }) - const sequencedStartupCommand = - agentTeamsPlan && - claudeAgentTeamsSourceCommand && - launchOpts.command && - claudeAgentTeamsSourceCommand !== launchOpts.command - ? agentTeamsPlan.command + const { workspace, release: releasePreparationAdmission } = + await this.resolveTerminalCreateWorkspaceWithAdmission(worktreeSelector) + try { + if ( + this.isAdmissionBlocked( + this.terminalAdmissionBlockedWorktreeIds, + workspace.id, + workspace.hostId + ) + ) { + throw new Error('terminal_admission_blocked_for_project_removal') + } + const launchOpts = await this.resolveAgentTerminalCreateOptions(workspace, opts) + const cwd = + this.resolveWorkspaceTerminalStartupCwd(workspace, launchOpts.cwd) ?? workspace.path + const preAllocatedHandle = this.createPreAllocatedTerminalHandle() + // Why: mint tabId in main before spawn so paneKey is known at PTY env + // build time. Hook-based agent status (Claude/Codex/Cursor/Gemini) keys + // off `${tabId}:${leafId}` — without these vars set on the PTY, the + // hook payload arrives with an empty paneKey and the renderer cannot + // attribute the event. Use a stable UUID leaf because hooks reject the + // legacy numeric pane keys after the pane-id migration. + const hintedTabId = launchOpts.tabId?.trim() + const canAdoptPaneIdentity = + hintedTabId !== undefined && + isValidHostTerminalTabId(hintedTabId) && + launchOpts.leafId !== undefined && + isTerminalLeafId(launchOpts.leafId) + const tabId = canAdoptPaneIdentity ? (hintedTabId as string) : randomUUID() + const leafId = canAdoptPaneIdentity ? (launchOpts.leafId as string) : randomUUID() + const paneKey = makePaneKey(tabId, leafId) + const launchToken = launchOpts.launchConfig + ? (launchOpts.launchToken ?? randomUUID()) : undefined - const effectiveLaunchConfig = - launchOpts.launchConfig && agentTeamsPlan - ? { - ...launchOpts.launchConfig, - agentCommand: launchOpts.launchConfig.agentCommand - ? effectiveClaudeAgentTeamsMode === 'in-process' || process.platform === 'win32' - ? addClaudeTeammateModeInProcess(launchOpts.launchConfig.agentCommand) - : addClaudeTeammateModeAuto(launchOpts.launchConfig.agentCommand) - : agentTeamsPlan.command, - agentEnv: { - ...launchOpts.launchConfig.agentEnv, - ...agentTeamsPlan.env - } - } - : launchOpts.launchConfig - // Why: setup/agent sequencing wraps the PTY launch in a wait shell before - // Claude Agent Teams runs. Preserve the direct Claude command separately - // so the wrapper can exec the teammate-mode variant after setup completes. - const env = this.buildTerminalWorkspaceEnv( - workspace, - { - ...baseEnv, - ...(sequencedStartupCommand - ? { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: sequencedStartupCommand } - : {}) - }, - paneKey, - tabId, - agentTeamsPlan?.env - ) - const result = await this.ptyController.spawn({ - cols: 120, - rows: 40, - cwd, - command: sequencedStartupCommand - ? launchOpts.command - : (agentTeamsPlan?.command ?? launchOpts.command), - commandDelivery: 'provider', - startupCommandDelivery: launchOpts.startupCommandDelivery, - env, - envToDelete: agentTeamsPlan?.envToDelete, - telemetry: launchOpts.telemetry, - connectionId: workspace.connectionId, - worktreeId: workspace.id, - preAllocatedHandle, - tabId, - leafId, - ...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}), - ...(launchOpts.persistHostSessionBinding ? { persistHostSessionBinding: true } : {}) - }) - this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) - this.registerPty(result.id, workspace.id, workspace.connectionId) - const pty = this.getOrCreatePtyWorktreeRecord(result.id) - if (pty) { - if (launchOpts.title) { - const observedAt = this.nextTitleObservationSequence() - pty.title = launchOpts.title - pty.titleUpdatedAt = observedAt - this.setPtyManagementTitleFromObservedTitle(pty, launchOpts.title, observedAt) - } else { - pty.title = null - pty.titleUpdatedAt = null + const baseEnv = { + ...launchOpts.env, + ...(launchToken ? { ORCA_AGENT_LAUNCH_TOKEN: launchToken } : {}) } - pty.tabId = tabId - pty.paneKey = paneKey - pty.launchConfig = effectiveLaunchConfig - ? copySleepingAgentLaunchConfig(effectiveLaunchConfig) - : null - pty.launchToken = launchToken ?? null - pty.launchAgent = launchOpts.launchAgent ?? null - } - const handle = pty ? this.issuePtyHandle(pty) : preAllocatedHandle - if (pty && launchOpts.deferMobileSessionPublish !== true) { - this.publishPtyBackedMobileSessionTerminal(workspace.id, pty, { - tabId, - leafId, - title: launchOpts.title ?? null, - activate: presentation === 'focused', - // Why: explicit background presentation may carry legacy activate - // metadata from an already-owned renderer pane; don't select it on mobile. - selectIfNoActiveTab: presentation !== 'background', - ...(cwd !== workspace.path ? { startupCwd: cwd } : {}) + const claudeAgentTeamsSourceCommand = + launchOpts.claudeAgentTeamsSourceCommand?.trim() || + launchOpts.command?.trim() || + undefined + const claudeAgentTeamsMode = this.store?.getSettings?.().claudeAgentTeamsMode + const effectiveClaudeAgentTeamsMode = inferCapturedClaudeAgentTeamsMode( + launchOpts.launchConfig, + claudeAgentTeamsSourceCommand, + claudeAgentTeamsMode + ) + const agentTeamsPlan = await buildClaudeAgentTeamsLaunchPlan({ + command: claudeAgentTeamsSourceCommand, + mode: effectiveClaudeAgentTeamsMode, + baseEnv: { + ...process.env, + ...baseEnv + }, + createTeamEnv: (shimDir, shimBin) => + this.claudeAgentTeams.createLaunchEnv({ + leaderHandle: preAllocatedHandle, + baseEnv: { + ...process.env, + ...baseEnv + }, + shimDir, + shimBin + }).env }) - } - let surface: RuntimeTerminalCreate['surface'] = 'background' - let warning: string | undefined - if (presentation !== 'background' && this.notifier?.revealTerminalSession) { - try { - // Why: after the PTY is spawned, renderer tab adoption is best-effort; - // failing here must not strand a live process without returning a handle. - // Pass the pre-minted tabId so the renderer adopts under the same id - // already baked into the PTY env — keeps paneKey hook attribution intact. - await this.notifier.revealTerminalSession(workspace.id, { - ptyId: result.id, - title: launchOpts.title ?? null, - ...(cwd !== workspace.path ? { cwd } : {}), - ...(effectiveLaunchConfig ? { launchConfig: effectiveLaunchConfig } : {}), - ...(launchToken ? { launchToken } : {}), - ...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}), - activate: presentation === 'focused', - ...(presentation ? { presentation } : {}), - tabId, - leafId - }) - surface = 'visible' - } catch (err) { - console.warn(`[terminal-create] failed to create inactive tab for ${result.id}:`, err) - warning = createTerminalRevealWarning(handle, err) + const sequencedStartupCommand = + agentTeamsPlan && + claudeAgentTeamsSourceCommand && + launchOpts.command && + claudeAgentTeamsSourceCommand !== launchOpts.command + ? agentTeamsPlan.command + : undefined + const effectiveLaunchConfig = + launchOpts.launchConfig && agentTeamsPlan + ? { + ...launchOpts.launchConfig, + agentCommand: launchOpts.launchConfig.agentCommand + ? effectiveClaudeAgentTeamsMode === 'in-process' || process.platform === 'win32' + ? addClaudeTeammateModeInProcess(launchOpts.launchConfig.agentCommand) + : addClaudeTeammateModeAuto(launchOpts.launchConfig.agentCommand) + : agentTeamsPlan.command, + agentEnv: { + ...launchOpts.launchConfig.agentEnv, + ...agentTeamsPlan.env + } + } + : launchOpts.launchConfig + // Why: setup/agent sequencing wraps the PTY launch in a wait shell before + // Claude Agent Teams runs. Preserve the direct Claude command separately + // so the wrapper can exec the teammate-mode variant after setup completes. + const env = this.buildTerminalWorkspaceEnv( + workspace, + { + ...baseEnv, + ...(sequencedStartupCommand + ? { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: sequencedStartupCommand } + : {}) + }, + paneKey, + tabId, + agentTeamsPlan?.env + ) + const result = await this.withTerminalAdmission( + workspace.id, + async () => { + const spawned = await this.ptyController!.spawn!({ + cols: 120, + rows: 40, + cwd, + command: sequencedStartupCommand + ? launchOpts.command + : (agentTeamsPlan?.command ?? launchOpts.command), + commandDelivery: 'provider', + startupCommandDelivery: launchOpts.startupCommandDelivery, + env, + envToDelete: agentTeamsPlan?.envToDelete, + telemetry: launchOpts.telemetry, + connectionId: workspace.connectionId, + worktreeId: workspace.id, + preAllocatedHandle, + tabId, + leafId, + ...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}), + // Why: a headless-created pane has no renderer session writer. + // Re-check after preparation in case the window closed meanwhile. + ...(launchOpts.persistHostSessionBinding || + this.getAvailableAuthoritativeWindow() === null + ? { persistHostSessionBinding: true } + : {}) + }) + this.registerPreAllocatedHandleForPty(spawned.id, preAllocatedHandle) + this.registerPty(spawned.id, workspace.id, workspace.connectionId) + return spawned + }, + workspace.hostId + ) + const pty = this.getOrCreatePtyWorktreeRecord(result.id) + if (pty) { + if (launchOpts.title) { + const observedAt = this.nextTitleObservationSequence() + pty.title = launchOpts.title + pty.titleUpdatedAt = observedAt + this.setPtyManagementTitleFromObservedTitle(pty, launchOpts.title, observedAt) + } else { + pty.title = null + pty.titleUpdatedAt = null + } + pty.tabId = tabId + pty.paneKey = paneKey + pty.launchConfig = effectiveLaunchConfig + ? copySleepingAgentLaunchConfig(effectiveLaunchConfig) + : null + pty.launchToken = launchToken ?? null + pty.launchAgent = launchOpts.launchAgent ?? null } - } else if (presentation !== 'background') { - warning = createTerminalRevealWarning(handle) - } - return { - handle, - tabId, - paneKey, - ptyId: result.id, - worktreeId: workspace.id, - title: launchOpts.title ?? null, - surface, - ...(warning ? { warning } : {}) + const handle = pty ? this.issuePtyHandle(pty) : preAllocatedHandle + if (pty && launchOpts.deferMobileSessionPublish !== true) { + this.publishPtyBackedMobileSessionTerminal(workspace.id, pty, { + tabId, + leafId, + title: launchOpts.title ?? null, + activate: presentation === 'focused', + // Why: explicit background presentation may carry legacy activate + // metadata from an already-owned renderer pane; don't select it on mobile. + selectIfNoActiveTab: presentation !== 'background', + ...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}), + ...(cwd !== workspace.path ? { startupCwd: cwd } : {}) + }) + } + let surface: RuntimeTerminalCreate['surface'] = 'background' + let warning: string | undefined + if (presentation !== 'background' && this.notifier?.revealTerminalSession) { + try { + // Why: after the PTY is spawned, renderer tab adoption is best-effort; + // failing here must not strand a live process without returning a handle. + // Pass the pre-minted tabId so the renderer adopts under the same id + // already baked into the PTY env — keeps paneKey hook attribution intact. + await this.notifier.revealTerminalSession(workspace.id, { + ptyId: result.id, + title: launchOpts.title ?? null, + ...(cwd !== workspace.path ? { cwd } : {}), + ...(effectiveLaunchConfig ? { launchConfig: effectiveLaunchConfig } : {}), + ...(launchToken ? { launchToken } : {}), + ...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}), + ...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}), + activate: presentation === 'focused', + ...(presentation ? { presentation } : {}), + tabId, + leafId + }) + surface = 'visible' + } catch (err) { + console.warn(`[terminal-create] failed to create inactive tab for ${result.id}:`, err) + warning = createTerminalRevealWarning(handle, err) + } + } else if (presentation !== 'background') { + warning = createTerminalRevealWarning(handle) + } + return { + handle, + tabId, + paneKey, + ptyId: result.id, + worktreeId: workspace.id, + title: launchOpts.title ?? null, + surface, + ...(warning ? { warning } : {}) + } + } finally { + releasePreparationAdmission() } } @@ -17579,69 +18390,105 @@ export class OrcaRuntimeService { const win = rendererWindow ?? this.getAuthoritativeWindow() // Why: mirrors browserTabCreate — when no worktree is specified, pass // undefined so the renderer uses its current active worktree. - const workspace = worktreeSelector - ? await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector) + const admittedWorkspace = worktreeSelector + ? await this.resolveTerminalCreateWorkspaceWithAdmission(worktreeSelector) : null - const launchOpts = workspace - ? await this.resolveAgentTerminalCreateOptions(workspace, opts) - : opts - const worktreeId = workspace?.id - const cwd = workspace - ? this.resolveWorkspaceTerminalStartupCwd(workspace, launchOpts.cwd) - : launchOpts.cwd - const requestId = randomUUID() - - // Why: terminal creation is a renderer-side Zustand store operation (like - // browser tab creation). The main process sends a request, the renderer - // creates the tab and replies with the tabId so we can resolve the handle. - const reply = await new Promise<{ tabId: string; title: string }>((resolve, reject) => { - const timer = setTimeout(() => { - ipcMain.removeListener('terminal:tabCreateReply', handler) - reject(new Error('Terminal creation timed out')) - }, 10_000) - - const handler = ( - event: Electron.IpcMainEvent, - r: { requestId: string; tabId?: string; title?: string; error?: string } - ): void => { - if (event.sender !== win.webContents || r.requestId !== requestId) { - return - } - clearTimeout(timer) - ipcMain.removeListener('terminal:tabCreateReply', handler) - if (r.error) { - reject(new Error(r.error)) - } else { - resolve({ tabId: r.tabId!, title: r.title ?? launchOpts.title ?? '' }) - } + const workspace = admittedWorkspace?.workspace ?? null + // Why: renderer-backed agent preparation can await remote trust before the + // tab request; keep project removal fenced across that entire interval. + const releasePreparationAdmission = admittedWorkspace?.release ?? null + try { + if ( + workspace && + this.isAdmissionBlocked( + this.terminalAdmissionBlockedWorktreeIds, + workspace.id, + workspace.hostId + ) + ) { + throw new Error('terminal_admission_blocked_for_project_removal') + } + const launchOpts = workspace + ? await this.resolveAgentTerminalCreateOptions(workspace, opts) + : opts + const worktreeId = workspace?.id + const cwd = workspace + ? this.resolveWorkspaceTerminalStartupCwd(workspace, launchOpts.cwd) + : launchOpts.cwd + const requestId = randomUUID() + if ( + workspace && + this.isAdmissionBlocked( + this.terminalAdmissionBlockedWorktreeIds, + workspace.id, + workspace.hostId + ) + ) { + throw new Error('terminal_admission_blocked_for_project_removal') } - ipcMain.on('terminal:tabCreateReply', handler) - win.webContents.send('terminal:requestTabCreate', { - requestId, - worktreeId, - command: launchOpts.command, - cwd, - ...(launchOpts.env ? { env: launchOpts.env } : {}), - ...(launchOpts.launchConfig ? { launchConfig: launchOpts.launchConfig } : {}), - ...(launchOpts.launchToken ? { launchToken: launchOpts.launchToken } : {}), - ...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}), - startupCommandDelivery: launchOpts.startupCommandDelivery, - title: launchOpts.title, - activate: presentation === 'focused', - ...(presentation ? { presentation } : {}) - }) - }) - // Why: the renderer created the tab immediately, but the graph sync that - // populates this.leaves may not have arrived yet. Wait for the leaf to - // appear so we can return a valid handle the caller can use right away. - const handle = await this.waitForTerminalHandle(reply.tabId) - return { - handle, - tabId: reply.tabId, - worktreeId: worktreeId ?? '', - title: reply.title, - surface: 'visible' + // Why: terminal creation is a renderer-side Zustand store operation (like + // browser tab creation). The main process sends a request, the renderer + // creates the tab and replies with the tabId so we can resolve the handle. + const createRendererTerminal = async (): Promise<{ + reply: { tabId: string; title: string } + handle: string + }> => { + const reply = await new Promise<{ tabId: string; title: string }>((resolve, reject) => { + const timer = setTimeout(() => { + ipcMain.removeListener('terminal:tabCreateReply', handler) + reject(new Error('Terminal creation timed out')) + }, 10_000) + + const handler = ( + event: Electron.IpcMainEvent, + r: { requestId: string; tabId?: string; title?: string; error?: string } + ): void => { + if (event.sender !== win.webContents || r.requestId !== requestId) { + return + } + clearTimeout(timer) + ipcMain.removeListener('terminal:tabCreateReply', handler) + if (r.error) { + reject(new Error(r.error)) + } else { + resolve({ tabId: r.tabId!, title: r.title ?? launchOpts.title ?? '' }) + } + } + ipcMain.on('terminal:tabCreateReply', handler) + win.webContents.send('terminal:requestTabCreate', { + requestId, + worktreeId, + command: launchOpts.command, + cwd, + ...(launchOpts.env ? { env: launchOpts.env } : {}), + ...(launchOpts.launchConfig ? { launchConfig: launchOpts.launchConfig } : {}), + ...(launchOpts.launchToken ? { launchToken: launchOpts.launchToken } : {}), + ...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}), + ...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}), + startupCommandDelivery: launchOpts.startupCommandDelivery, + title: launchOpts.title, + activate: presentation === 'focused', + ...(presentation ? { presentation } : {}) + }) + }) + + // Why: keep admission active until graph sync publishes the created PTY; + // project removal can then drain this create before its liveness snapshot. + return { reply, handle: await this.waitForTerminalHandle(reply.tabId) } + } + const { reply, handle } = workspace + ? await this.withTerminalAdmission(workspace.id, createRendererTerminal, workspace.hostId) + : await this.withTerminalAdmission(GLOBAL_TERMINAL_ADMISSION_KEY, createRendererTerminal) + return { + handle, + tabId: reply.tabId, + worktreeId: worktreeId ?? '', + title: reply.title, + surface: 'visible' + } + } finally { + releasePreparationAdmission?.() } } @@ -17683,6 +18530,7 @@ export class OrcaRuntimeService { agent?: TuiAgent launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' activate?: boolean clientMutationId?: string signal?: AbortSignal @@ -17727,183 +18575,205 @@ export class OrcaRuntimeService { agent?: TuiAgent launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' activate?: boolean clientMutationId?: string signal?: AbortSignal } = {} ): Promise { this.assertGraphReady() - const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector) - const worktreeId = workspace.id - const cwd = this.resolveWorkspaceTerminalStartupCwd(workspace, opts.cwd) - this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) - let afterDesktopTabId: string | undefined - if (opts.afterTabId) { - const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) - const anchor = snapshot?.tabs.find((tab) => tab.id === opts.afterTabId) - if (!anchor) { - throw new Error('after_tab_not_found') - } - afterDesktopTabId = anchor.type === 'terminal' ? anchor.parentTabId : anchor.id - } - const startupCommand = await this.resolveMobileSessionTerminalCommand(workspace, opts) - - const win = this.getAvailableAuthoritativeWindow() - if (!win) { - return await this.createHeadlessMobileSessionTerminal( - worktreeId, - opts.activate !== false, - opts.afterTabId, - { - command: startupCommand.command, - cwd, - env: startupCommand.env, - startupCommandDelivery: startupCommand.startupCommandDelivery, - launchAgent: startupCommand.launchAgent, - targetGroupId: opts.targetGroupId, - launchConfig: startupCommand.launchConfig - } - ) - } - const requestId = randomUUID() - const reply = await new Promise<{ tabId: string; title: string }>((resolve, reject) => { - const timer = setTimeout(() => { - ipcMain.removeListener('terminal:tabCreateReply', handler) - opts.signal?.removeEventListener('abort', onAbort) - reject(new Error('Terminal creation timed out')) - }, 10_000) - // Why: a dead client connection cancels the wait; the renderer tab (and - // its shell) stays alive for the host and mirrors on reconnect (#7718). - const onAbort = (): void => { - clearTimeout(timer) - ipcMain.removeListener('terminal:tabCreateReply', handler) - reject(new Error('client_disconnected')) - } - - const handler = ( - event: Electron.IpcMainEvent, - r: { requestId: string; tabId?: string; title?: string; error?: string } - ): void => { - if (event.sender !== win.webContents || r.requestId !== requestId) { - return - } - clearTimeout(timer) - ipcMain.removeListener('terminal:tabCreateReply', handler) - opts.signal?.removeEventListener('abort', onAbort) - if (r.error) { - reject(new Error(r.error)) - } else { - resolve({ tabId: r.tabId!, title: r.title ?? '' }) - } - } - opts.signal?.addEventListener('abort', onAbort, { once: true }) - ipcMain.on('terminal:tabCreateReply', handler) - win.webContents.send('terminal:requestTabCreate', { - requestId, - worktreeId, - afterTabId: afterDesktopTabId, - targetGroupId: opts.targetGroupId, - command: startupCommand.command, - cwd, - ...(startupCommand.env ? { env: startupCommand.env } : {}), - ...(startupCommand.launchConfig ? { launchConfig: startupCommand.launchConfig } : {}), - ...(startupCommand.launchAgent ? { launchAgent: startupCommand.launchAgent } : {}), - startupCommandDelivery: startupCommand.startupCommandDelivery, - source: 'runtime-session', - activate: opts.activate - }) - }) - - if (opts.activate !== false) { - this.notifier?.focusTerminal(reply.tabId, worktreeId, null) - } - // Why: register the wait before the renderer's PTY spawn arrives so that - // spawn (registerPty) can publish the pty-backed surface main-side even if - // graph-sync is stalled (#7587). Removed in the finally below. - const pendingCreateKey = `${worktreeId}::${reply.tabId}` - // Why: a rescue publishes into the active group (opts.targetGroupId is not - // threaded); the renderer's reconciling publication then moves the tab to the - // requested group, so any wrong-group placement is cosmetic and stall-window-only. - this.pendingMobileTerminalCreatesByKey.set(pendingCreateKey, { - activate: opts.activate !== false, - selectIfNoActiveTab: true - }) + const { workspace, release: releasePreparationAdmission } = + await this.resolveTerminalCreateWorkspaceWithAdmission(worktreeSelector) try { - // Why: the PTY spawn and the tabCreate reply race on independent IPC - // channels; if the spawn already registered, publish immediately so the - // wait resolves without depending on a graph sync. - this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, reply.tabId) - const surface = await this.waitForMobileTerminalSurface(worktreeId, reply.tabId, { - timeoutMs: MOBILE_TERMINAL_SURFACE_TIMEOUT_MS, - signal: opts.signal - }) - if (this.isReadyMobileTerminalSurface(surface)) { - return surface + const worktreeId = workspace.id + const cwd = this.resolveWorkspaceTerminalStartupCwd(workspace, opts.cwd) + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) + let afterDesktopTabId: string | undefined + if (opts.afterTabId) { + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + const anchor = snapshot?.tabs.find((tab) => tab.id === opts.afterTabId) + if (!anchor) { + throw new Error('after_tab_not_found') + } + afterDesktopTabId = anchor.type === 'terminal' ? anchor.parentTabId : anchor.id } - const readySurface = await this.waitForMobileTerminalSurface(worktreeId, reply.tabId, { - timeoutMs: MOBILE_TERMINAL_READY_FALLBACK_MS, - requireReady: true, - signal: opts.signal - }).catch(() => null) - if (readySurface) { - return readySurface + const startupCommand = await this.resolveMobileSessionTerminalCommand(workspace, opts) + + const win = this.getAvailableAuthoritativeWindow() + if (!win) { + return await this.createHeadlessMobileSessionTerminal( + worktreeId, + opts.activate !== false, + opts.afterTabId, + { + command: startupCommand.command, + cwd, + env: startupCommand.env, + startupCommandDelivery: startupCommand.startupCommandDelivery, + launchAgent: startupCommand.launchAgent, + viewMode: opts.viewMode, + targetGroupId: opts.targetGroupId, + launchConfig: startupCommand.launchConfig + } + ) } - if (opts.signal?.aborted) { - // Why: nobody is waiting for this create anymore; do not materialize - // or roll back — the renderer's own publication settles the tab. - throw new Error('client_disconnected') - } - const pendingSurface = this.findMobileTerminalSurface(worktreeId, reply.tabId) - if (!pendingSurface) { - throw new Error('Timed out waiting for terminal surface after creation') - } - // Why: hidden/occluded renderer windows can publish the tab shell before - // TerminalPane mounts and spawns the PTY. Materialize into the same - // identity so later renderer focus adopts instead of creating another tab. - return await this.createHeadlessMobileSessionTerminal( + return await this.withTerminalAdmission( worktreeId, - opts.activate !== false, - opts.afterTabId, - { - command: startupCommand.command, - cwd, - env: startupCommand.env, - startupCommandDelivery: startupCommand.startupCommandDelivery, - identity: { tabId: pendingSurface.tab.parentTabId, leafId: pendingSurface.tab.leafId }, - launchAgent: startupCommand.launchAgent, - targetGroupId: opts.targetGroupId, - launchConfig: startupCommand.launchConfig - } + async () => { + const requestId = randomUUID() + const reply = await new Promise<{ tabId: string; title: string }>((resolve, reject) => { + const timer = setTimeout(() => { + ipcMain.removeListener('terminal:tabCreateReply', handler) + opts.signal?.removeEventListener('abort', onAbort) + reject(new Error('Terminal creation timed out')) + }, 10_000) + // Why: a dead client connection cancels the wait; the renderer tab (and + // its shell) stays alive for the host and mirrors on reconnect (#7718). + const onAbort = (): void => { + clearTimeout(timer) + ipcMain.removeListener('terminal:tabCreateReply', handler) + reject(new Error('client_disconnected')) + } + + const handler = ( + event: Electron.IpcMainEvent, + r: { requestId: string; tabId?: string; title?: string; error?: string } + ): void => { + if (event.sender !== win.webContents || r.requestId !== requestId) { + return + } + clearTimeout(timer) + ipcMain.removeListener('terminal:tabCreateReply', handler) + opts.signal?.removeEventListener('abort', onAbort) + if (r.error) { + reject(new Error(r.error)) + } else { + resolve({ tabId: r.tabId!, title: r.title ?? '' }) + } + } + opts.signal?.addEventListener('abort', onAbort, { once: true }) + ipcMain.on('terminal:tabCreateReply', handler) + win.webContents.send('terminal:requestTabCreate', { + requestId, + worktreeId, + afterTabId: afterDesktopTabId, + targetGroupId: opts.targetGroupId, + command: startupCommand.command, + cwd, + ...(startupCommand.env ? { env: startupCommand.env } : {}), + ...(startupCommand.launchConfig ? { launchConfig: startupCommand.launchConfig } : {}), + ...(startupCommand.launchAgent ? { launchAgent: startupCommand.launchAgent } : {}), + ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), + startupCommandDelivery: startupCommand.startupCommandDelivery, + source: 'runtime-session', + activate: opts.activate + }) + }) + + if (opts.activate !== false) { + this.notifier?.focusTerminal(reply.tabId, worktreeId, null) + } + // Why: register the wait before the renderer's PTY spawn arrives so that + // spawn (registerPty) can publish the pty-backed surface main-side even if + // graph-sync is stalled (#7587). Removed in the finally below. + const pendingCreateKey = `${worktreeId}::${reply.tabId}` + // Why: a rescue publishes into the active group (opts.targetGroupId is not + // threaded); the renderer's reconciling publication then moves the tab to the + // requested group, so any wrong-group placement is cosmetic and stall-window-only. + this.pendingMobileTerminalCreatesByKey.set(pendingCreateKey, { + activate: opts.activate !== false, + selectIfNoActiveTab: true, + ...(opts.viewMode ? { viewMode: opts.viewMode } : {}) + }) + try { + // Why: the PTY spawn and the tabCreate reply race on independent IPC + // channels; if the spawn already registered, publish immediately so the + // wait resolves without depending on a graph sync. + this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, reply.tabId) + const surface = await this.waitForMobileTerminalSurface(worktreeId, reply.tabId, { + timeoutMs: MOBILE_TERMINAL_SURFACE_TIMEOUT_MS, + signal: opts.signal + }) + if (this.isReadyMobileTerminalSurface(surface)) { + return surface + } + const readySurface = await this.waitForMobileTerminalSurface(worktreeId, reply.tabId, { + timeoutMs: MOBILE_TERMINAL_READY_FALLBACK_MS, + requireReady: true, + signal: opts.signal + }).catch(() => null) + if (readySurface) { + return readySurface + } + if (opts.signal?.aborted) { + // Why: nobody is waiting for this create anymore; do not materialize + // or roll back — the renderer's own publication settles the tab. + throw new Error('client_disconnected') + } + const pendingSurface = this.findMobileTerminalSurface(worktreeId, reply.tabId) + if (!pendingSurface) { + throw new Error('Timed out waiting for terminal surface after creation') + } + // Why: hidden/occluded renderer windows can publish the tab shell before + // TerminalPane mounts and spawns the PTY. Materialize into the same + // identity so later renderer focus adopts instead of creating another tab. + return await this.createHeadlessMobileSessionTerminal( + worktreeId, + opts.activate !== false, + opts.afterTabId, + { + command: startupCommand.command, + cwd, + env: startupCommand.env, + startupCommandDelivery: startupCommand.startupCommandDelivery, + identity: { + tabId: pendingSurface.tab.parentTabId, + leafId: pendingSurface.tab.leafId + }, + launchAgent: startupCommand.launchAgent, + viewMode: opts.viewMode, + targetGroupId: opts.targetGroupId, + launchConfig: startupCommand.launchConfig + } + ) + } catch (error) { + // Why: publication latency (throttled/hidden renderer), not spawn failure, + // can trip the surface timeout. Rescue only when a live PTY actually backs + // the tab — gating on a surface would let a handle-less shell (or a failed + // materialize) resolve as success and skip the ghost-tab rollback (#7587). + if (this.findLiveRegisteredPtyForRendererTab(worktreeId, reply.tabId)) { + const rescued = this.ensurePtyBackedMobileSurfaceForRendererTab( + worktreeId, + reply.tabId + ) + if (rescued) { + return rescued + } + } + // Why: don't roll back when (a) the client connection died — the wait + // was cancelled, not the spawn — or (b) a live shell already backs the + // tab (its pane key may simply not be registered yet). Killing a real + // terminal the host user can see is the "tab dies after ~10s" bug (#7718). + if ( + isClientDisconnectedError(error) || + this.hasLiveShellForRendererTab(worktreeId, reply.tabId) + ) { + throw error + } + // Why: the renderer created the tab but no live PTY backs it (true PTY + // spawn/handle failure). Roll the half-created tab back via the renderer + // close path so it can't linger as a ghost in mobile snapshots, then + // surface the failure to the caller. + this.notifier?.closeTerminal(reply.tabId) + throw error + } finally { + this.pendingMobileTerminalCreatesByKey.delete(pendingCreateKey) + } + }, + workspace.hostId ) - } catch (error) { - // Why: publication latency (throttled/hidden renderer), not spawn failure, - // can trip the surface timeout. Rescue only when a live PTY actually backs - // the tab — gating on a surface would let a handle-less shell (or a failed - // materialize) resolve as success and skip the ghost-tab rollback (#7587). - if (this.findLiveRegisteredPtyForRendererTab(worktreeId, reply.tabId)) { - const rescued = this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, reply.tabId) - if (rescued) { - return rescued - } - } - // Why: don't roll back when (a) the client connection died — the wait - // was cancelled, not the spawn — or (b) a live shell already backs the - // tab (its pane key may simply not be registered yet). Killing a real - // terminal the host user can see is the "tab dies after ~10s" bug (#7718). - if ( - isClientDisconnectedError(error) || - this.hasLiveShellForRendererTab(worktreeId, reply.tabId) - ) { - throw error - } - // Why: the renderer created the tab but no live PTY backs it (true PTY - // spawn/handle failure). Roll the half-created tab back via the renderer - // close path so it can't linger as a ghost in mobile snapshots, then - // surface the failure to the caller. - this.notifier?.closeTerminal(reply.tabId) - throw error } finally { - this.pendingMobileTerminalCreatesByKey.delete(pendingCreateKey) + releasePreparationAdmission() } } @@ -17994,6 +18864,7 @@ export class OrcaRuntimeService { startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] identity?: { tabId: string; leafId: string; sessionId?: string } launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' targetGroupId?: string launchConfig?: SleepingAgentLaunchConfig } = {} @@ -18011,6 +18882,7 @@ export class OrcaRuntimeService { env: opts.env, ...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}), ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), + ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), startupCommandDelivery: opts.startupCommandDelivery, ...(opts.identity ? { @@ -18032,6 +18904,11 @@ export class OrcaRuntimeService { } const parentTabId = livePty.pty.tabId ?? `pty:${livePty.pty.ptyId}` const leafId = parsePaneKey(livePty.pty.paneKey ?? '')?.leafId ?? randomUUID() + if (opts.viewMode) { + // Why: the runtime-owned binding must survive a serve restart with the + // same initial mode, not fall back to a later client's local default. + this.persistHeadlessSessionTabProps(worktreeId, parentTabId, { viewMode: opts.viewMode }) + } const existing = this.mobileSessionTabsByWorktree.get(worktreeId) const existingSurface = existing?.tabs.find( @@ -18054,6 +18931,7 @@ export class OrcaRuntimeService { title: terminal.title ?? livePty.pty.title ?? 'Terminal', ...(cwd ? { startupCwd: cwd } : {}), ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), + ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), parentLayout, isActive: activate } @@ -18200,21 +19078,27 @@ export class OrcaRuntimeService { return null } const existing = this.findMobileTerminalSurface(worktreeId, tabId) - if (existing) { - // Why: the renderer's own publication already landed; stay idempotent. + if ( + existing && + this.isReadyMobileTerminalSurface(existing) && + (pending.viewMode === undefined || existing.tab.viewMode === pending.viewMode) + ) { + // Why: the renderer's ready publication already landed with the intended + // mode; only a pending shell still needs the main-side PTY rescue. return existing } const pty = this.findLiveRegisteredPtyForRendererTab(worktreeId, tabId) const leafId = pty ? parsePaneKey(pty.paneKey ?? '')?.leafId : undefined if (!pty || !leafId) { - return null + return existing } this.publishPtyBackedMobileSessionTerminal(worktreeId, pty, { tabId, leafId, title: null, activate: pending.activate, - selectIfNoActiveTab: pending.selectIfNoActiveTab + selectIfNoActiveTab: pending.selectIfNoActiveTab, + ...(pending.viewMode ? { viewMode: pending.viewMode } : {}) }) // Why: waitForMobileTerminalSurface's check closures are drained only inside // syncWindowGraph; a main-side publish must drain them too or the pending @@ -18420,17 +19304,29 @@ export class OrcaRuntimeService { async closeTerminal(handle: string): Promise { const pty = this.getLivePtyForHandle(handle) - this.claudeAgentTeams.removeTeamForLeaderHandle(handle) if (pty) { - const ptyKilled = this.ptyController?.kill(pty.pty.ptyId) ?? false - return { handle, tabId: pty.pty.tabId ?? pty.record.tabId, ptyKilled } + if (!this.ptyController?.stopAndWait) { + throw new Error('terminal_verified_stop_unavailable') + } + if (!(await this.ptyController.stopAndWait(pty.pty.ptyId))) { + throw new Error('terminal_verified_stop_failed') + } + this.claudeAgentTeams.removeTeamForLeaderHandle(handle) + return { handle, tabId: pty.pty.tabId ?? pty.record.tabId, ptyKilled: true } } this.assertGraphReady() const { leaf } = this.getLiveLeafForHandle(handle) let ptyKilled = false if (leaf.ptyId) { - ptyKilled = this.ptyController?.kill(leaf.ptyId) ?? false + if (!this.ptyController?.stopAndWait) { + throw new Error('terminal_verified_stop_unavailable') + } + ptyKilled = await this.ptyController.stopAndWait(leaf.ptyId) + if (!ptyKilled) { + throw new Error('terminal_verified_stop_failed') + } } + this.claudeAgentTeams.removeTeamForLeaderHandle(handle) // Why: killing the PTY in a multi-pane tab is sufficient — the renderer's // PTY exit handler already calls PaneManager.closePane() for split layouts. // Sending an additional IPC close would race with the exit handler and @@ -18473,14 +19369,24 @@ export class OrcaRuntimeService { } } - this.notifier?.splitTerminal(leaf.tabId, leaf.paneRuntimeId, { - direction, - command: opts.command, - telemetrySource: opts.telemetrySource - }) + const leafPty = leaf.ptyId ? this.ptysById.get(leaf.ptyId) : undefined + const leafHostId = leafPty?.connectionId + ? toSshExecutionHostId(leafPty.connectionId) + : LOCAL_EXECUTION_HOST_ID + return await this.withTerminalAdmission( + leaf.worktreeId, + async () => { + this.notifier?.splitTerminal(leaf.tabId, leaf.paneRuntimeId, { + direction, + command: opts.command, + telemetrySource: opts.telemetrySource + }) - const newHandle = await this.waitForNewLeafInTab(leaf.tabId, leafKeysBefore) - return { handle: newHandle, tabId: leaf.tabId, paneRuntimeId: leaf.paneRuntimeId } + const newHandle = await this.waitForNewLeafInTab(leaf.tabId, leafKeysBefore) + return { handle: newHandle, tabId: leaf.tabId, paneRuntimeId: leaf.paneRuntimeId } + }, + leafHostId + ) } private async splitPtyBackedTerminal( @@ -18506,65 +19412,93 @@ export class OrcaRuntimeService { throw new Error('terminal_handle_stale') } const direction = opts.direction ?? 'horizontal' - const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${pty.worktreeId}`) - const leafId = randomUUID() - const preAllocatedHandle = this.createPreAllocatedTerminalHandle() - const paneKey = makePaneKey(parentTabId, leafId) - const result = await this.ptyController.spawn({ - cols: 120, - rows: 40, - cwd: workspace.path, - command: opts.command, - commandDelivery: 'provider', - env: this.buildTerminalWorkspaceEnv(workspace, opts.env ?? {}, paneKey, parentTabId), - envToDelete: opts.envToDelete, - connectionId: workspace.connectionId, - worktreeId: workspace.id, - preAllocatedHandle - }) - this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) - this.registerPty(result.id, workspace.id, workspace.connectionId) - const createdPty = this.getOrCreatePtyWorktreeRecord(result.id) - if (createdPty) { - createdPty.tabId = parentTabId - createdPty.paneKey = paneKey - } - + const ptyHostId = pty.connectionId + ? toSshExecutionHostId(pty.connectionId) + : LOCAL_EXECUTION_HOST_ID + const { workspace, release: releasePreparationAdmission } = + await this.resolveTerminalCreateWorkspaceWithAdmission(`id:${pty.worktreeId}`, ptyHostId) try { - await this.notifier?.revealTerminalSession?.(workspace.id, { - ptyId: result.id, - title: null, - activate: opts.activate !== false, - tabId: parentTabId, - leafId, - splitFromLeafId: parsedPaneKey.leafId, - splitDirection: direction, - splitTelemetrySource: opts.telemetrySource - }) - } catch (error) { - this.ptyController.kill?.(result.id) - throw error - } - if (createdPty) { - this.publishPtyBackedMobileSessionTerminal(workspace.id, createdPty, { - tabId: parentTabId, - leafId, - title: null, - activate: opts.activate !== false, - split: { splitFromLeafId: parsedPaneKey.leafId, direction } - }) - // Why: persist the split into the workspace session so a later snapshot - // rebuild keeps it instead of collapsing back to a single pane. - this.persistHeadlessTerminalSplit({ - tabId: parentTabId, - leafId, - ptyId: createdPty.ptyId, - splitFromLeafId: parsedPaneKey.leafId, - direction - }) - } + if ( + this.isAdmissionBlocked( + this.terminalAdmissionBlockedWorktreeIds, + workspace.id, + workspace.hostId + ) + ) { + throw new Error('terminal_admission_blocked_for_project_removal') + } + const leafId = randomUUID() + const preAllocatedHandle = this.createPreAllocatedTerminalHandle() + const paneKey = makePaneKey(parentTabId, leafId) + const result = await this.withTerminalAdmission( + workspace.id, + async () => { + const spawned = await this.ptyController!.spawn!({ + cols: 120, + rows: 40, + cwd: workspace.path, + command: opts.command, + commandDelivery: 'provider', + env: this.buildTerminalWorkspaceEnv(workspace, opts.env ?? {}, paneKey, parentTabId), + envToDelete: opts.envToDelete, + connectionId: workspace.connectionId, + worktreeId: workspace.id, + preAllocatedHandle + }) + this.registerPreAllocatedHandleForPty(spawned.id, preAllocatedHandle) + this.registerPty(spawned.id, workspace.id, workspace.connectionId) + return spawned + }, + workspace.hostId + ) + const createdPty = this.getOrCreatePtyWorktreeRecord(result.id) + if (createdPty) { + createdPty.tabId = parentTabId + createdPty.paneKey = paneKey + } - return { handle: this.issuePtyHandle(createdPty ?? pty), tabId: parentTabId, paneRuntimeId: -1 } + try { + await this.notifier?.revealTerminalSession?.(workspace.id, { + ptyId: result.id, + title: null, + activate: opts.activate !== false, + tabId: parentTabId, + leafId, + splitFromLeafId: parsedPaneKey.leafId, + splitDirection: direction, + splitTelemetrySource: opts.telemetrySource + }) + } catch (error) { + this.ptyController.kill?.(result.id) + throw error + } + if (createdPty) { + this.publishPtyBackedMobileSessionTerminal(workspace.id, createdPty, { + tabId: parentTabId, + leafId, + title: null, + activate: opts.activate !== false, + split: { splitFromLeafId: parsedPaneKey.leafId, direction } + }) + // Why: persist the split into the workspace session so a later snapshot + // rebuild keeps it instead of collapsing back to a single pane. + this.persistHeadlessTerminalSplit({ + tabId: parentTabId, + leafId, + ptyId: createdPty.ptyId, + splitFromLeafId: parsedPaneKey.leafId, + direction + }) + } + + return { + handle: this.issuePtyHandle(createdPty ?? pty), + tabId: parentTabId, + paneRuntimeId: -1 + } + } finally { + releasePreparationAdmission() + } } async handleAgentTeamsTmuxCompat( @@ -18656,31 +19590,72 @@ export class OrcaRuntimeService { }) } - async stopTerminalsForWorktree(worktreeSelector: string): Promise<{ stopped: number }> { - // Why: this mutates live PTYs, so the runtime must reject it while the - // renderer graph is reloading instead of acting on cached leaf ownership. - const graphEpoch = this.captureReadyGraphEpoch() - const worktree = await this.resolveWorktreeSelector(worktreeSelector) - this.assertStableReadyGraph(graphEpoch) - const ptyIds = new Set() - for (const leaf of this.leaves.values()) { - if (leaf.worktreeId === worktree.id && leaf.ptyId) { - ptyIds.add(leaf.ptyId) - } + async stopTerminalsForWorktree( + worktreeSelector: string, + options: { allowUnavailableGraph?: boolean; hostId?: ExecutionHostId | null } = {} + ): Promise<{ stopped: number }> { + // Why: fresh provider liveness, not a cached renderer graph, is shutdown + // authority. When a graph is ready, still reject epoch changes mid-proof; + // headless/boot removal can proceed from the same fresh provider snapshot. + const graphEpoch = options.allowUnavailableGraph + ? this.graphStatus === 'ready' + ? this.captureReadyGraphEpoch() + : null + : this.captureReadyGraphEpoch() + const worktree = await this.resolveWorktreeSelectorForExecutionHost( + worktreeSelector, + options.hostId + ) + const connectionId = this.connectionIdForExecutionHost(options.hostId) + if (graphEpoch !== null) { + this.assertStableReadyGraph(graphEpoch) } - for (const pty of this.ptysById.values()) { - if (pty.worktreeId === worktree.id && pty.connected) { - ptyIds.add(pty.ptyId) - } + const resolvedWorktrees = [...(await this.getResolvedWorktreeMap()).values()] + if (graphEpoch !== null) { + this.assertStableReadyGraph(graphEpoch) + } + const refreshedPtyLiveness = await this.refreshPtyWorktreeRecordsFromController( + resolvedWorktrees, + worktree.id, + connectionId + ) + if (!refreshedPtyLiveness) { + throw new Error('terminal_liveness_unavailable') + } + const ptyIds = this.getLivePtyIdsForWorktree(worktree.id, refreshedPtyLiveness, connectionId) + if (ptyIds.size === 0) { + return { stopped: 0 } + } + if (!this.ptyController?.stopAndWait) { + throw new Error('terminal_verified_stop_unavailable') } - let stopped = 0 - for (const ptyId of ptyIds) { - if (this.ptyController?.kill(ptyId)) { - stopped += 1 + const stoppedPtyIds: string[] = [] + for (const ptyId of [...ptyIds].sort()) { + if (!(await this.ptyController.stopAndWait(ptyId))) { + throw Object.assign(new Error('terminal_verified_stop_failed'), { ptyId }) } + stoppedPtyIds.push(ptyId) } - return { stopped } + const postStopLiveness = await this.refreshPtyWorktreeRecordsFromController( + resolvedWorktrees, + worktree.id, + connectionId + ) + if (!postStopLiveness) { + throw new Error('terminal_stop_postcheck_unavailable') + } + const remainingLivePtyIds = this.getLivePtyIdsForWorktree( + worktree.id, + postStopLiveness, + connectionId + ) + if (remainingLivePtyIds.size > 0) { + throw Object.assign(new Error('terminal_stop_still_live'), { + remainingLivePtyIds: [...remainingLivePtyIds].sort() + }) + } + return { stopped: stoppedPtyIds.length } } async stopExactTerminalsForWorktree( @@ -18767,14 +19742,17 @@ export class OrcaRuntimeService { private getLivePtyIdsForWorktree( worktreeId: string, - freshPtyIds?: ReadonlySet + freshPtyIds?: ReadonlySet, + connectionId?: string | null ): Set { const ptyIds = new Set() for (const leaf of this.leaves.values()) { + const pty = leaf.ptyId ? this.ptysById.get(leaf.ptyId) : undefined if ( leaf.worktreeId === worktreeId && leaf.connected && leaf.ptyId && + (connectionId === undefined || pty?.connectionId === connectionId) && (!freshPtyIds || freshPtyIds.has(leaf.ptyId)) ) { ptyIds.add(leaf.ptyId) @@ -18784,6 +19762,7 @@ export class OrcaRuntimeService { if ( pty.worktreeId === worktreeId && pty.connected && + (connectionId === undefined || pty.connectionId === connectionId) && (!freshPtyIds || freshPtyIds.has(pty.ptyId)) ) { ptyIds.add(pty.ptyId) @@ -18821,6 +19800,7 @@ export class OrcaRuntimeService { // against whatever the renderer rebuilds next. this.rendererGraphEpoch += 1 this.graphStatus = 'reloading' + this.setTerminalSideEffectConsumerAvailable(false) this.rememberDetachedPreAllocatedLeaves() this.handles.clear() this.handleByLeafKey.clear() @@ -18837,6 +19817,7 @@ export class OrcaRuntimeService { return } this.graphStatus = 'ready' + this.setTerminalSideEffectConsumerAvailable(windowId !== HEADLESS_RUNTIME_WINDOW_ID) this.refreshWritableFlags() } @@ -18850,6 +19831,7 @@ export class OrcaRuntimeService { this.rendererGraphEpoch += 1 } this.graphStatus = 'unavailable' + this.setTerminalSideEffectConsumerAvailable(false) this.authoritativeWindowId = null this.rememberDetachedPreAllocatedLeaves() this.tabs.clear() @@ -18920,10 +19902,12 @@ export class OrcaRuntimeService { { getSshFilesystemProvider } ) assertFolderWorkspacePathUsable(status) + const connectionId = this.resolveFolderWorkspaceConnectionId(workspace) return { id: folderWorkspaceKey(workspace.id), path: workspace.folderPath, - connectionId: this.resolveFolderWorkspaceConnectionId(workspace), + hostId: connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID, + connectionId, repo: null, folderWorkspace: workspace } @@ -18954,7 +19938,8 @@ export class OrcaRuntimeService { } private async resolveTerminalWorkspaceLaunchScope( - selector: string + selector: string, + hostId?: ExecutionHostId | null ): Promise { const floatingTerminalSelector = selector === FLOATING_TERMINAL_WORKTREE_ID || @@ -18965,6 +19950,7 @@ export class OrcaRuntimeService { return { id: FLOATING_TERMINAL_WORKTREE_ID, path: homedir(), + hostId: LOCAL_EXECUTION_HOST_ID, connectionId: null, repo: null, folderWorkspace: null @@ -18979,11 +19965,23 @@ export class OrcaRuntimeService { const workspaceSelector = selector.startsWith('id:') ? selector.slice(3) : selector const parsed = parseWorkspaceKey(workspaceSelector) const worktreeSelector = parsed?.type === 'worktree' ? `id:${parsed.worktreeId}` : selector - const worktree = await this.resolveWorktreeSelector(worktreeSelector) - const repo = this.store?.getRepo(worktree.repoId) ?? null + const worktree = await this.resolveWorktreeSelectorForExecutionHost(worktreeSelector, hostId) + const resolvedHostId = this.executionHostForWorktree(worktree) + const repo = + this.store + ?.getRepos() + .find( + (candidate) => + candidate.id === worktree.repoId && getRepoExecutionHostId(candidate) === resolvedHostId + ) ?? + (this.store?.getRepo(worktree.repoId) && + getRepoExecutionHostId(this.store.getRepo(worktree.repoId)!) === resolvedHostId + ? this.store.getRepo(worktree.repoId)! + : null) return { id: worktree.id, path: worktree.path, + hostId: resolvedHostId, connectionId: repo?.connectionId ?? null, repo, folderWorkspace: null @@ -19020,7 +20018,48 @@ export class OrcaRuntimeService { } } + private getValidatedExplicitWorktreeIdSelector(selector: string | undefined): string | null { + const worktreeId = getExplicitWorktreeIdSelector(selector) + if ( + worktreeId && + !worktreeId.includes(WORKTREE_ID_SEPARATOR) && + this.store?.getRepo(worktreeId) + ) { + // Why: registered repo ids are known-invalid worktree ids, so reject them + // before exact-id fast paths or Git/SSH worktree scans can hide the mistake. + throw new WorktreeIdRequiresFullPathError() + } + return worktreeId + } + + private async resolveWorktreeSelectorForExecutionHost( + selector: string, + hostId?: ExecutionHostId | null + ): Promise { + const normalizedHostId = normalizeExecutionHostId(hostId) + if (!normalizedHostId) { + return await this.resolveWorktreeSelector(selector) + } + const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(selector) + const worktreeId = explicitWorktreeId ?? (selector.startsWith('id:') ? selector.slice(3) : null) + if (!worktreeId) { + throw new Error('selector_not_found') + } + const candidates = (await this.listResolvedWorktrees()).filter( + (worktree) => + worktree.id === worktreeId && this.executionHostForWorktree(worktree) === normalizedHostId + ) + if (candidates.length === 1) { + return candidates[0] + } + if (candidates.length > 1) { + throw new Error('selector_ambiguous') + } + throw new Error('selector_not_found') + } + private async resolveWorktreeSelector(selector: string): Promise { + const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(selector) const worktrees = await this.listResolvedWorktrees() let candidates: ResolvedWorktree[] @@ -19029,7 +20068,7 @@ export class OrcaRuntimeService { } if (selector.startsWith('id:')) { - const worktreeId = selector.slice(3) + const worktreeId = explicitWorktreeId ?? selector.slice(3) candidates = worktrees.filter((worktree) => worktree.id === worktreeId) if (candidates.length === 0) { const parsed = splitWorktreeIdForFilesystem(worktreeId) @@ -19156,6 +20195,15 @@ export class OrcaRuntimeService { private async resolveLineageForWorktreeCreate( input?: WorktreeLineageInput ): Promise { + const parentSelectorNextSteps = [ + 'Pass a valid --parent-worktree selector such as folder:, worktree:, id:::, branch:, issue:, path:, or active/current.', + 'Retry with --no-parent to create without lineage.' + ] + const parentSelectorNotFoundMessage = (err: unknown): string => + err instanceof WorktreeIdRequiresFullPathError + ? err.message + : 'Parent selector was not found.' + if (!input) { return { kind: 'none', warnings: [] } } @@ -19185,15 +20233,12 @@ export class OrcaRuntimeService { origin: 'cli', capture: { source: 'explicit-cli-flag', confidence: 'explicit' } } - } catch { + } catch (err) { throw new RuntimeLineageError( 'LINEAGE_PARENT_NOT_FOUND', - 'Parent selector was not found.', + parentSelectorNotFoundMessage(err), { - nextSteps: [ - 'Pass a valid --parent-worktree selector such as folder:, worktree:, id:, branch:, issue:, path:, or active/current.', - 'Retry with --no-parent to create without lineage.' - ] + nextSteps: parentSelectorNextSteps } ) } @@ -19213,15 +20258,12 @@ export class OrcaRuntimeService { origin: 'cli', capture: { source: 'explicit-cli-flag', confidence: 'explicit' } } - } catch { + } catch (err) { throw new RuntimeLineageError( 'LINEAGE_PARENT_NOT_FOUND', - 'Parent selector was not found.', + parentSelectorNotFoundMessage(err), { - nextSteps: [ - 'Pass a valid --parent-worktree selector such as folder:, worktree:, id:, branch:, issue:, path:, or active/current.', - 'Retry with --no-parent to create without lineage.' - ] + nextSteps: parentSelectorNextSteps } ) } @@ -19591,12 +20633,16 @@ export class OrcaRuntimeService { } private async listResolvedWorktrees(): Promise { + return (await this.listResolvedWorktreeSnapshot()).worktrees + } + + private async listResolvedWorktreeSnapshot(): Promise { if (!this.store) { - return [] + return { worktrees: [], platformByRepoId: new Map() } } const now = Date.now() if (this.resolvedWorktreeCache && this.resolvedWorktreeCache.expiresAt > now) { - return this.resolvedWorktreeCache.worktrees + return this.resolvedWorktreeCache } const generation = this.resolvedWorktreeGeneration if (this.resolvedWorktreeInFlight?.generation === generation) { @@ -19614,14 +20660,22 @@ export class OrcaRuntimeService { } } - private async computeResolvedWorktrees(generation: number): Promise { + private async computeResolvedWorktrees(generation: number): Promise { if (!this.store) { - return [] + return { worktrees: [], platformByRepoId: new Map() } } const now = Date.now() const metaById = this.store.getAllWorktreeMeta() ?? {} + const repos = this.store.getRepos() + const projectRuntimeByRepoId = resolveLocalProjectRuntimesForRepos(this.requireStore(), repos) + const platformByRepoId = new Map( + repos.map((repo) => [ + repo.id, + getAgentLaunchPlatformForRepo(repo, projectRuntimeByRepoId.get(repo.id)) + ]) + ) const perRepoWorktrees = await Promise.all( - this.store.getRepos().map(async (repo) => { + repos.map(async (repo) => { if (isFolderRepo(repo)) { return listRuntimeFolderWorkspaces(this.requireStore(), repo).map((worktree) => ({ ...worktree, @@ -19642,7 +20696,7 @@ export class OrcaRuntimeService { // Why: mobile startup RPCs share this path. A slow repo scan should // degrade one repo's metadata, not block all terminal/session loading. const scan = await withTimeout( - this.listRepoWorktreesForResolution(repo), + this.listRepoWorktreesForResolution(repo, projectRuntimeByRepoId), RESOLVED_WORKTREE_REPO_TIMEOUT_MS, { ok: false, worktrees: [] } ) @@ -19685,10 +20739,11 @@ export class OrcaRuntimeService { if (generation === this.resolvedWorktreeGeneration) { this.resolvedWorktreeCache = { worktrees, + platformByRepoId, expiresAt: now + RESOLVED_WORKTREE_CACHE_TTL_MS } } - return worktrees + return { worktrees, platformByRepoId } } private attachLineageToResolvedWorktrees(worktrees: ResolvedWorktree[]): ResolvedWorktree[] { @@ -19773,13 +20828,19 @@ export class OrcaRuntimeService { } } - private async listRepoWorktreesForResolution(repo: Repo): Promise { + private async listRepoWorktreesForResolution( + repo: Repo, + projectRuntimeByRepoId?: ReadonlyMap + ): Promise { if (!repo.connectionId) { + const projectRuntime = projectRuntimeByRepoId + ? projectRuntimeByRepoId.get(repo.id) + : resolveLocalProjectRuntimeForRepo(this.requireStore(), repo) return { ok: true, worktrees: await listRepoWorktrees( repo, - getLocalProjectWorktreeGitOptions(this.requireStore(), repo) + getLocalProjectWorktreeGitOptionsForRuntime(repo, projectRuntime) ) } } @@ -19862,7 +20923,14 @@ export class OrcaRuntimeService { state: Partial< Pick< RuntimePtyWorktreeRecord, - 'connected' | 'lastOutputAt' | 'preview' | 'tabId' | 'paneKey' | 'title' | 'connectionId' + | 'connected' + | 'lastOutputAt' + | 'preview' + | 'tabId' + | 'paneKey' + | 'title' + | 'connectionId' + | 'isWsl' > > = {} ): RuntimePtyWorktreeRecord { @@ -19873,6 +20941,7 @@ export class OrcaRuntimeService { ptyId, worktreeId, connectionId: state.connectionId ?? parseAppSshPtyId(ptyId)?.connectionId ?? null, + isWsl: state.isWsl ?? null, tabId: state.tabId ?? null, paneKey: state.paneKey ?? null, launchConfig: null, @@ -19914,6 +20983,9 @@ export class OrcaRuntimeService { if (state.connectionId !== undefined) { pty.connectionId = state.connectionId } + if (state.isWsl !== undefined) { + pty.isWsl = state.isWsl + } if (state.tabId !== undefined) { pty.tabId = state.tabId } @@ -19971,13 +21043,14 @@ export class OrcaRuntimeService { */ private async refreshPtyWorktreeRecordsFromController( resolvedWorktrees: ResolvedWorktree[], - targetWorktreeId: string | null = null + targetWorktreeId: string | null = null, + connectionId?: string | null ): Promise | null> { if (!this.ptyController?.listProcesses) { return null } const sessionsResult = await withTimeoutResult( - this.ptyController.listProcesses(), + this.ptyController.listProcesses(connectionId), PTY_CONTROLLER_LIST_TIMEOUT_MS ) if (!sessionsResult.ok) { @@ -20005,6 +21078,9 @@ export class OrcaRuntimeService { this.refreshPtyForegroundAgent(session.id) } for (const pty of this.ptysById.values()) { + if (connectionId !== undefined && pty.connectionId !== connectionId) { + continue + } if (!livePtyIds.has(pty.ptyId) && !this.leafExistsForPty(pty.ptyId)) { pty.connected = false pty.disconnectedAt ??= Date.now() @@ -20100,23 +21176,34 @@ export class OrcaRuntimeService { private getSummaryForRuntimeWorktreeId( summaries: Map, - resolvedWorktrees: ResolvedWorktree[], + runtimeWorktreeSummaryPathIndex: RuntimeWorktreeSummaryPathIndex, + missingRuntimeWorktreeIds: Set, runtimeWorktreeId: string ): RuntimeWorktreePsSummary | null { const exact = summaries.get(runtimeWorktreeId) if (exact) { return exact } + if (missingRuntimeWorktreeIds.has(runtimeWorktreeId)) { + return null + } const parsed = parseRuntimeWorktreeId(runtimeWorktreeId) if (!parsed) { return null } - const resolved = resolvedWorktrees.find( - (worktree) => - worktree.repoId === parsed.repoId && - areWorktreePathsEqual(worktree.path, parsed.worktreePath) + const comparisonPlatform = + runtimeWorktreeSummaryPathIndex.platformByRepoId.get(parsed.repoId) ?? process.platform + const indexed = findRuntimeWorktreeSummaryByPath( + runtimeWorktreeSummaryPathIndex, + parsed.repoId, + parsed.worktreePath, + comparisonPlatform ) - return resolved ? (summaries.get(resolved.id) ?? null) : null + if (indexed) { + return indexed + } + missingRuntimeWorktreeIds.add(runtimeWorktreeId) + return null } private buildTerminalSummary( @@ -20406,7 +21493,7 @@ export class OrcaRuntimeService { tabId: string ): Promise { const worktreeId = - getExplicitWorktreeIdSelector(worktreeSelector) ?? + this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) ?? (await this.resolveWorktreeSelector(worktreeSelector)).id const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) const tab = snapshot?.tabs.find( @@ -20881,7 +21968,8 @@ export class OrcaRuntimeService { // status without throwing on stale handles, so this returns null on any error. getAgentStatusForHandle(handle: string): string | null { try { - return this.getTerminalAgentStatusSnapshot(handle).titleStatus + const ptyId = this.getTerminalAgentStatusPtyId(handle) + return this.getTerminalAgentStatusSnapshot(handle, ptyId).titleStatus } catch { return null } @@ -26297,6 +27385,118 @@ function parseRuntimeWorktreeId( return parsed } +type RuntimeWorktreeSummaryPathCandidate = { + summary: RuntimeWorktreePsSummary + order: number +} + +type RuntimeWorktreeSummaryPathIndex = { + platformByRepoId: ReadonlyMap + posixAbsolute: Map + posixRelative: Map + windows: Map + windowsAbsolute: Map +} + +function buildRuntimeWorktreeSummaryPathIndex( + summaries: ReadonlyMap, + resolvedWorktrees: readonly ResolvedWorktree[], + platformByRepoId: ReadonlyMap +): RuntimeWorktreeSummaryPathIndex { + const index: RuntimeWorktreeSummaryPathIndex = { + platformByRepoId, + posixAbsolute: new Map(), + posixRelative: new Map(), + windows: new Map(), + windowsAbsolute: new Map() + } + for (const [order, worktree] of resolvedWorktrees.entries()) { + const summary = summaries.get(worktree.id) + if (!summary) { + continue + } + const platform = platformByRepoId.get(worktree.repoId) ?? process.platform + const candidate = { summary, order } + if (isPosixAbsoluteRuntimeWorktreePath(worktree.path)) { + setFirstRuntimeWorktreePathCandidate( + index.posixAbsolute, + runtimeWorktreeSummaryPathKey(worktree.repoId, worktree.path, platform), + candidate + ) + continue + } + + const windowsKey = runtimeWorktreeSummaryPathKey(worktree.repoId, worktree.path, 'win32') + setFirstRuntimeWorktreePathCandidate(index.windows, windowsKey, candidate) + if (isWindowsAbsolutePathLike(worktree.path)) { + setFirstRuntimeWorktreePathCandidate(index.windowsAbsolute, windowsKey, candidate) + } else if (platform !== 'win32') { + setFirstRuntimeWorktreePathCandidate( + index.posixRelative, + runtimeWorktreeSummaryPathKey(worktree.repoId, worktree.path, platform), + candidate + ) + } + } + return index +} + +function findRuntimeWorktreeSummaryByPath( + index: RuntimeWorktreeSummaryPathIndex, + repoId: string, + worktreePath: string, + platform: NodeJS.Platform +): RuntimeWorktreePsSummary | null { + if (isPosixAbsoluteRuntimeWorktreePath(worktreePath)) { + return ( + index.posixAbsolute.get(runtimeWorktreeSummaryPathKey(repoId, worktreePath, platform)) + ?.summary ?? null + ) + } + + const windowsKey = runtimeWorktreeSummaryPathKey(repoId, worktreePath, 'win32') + if (platform === 'win32' || isWindowsAbsolutePathLike(worktreePath)) { + return index.windows.get(windowsKey)?.summary ?? null + } + + const posixCandidate = index.posixRelative.get( + runtimeWorktreeSummaryPathKey(repoId, worktreePath, platform) + ) + const windowsCandidate = index.windowsAbsolute.get(windowsKey) + // Why: a malformed relative path can compare as POSIX against another + // relative path or as Windows against an absolute Windows path. Preserve the + // old pairwise scan's first-match result without rescanning every worktree. + if (!posixCandidate) { + return windowsCandidate?.summary ?? null + } + if (!windowsCandidate || posixCandidate.order < windowsCandidate.order) { + return posixCandidate.summary + } + return windowsCandidate.summary +} + +function setFirstRuntimeWorktreePathCandidate( + candidates: Map, + key: string, + candidate: RuntimeWorktreeSummaryPathCandidate +): void { + if (!candidates.has(key)) { + candidates.set(key, candidate) + } +} + +function isPosixAbsoluteRuntimeWorktreePath(worktreePath: string): boolean { + return worktreePath.startsWith('/') && !worktreePath.startsWith('//') +} + +function runtimeWorktreeSummaryPathKey( + repoId: string, + worktreePath: string, + platform: NodeJS.Platform +): string { + return `${repoId}\0${worktreePathComparisonKey(worktreePath, platform)}` +} + function includeTargetResolvedWorktree( resolvedWorktrees: ResolvedWorktree[], targetWorktree: ResolvedWorktree | null @@ -26577,6 +27777,11 @@ function compareWorktreePs( if (left.unread !== right.unread) { return left.unread ? -1 : 1 } + // Why: worktree.ps is truncated for mobile, so host-visible activity must + // survive ahead of ordinary inactive rows without displacing pinned/unread. + if (left.hasHostSidebarActivity !== right.hasHostSidebarActivity) { + return left.hasHostSidebarActivity ? -1 : 1 + } const leftLast = left.lastOutputAt ?? -1 const rightLast = right.lastOutputAt ?? -1 if (leftLast !== rightLast) { diff --git a/src/main/runtime/orchestration-cli-subprocess.test.ts b/src/main/runtime/orchestration-cli-subprocess.test.ts index 3b94acbb3df..6ec938a7e28 100644 --- a/src/main/runtime/orchestration-cli-subprocess.test.ts +++ b/src/main/runtime/orchestration-cli-subprocess.test.ts @@ -1,14 +1,14 @@ -// Why: subprocess-level test for the CLI heartbeat behavior described in +// Why: subprocess-level test for the CLI keepalive behavior described in // design doc §3.4. Spawns the real compiled CLI with no TTY, points it at a // real in-process runtime via ORCA_USER_DATA_PATH, and asserts: -// - the first heartbeat line appears on stderr well under Claude Code's +// - the first keepalive line appears on stderr well under Claude Code's // ~2 min Bash-tool silence budget (we verify with a shortened interval; // production uses 15 s via the same code path) -// - ≥3 heartbeats arrive during the wait window -// - stderr is line-flushed (we observe each heartbeat as a separate chunk +// - ≥3 keepalives arrive during the wait window +// - stderr is line-flushed (we observe each keepalive as a separate chunk // before the process exits — not in one burst at the end) -// - stdout stays a single clean JSON payload (no heartbeats leak to stdout) -// - a `jq "select(._heartbeat|not)"` filter on the merged stream would +// - stdout stays a single clean JSON payload (no keepalives leak to stdout) +// - a `jq "select(._keepalive|not)"` filter on the merged stream would // yield exactly the final result // // This test is skipped if the CLI hasn't been built yet (out/cli/index.js @@ -66,7 +66,7 @@ async function runBuiltCli( } describeIfBuilt('orca orchestration check --wait subprocess (§3.4)', () => { - it('emits newline-flushed JSON heartbeats to stderr while waiting', async () => { + it('emits newline-flushed JSON keepalives to stderr while waiting', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-cli-sub-')) const runtime = new OrcaRuntimeService() const db = new OrchestrationDb(':memory:') @@ -75,12 +75,12 @@ describeIfBuilt('orca orchestration check --wait subprocess (§3.4)', () => { await server.start() try { - // Why: use the ORCA_HEARTBEAT_INTERVAL_MS escape hatch to shrink the + // Why: use the ORCA_KEEPALIVE_INTERVAL_MS escape hatch to shrink the // test to ~1 s wall time. Production callers never set this; the // production default (15 s) is exercised by §3.4's own unit tests // and by the fact that this same code path runs with the real // constant when the env var is absent. - const heartbeatMs = 200 + const keepaliveMs = 200 const waitTimeoutMs = 1200 const child = spawn( @@ -99,7 +99,7 @@ describeIfBuilt('orca orchestration check --wait subprocess (§3.4)', () => { ...process.env, ORCA_USER_DATA_PATH: userDataPath, ORCA_TERMINAL_HANDLE: 'term_nobody', - ORCA_HEARTBEAT_INTERVAL_MS: String(heartbeatMs) + ORCA_KEEPALIVE_INTERVAL_MS: String(keepaliveMs) }, // Why: explicit pipe for all three fds so we can watch stderr // in real time; no TTY attached (Bash-tool parity). @@ -125,7 +125,7 @@ describeIfBuilt('orca orchestration check --wait subprocess (§3.4)', () => { const stderr = stderrChunks.map((c) => c.data).join('') const stdout = stdoutChunks.map((c) => c.data).join('') - const heartbeatLines = stderr + const keepaliveLines = stderr .split('\n') .filter((line) => line.trim().length > 0) .map((line) => { @@ -135,51 +135,52 @@ describeIfBuilt('orca orchestration check --wait subprocess (§3.4)', () => { return null } }) - .filter((p): p is Record => p !== null && p._heartbeat === true) + .filter((p): p is Record => p !== null && p._keepalive === true) - // ≥3 heartbeats in a 1.2s window with a 200ms interval - expect(heartbeatLines.length).toBeGreaterThanOrEqual(3) - expect(heartbeatLines[0]).toHaveProperty('elapsedMs') - expect(heartbeatLines[0]).toHaveProperty('deadlineMs', waitTimeoutMs) + // ≥3 keepalives in a 1.2s window with a 200ms interval + expect(keepaliveLines.length).toBeGreaterThanOrEqual(3) + expect(keepaliveLines[0]).toMatchObject({ _keepalive: true, _heartbeat: true }) + expect(keepaliveLines[0]).toHaveProperty('elapsedMs') + expect(keepaliveLines[0]).toHaveProperty('deadlineMs', waitTimeoutMs) // Why: under full-suite load the child process startup may take longer - // than one heartbeat interval. The invariant that matters is that at - // least one heartbeat is observed before the terminal stdout payload. - const firstHeartbeatChunk = stderrChunks.find((c) => c.data.includes('_heartbeat')) - expect(firstHeartbeatChunk).toBeDefined() - expect(firstHeartbeatChunk!.at).toBeLessThan(stdoutChunks[0]?.at ?? Number.POSITIVE_INFINITY) + // than one keepalive interval. The invariant that matters is that at + // least one keepalive is observed before the terminal stdout payload. + const firstKeepaliveChunk = stderrChunks.find((c) => c.data.includes('_keepalive')) + expect(firstKeepaliveChunk).toBeDefined() + expect(firstKeepaliveChunk!.at).toBeLessThan(stdoutChunks[0]?.at ?? Number.POSITIVE_INFINITY) - // Why: line-flushing proof — the *first* heartbeat chunk must arrive + // Why: line-flushing proof — the *first* keepalive chunk must arrive // strictly before the exit chunk; i.e. we got at least two separate - // stderr data events (heartbeat + final). A single-chunk delivery + // stderr data events (keepalive + final). A single-chunk delivery // would indicate stderr was buffered until exit. const lastStderrAt = stderrChunks.at(-1)?.at ?? 0 const firstStderrAt = stderrChunks.at(0)?.at ?? 0 expect(lastStderrAt).toBeGreaterThan(firstStderrAt) - // Stdout: exactly one JSON payload, the terminal result. No heartbeats + // Stdout: exactly one JSON payload, the terminal result. No keepalives // leak, and the content parses as valid JSON. const stdoutTrimmed = stdout.trim() const stdoutPayload = JSON.parse(stdoutTrimmed) as Record - expect(stdoutPayload).not.toHaveProperty('_heartbeat') - expect(stdoutTrimmed).not.toContain('_heartbeat') + expect(stdoutPayload).not.toHaveProperty('_keepalive') + expect(stdoutTrimmed).not.toContain('_keepalive') // Why: result should be an RPC success envelope with the expected // shape. `count: 0` and `messages: []` because the wait timed out // with no message for term_nobody. expect(stdoutPayload).toMatchObject({ ok: true }) - // Why: the heartbeats-on-stderr design is meant to pair with shell - // filters like `2>&1 | jq "select(._heartbeat|not)"`. jq is + // Why: the keepalives-on-stderr design is meant to pair with shell + // filters like `2>&1 | jq "select(._keepalive|not)"`. jq is // line-oriented by default, but also accepts pretty-printed JSON // across multiple lines. What matters here is that every - // heartbeat line on stderr is a standalone JSON object (so jq can + // keepalive line on stderr is a standalone JSON object (so jq can // match it) and doesn't span multiple lines — assert that each - // heartbeat is a single-line JSON with no embedded newlines. + // keepalive is a single-line JSON with no embedded newlines. for (const line of stderr.split('\n')) { if (line.trim().length === 0) { continue } - if (line.includes('_heartbeat')) { + if (line.includes('_keepalive')) { expect(() => JSON.parse(line)).not.toThrow() expect(line).not.toContain('\n') } diff --git a/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap b/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap index 2f8c48d1a6c..c55426c4216 100644 --- a/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap +++ b/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap @@ -22,7 +22,7 @@ Slack, GitHub comments, or any other channel to reach a human during the run. # with subject like "Failed: " — never silently exit. # Include BOTH taskId and dispatchId in the payload so a late completion # from a failed retry cannot complete the current dispatch. - orca orchestration send --to term_COORD \\ + orca orchestration send --to term_COORD --from term_WORKER \\ --type worker_done --subject "" \\ --body "<3-sentence summary: what you did, what you found, what's left>" \\ --task-id task_SNAP --dispatch-id ctx_SNAP \\ @@ -39,7 +39,7 @@ Slack, GitHub comments, or any other channel to reach a human during the run. # attributes the heartbeat to the specific dispatch context, not just # the task, so a straggler heartbeat from a previously-failed dispatch # cannot mask a hung retry. - orca orchestration send --to term_COORD \\ + orca orchestration send --to term_COORD --from term_WORKER \\ --type heartbeat --subject "alive" \\ --task-id task_SNAP --dispatch-id ctx_SNAP \\ --phase "" @@ -56,20 +56,20 @@ Slack, GitHub comments, or any other channel to reach a human during the run. # blocks on \`check --wait\` until the coordinator replies, then prints the # reply body. Use it anywhere you would otherwise have reached for # AskUserQuestion. - orca orchestration ask --to term_COORD \\ + orca orchestration ask --to term_COORD --from term_WORKER \\ --question "" \\ --options "" \\ --timeout-ms 600000 # Escalate a blocker or failure (pre-completion, when you need the # coordinator to do something before you can continue): - orca orchestration send --to term_COORD \\ + orca orchestration send --to term_COORD --from term_WORKER \\ --type escalation --subject "Blocked: " \\ --body "
" \\ --task-id task_SNAP # Check for messages from the coordinator: - orca orchestration check + orca orchestration check --terminal term_WORKER === AFTER YOU SEND worker_done === diff --git a/src/main/runtime/orchestration/cli-command.test.ts b/src/main/runtime/orchestration/cli-command.test.ts new file mode 100644 index 00000000000..1d07d335c8a --- /dev/null +++ b/src/main/runtime/orchestration/cli-command.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { resolveTerminalOrchestrationCliCommand } from './cli-command' + +describe('resolveTerminalOrchestrationCliCommand', () => { + it('uses orca-ide for a pane recorded as WSL', () => { + expect( + resolveTerminalOrchestrationCliCommand({ + connectionId: null, + isWsl: true, + worktreeId: 'repo::C:\\repo' + }) + ).toBe('orca-ide') + }) + + it('uses project runtime and WSL paths when restored pane metadata is unavailable', () => { + expect( + resolveTerminalOrchestrationCliCommand({ + connectionId: null, + isWsl: null, + worktreeId: 'repo::C:\\repo', + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'project', + distro: 'Ubuntu', + reason: 'project-override', + cacheKey: 'project:wsl:Ubuntu' + } + } + }) + ).toBe('orca-ide') + expect( + resolveTerminalOrchestrationCliCommand({ + connectionId: null, + isWsl: null, + worktreeId: 'repo::\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo' + }) + ).toBe('orca-ide') + }) + + it('preserves native and SSH bare-orca commands', () => { + expect( + resolveTerminalOrchestrationCliCommand({ + connectionId: null, + isWsl: false, + worktreeId: 'repo::/home/alice/repo' + }) + ).toBe('orca') + expect( + resolveTerminalOrchestrationCliCommand({ + connectionId: 'ssh-1', + isWsl: null, + worktreeId: 'repo::\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo' + }) + ).toBe('orca') + }) +}) diff --git a/src/main/runtime/orchestration/cli-command.ts b/src/main/runtime/orchestration/cli-command.ts new file mode 100644 index 00000000000..c0f4883f1e4 --- /dev/null +++ b/src/main/runtime/orchestration/cli-command.ts @@ -0,0 +1,25 @@ +import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' +import { isWslUncPath } from '../../../shared/wsl-paths' +import { splitWorktreeIdForFilesystem } from '../../../shared/worktree-id' + +export type OrchestrationCliCommand = 'orca' | 'orca-ide' + +export function resolveTerminalOrchestrationCliCommand(args: { + connectionId: string | null + isWsl: boolean | null | undefined + worktreeId: string + projectRuntime?: ProjectExecutionRuntimeResolution +}): OrchestrationCliCommand { + if (args.connectionId) { + return 'orca' + } + if (args.isWsl !== null && args.isWsl !== undefined) { + return args.isWsl ? 'orca-ide' : 'orca' + } + if (args.projectRuntime?.status === 'resolved' && args.projectRuntime.runtime.kind === 'wsl') { + return 'orca-ide' + } + + const worktreePath = splitWorktreeIdForFilesystem(args.worktreeId)?.worktreePath + return worktreePath && isWslUncPath(worktreePath) ? 'orca-ide' : 'orca' +} diff --git a/src/main/runtime/orchestration/coordinator.test.ts b/src/main/runtime/orchestration/coordinator.test.ts index 8593fd81622..cb532b71e36 100644 --- a/src/main/runtime/orchestration/coordinator.test.ts +++ b/src/main/runtime/orchestration/coordinator.test.ts @@ -21,6 +21,7 @@ function createMockRuntime(): CoordinatorRuntime & { createdTerminalOptions: { title?: string }[] probeDriftCalls: string[] probeDriftResult: DriftResult + cliCommand: 'orca' | 'orca-ide' setProbeDrift(result: DriftResult): void throwProbeDrift: Error | null } { @@ -36,6 +37,7 @@ function createMockRuntime(): CoordinatorRuntime & { createdTerminalOptions: [] as { title?: string }[], probeDriftCalls: [] as string[], probeDriftResult: null as DriftResult, + cliCommand: 'orca' as 'orca' | 'orca-ide', throwProbeDrift: null as Error | null, setProbeDrift(result: DriftResult): void { mock.probeDriftResult = result @@ -63,6 +65,9 @@ function createMockRuntime(): CoordinatorRuntime & { throw mock.throwProbeDrift } return mock.probeDriftResult + }, + getTerminalOrchestrationCliCommand() { + return mock.cliCommand } } return mock @@ -76,6 +81,7 @@ function insertWorkerDone( from?: string dispatchId?: string filesModified?: string[] + senderPaneKey?: string } ): void { const dispatch = db.getDispatchContext(params.taskId) @@ -83,8 +89,9 @@ function insertWorkerDone( if (!dispatchId) { throw new Error(`No dispatch for task ${params.taskId}`) } + const from = params.from ?? dispatch?.assignee_handle ?? 'term_unknown' db.insertMessage({ - from: params.from ?? dispatch?.assignee_handle ?? 'term_unknown', + from, to: params.to ?? 'coord', subject: 'Done', type: 'worker_done', @@ -92,7 +99,10 @@ function insertWorkerDone( taskId: params.taskId, dispatchId, ...(params.filesModified ? { filesModified: params.filesModified } : {}) - }) + }), + senderPaneKey: + params.senderPaneKey ?? + (from === dispatch?.assignee_handle ? (dispatch.assignee_pane_key ?? undefined) : undefined) }) } @@ -116,6 +126,7 @@ describe('Coordinator', () => { it('dispatches a ready task to an available terminal', async () => { db = new OrchestrationDb(':memory:') const runtime = createMockRuntime() + runtime.cliCommand = 'orca-ide' runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }] const task = db.createTask({ spec: 'implement feature' }) @@ -142,6 +153,32 @@ describe('Coordinator', () => { expect(result.status).toBe('completed') expect(result.completedTasks).toContain(task.id) expect(runtime.sentMessages.length).toBeGreaterThan(0) + expect(runtime.sentMessages[0].text).toContain('orca-ide orchestration send') + }) + + it('records the assignee pane key when the runtime can resolve one', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }] + const withPaneLookup = Object.assign(runtime, { + getTerminalPaneKey: (handle: string) => (handle === 'term_a' ? 'tab_a:leaf_a' : null) + }) + + const task = db.createTask({ spec: 'implement feature' }) + const coordinator = new Coordinator(db, withPaneLookup, { + spec: 'build it', + coordinatorHandle: 'coord', + pollIntervalMs: 50 + }) + const runPromise = coordinator.run() + await new Promise((r) => { + setTimeout(r, 100) + }) + + expect(db.getDispatchContext(task.id)?.assignee_pane_key).toBe('tab_a:leaf_a') + + insertWorkerDone(db, { taskId: task.id }) + await runPromise }) it('records completedTasks when send reconciled worker_done before coordinator read', async () => { @@ -570,20 +607,22 @@ describe('Coordinator', () => { expect(db.getDispatchContextById(activeCtx.id)?.status).toBe('completed') }) - it('ignores worker_done sent by a terminal that does not own the dispatch', async () => { + it('accepts worker_done pane provenance after an assignee handle changes', async () => { db = new OrchestrationDb(':memory:') const runtime = createMockRuntime() const logs: string[] = [] const task = db.createTask({ spec: 'owned work' }) - const ctx = db.createDispatchContext(task.id, 'term_owner') + const leafId = '11111111-1111-4111-8111-111111111111' + const ctx = db.createDispatchContext(task.id, 'term_owner', `tab_before:${leafId}`) db.insertMessage({ - from: 'term_intruder', + from: 'term_reminted', to: 'coord', - subject: 'Spoofed done', + subject: 'Done after restart', type: 'worker_done', - payload: JSON.stringify({ taskId: task.id, dispatchId: ctx.id }) + payload: JSON.stringify({ taskId: task.id, dispatchId: ctx.id }), + senderPaneKey: `tab_after:${leafId}` }) const coordinator = new Coordinator(db, runtime, { @@ -592,16 +631,12 @@ describe('Coordinator', () => { pollIntervalMs: 20, onLog: (m) => logs.push(m) }) - const runPromise = coordinator.run() - await new Promise((r) => { - setTimeout(r, 80) - }) - coordinator.stop() - await runPromise + const result = await coordinator.run() - expect(db.getTask(task.id)?.status).toBe('dispatched') - expect(db.getDispatchContextById(ctx.id)?.status).toBe('dispatched') - expect(logs.some((m) => m.includes('expected term_owner'))).toBe(true) + expect(result.status).toBe('completed') + expect(db.getTask(task.id)?.status).toBe('completed') + expect(db.getDispatchContextById(ctx.id)?.status).toBe('completed') + expect(logs.some((m) => m.includes('Task') && m.includes('completed'))).toBe(true) }) it('can be stopped', async () => { diff --git a/src/main/runtime/orchestration/coordinator.ts b/src/main/runtime/orchestration/coordinator.ts index 2313dfdf320..04f0788577c 100644 --- a/src/main/runtime/orchestration/coordinator.ts +++ b/src/main/runtime/orchestration/coordinator.ts @@ -29,6 +29,12 @@ export type CoordinatorRuntime = { behind: number recentSubjects: string[] } | null> + // Why: optional so lightweight runtime fakes keep compiling; when present, + // dispatch records the remint-stable pane identity of the assignee. + getTerminalPaneKey?(handle: string): string | null + // Why: Windows can host native and WSL workers concurrently, so the + // worker pane—not the coordinator process—selects the packaged CLI name. + getTerminalOrchestrationCliCommand?(handle: string): 'orca' | 'orca-ide' } // Why (§3.1): single threshold, no warn/refuse split. Coordinator picked 20 @@ -461,7 +467,11 @@ export class Coordinator { } } - const dispatch = this.db.createDispatchContext(task.id, targetHandle) + const dispatch = this.db.createDispatchContext( + task.id, + targetHandle, + this.runtime.getTerminalPaneKey?.(targetHandle) ?? undefined + ) // Why: agents dispatched by the coordinator must use orca-dev in dev mode // so they talk to the dev runtime's socket, not production (Section 6.4). @@ -477,7 +487,11 @@ export class Coordinator { // part of its instructions). taskSpec: strippedSpec, coordinatorHandle: this.opts.coordinatorHandle, + workerHandle: targetHandle, devMode: process.env.ORCA_USER_DATA_PATH?.includes('orca-dev'), + ...(this.runtime.getTerminalOrchestrationCliCommand + ? { cliCommand: this.runtime.getTerminalOrchestrationCliCommand(targetHandle) } + : {}), // Why (§3.2): drift section fires only when behind > 0. The preamble // builder gates on this itself; passing the object unconditionally lets // the coordinator stay dumb about the display rule. diff --git a/src/main/runtime/orchestration/db.test.ts b/src/main/runtime/orchestration/db.test.ts index 8f43298a92c..15fb0a06a00 100644 --- a/src/main/runtime/orchestration/db.test.ts +++ b/src/main/runtime/orchestration/db.test.ts @@ -6,6 +6,20 @@ import Database from '../../sqlite/sync-database' import { OrchestrationDb } from './db' import type { MessageType } from './db' +// Overwrites the datetime('now')-seeded timestamps with explicit fixture values +// so stale-detection assertions stay deterministic (no wall clock). +function setDispatchTimes( + d: OrchestrationDb, + id: string, + dispatchedAt: string, + heartbeatAt: string | null = null +): void { + const sqlite = (d as unknown as { db: Database.Database }).db + sqlite + .prepare('UPDATE dispatch_contexts SET dispatched_at = ?, last_heartbeat_at = ? WHERE id = ?') + .run(dispatchedAt, heartbeatAt, id) +} + describe('OrchestrationDb', () => { let db: OrchestrationDb | undefined @@ -339,6 +353,53 @@ describe('OrchestrationDb', () => { ) }) + // Real leaf UUIDs: pane keys are `${tabId}:${leafUuid}`; only the leaf is + // remint-stable identity (tab half can change on pane break-out). + const LEAF_A = '11111111-1111-1111-8111-111111111111' + const LEAF_B = '22222222-2222-4222-9222-222222222222' + + it('rejects dispatch to a reminted handle on a pane with an active dispatch', () => { + const d = createDb() + const t1 = d.createTask({ spec: 'first' }) + const t2 = d.createTask({ spec: 'second' }) + d.createDispatchContext(t1.id, 'term_old', `tab_1:${LEAF_A}`) + + expect(() => d.createDispatchContext(t2.id, 'term_new', `tab_1:${LEAF_A}`)).toThrow( + /already has an active dispatch/ + ) + }) + + it('rejects dispatch when pane keys share a leaf after break-out', () => { + const d = createDb() + const t1 = d.createTask({ spec: 'first' }) + const t2 = d.createTask({ spec: 'second' }) + d.createDispatchContext(t1.id, 'term_old', `tab_1:${LEAF_A}`) + + expect(() => d.createDispatchContext(t2.id, 'term_new', `tab_2:${LEAF_A}`)).toThrow( + /already has an active dispatch/ + ) + }) + + it('allows concurrent dispatches to different panes', () => { + const d = createDb() + const t1 = d.createTask({ spec: 'first' }) + const t2 = d.createTask({ spec: 'second' }) + d.createDispatchContext(t1.id, 'term_a', `tab_1:${LEAF_A}`) + + expect(() => d.createDispatchContext(t2.id, 'term_b', `tab_1:${LEAF_B}`)).not.toThrow() + }) + + it('falls back to handle lock when pane keys are missing', () => { + const d = createDb() + const t1 = d.createTask({ spec: 'first' }) + const t2 = d.createTask({ spec: 'second' }) + d.createDispatchContext(t1.id, 'term_worker') + + // New dispatch has a pane key but the active row is legacy (no pane key): + // only handle identity can lock; a different handle is free. + expect(() => d.createDispatchContext(t2.id, 'term_other', `tab_1:${LEAF_A}`)).not.toThrow() + }) + it('allows dispatch to a terminal after previous dispatch completes', () => { const d = createDb() const t1 = d.createTask({ spec: 'first' }) @@ -649,6 +710,67 @@ describe('OrchestrationDb', () => { expect(stale.map((s) => s.id)).toEqual([ctxB.id]) }) + // Regression for #8452: dispatched_at / last_heartbeat_at are written by + // datetime('now') (space-format, e.g. "2026-07-12 12:00:00") while the + // threshold is ISO ("...T11:55:00.000Z"). Raw TEXT ordering ranks the space + // (0x20) below the 'T' (0x54) at index 10, flagging fresh same-date rows. + it('getStaleDispatches ignores fresh SQLite space-format timestamps (#8452)', () => { + const d = createDb() + + // Fresh worker: dispatched 12:00, heartbeat 12:05 (space-format), both + // after the 11:55 threshold → NOT stale. + const fresh = d.createDispatchContext(d.createTask({ spec: 'fresh' }).id, 'term_fresh') + setDispatchTimes(d, fresh.id, '2026-07-12 12:00:00', '2026-07-12 12:05:00') + + // Legacy ISO-format fresh row (mixed-format table) stays fresh too. + const legacy = d.createDispatchContext(d.createTask({ spec: 'legacy' }).id, 'term_legacy') + setDispatchTimes(d, legacy.id, '2026-07-12T12:00:00.000Z', '2026-07-12T12:05:00.000Z') + + // Genuinely hung: dispatched + heartbeated at 10:00, ~2h before threshold. + const hung = d.createDispatchContext(d.createTask({ spec: 'hung' }).id, 'term_hung') + setDispatchTimes(d, hung.id, '2026-07-12 10:00:00', '2026-07-12 10:00:00') + + const stale = d.getStaleDispatches('2026-07-12T11:55:00.000Z') + expect(stale.map((s) => s.id)).toEqual([hung.id]) + }) + + it('getStaleDispatches keeps a just-dispatched space-format row in the grace window (#8452)', () => { + const d = createDb() + + // Space-format dispatched_at one minute after the threshold, no heartbeat + // yet → still inside the grace window, must not be flagged. + const ctx = d.createDispatchContext(d.createTask({ spec: 'x' }).id, 'term_x') + setDispatchTimes(d, ctx.id, '2026-07-12 12:00:00') + + const stale = d.getStaleDispatches('2026-07-12T11:59:00.000Z') + expect(stale).toEqual([]) + }) + + // Same-UTC-date midnight threshold: keeps the buggy space-vs-'T' compare in + // play so this guards the fix at a day boundary (#8452; idea from @KMGeon's #8453). + it('getStaleDispatches keeps a fresh row just after a UTC-midnight threshold (#8452)', () => { + const d = createDb() + + const ctx = d.createDispatchContext(d.createTask({ spec: 'midnight' }).id, 'term_midnight') + setDispatchTimes(d, ctx.id, '2026-05-04 00:04:00') + + const stale = d.getStaleDispatches('2026-05-04T00:00:00.000Z') + expect(stale).toEqual([]) + }) + + // Guards the last_heartbeat_at half of the fix on its own: a worker + // dispatched long before the threshold (stale under either format) that + // just sent a fresh space-format heartbeat must stay fresh (#8452). + it('getStaleDispatches keeps a live worker with a fresh space-format heartbeat (#8452)', () => { + const d = createDb() + + const ctx = d.createDispatchContext(d.createTask({ spec: 'live' }).id, 'term_live') + setDispatchTimes(d, ctx.id, '2026-07-12 10:00:00', '2026-07-12 11:59:00') + + const stale = d.getStaleDispatches('2026-07-12T11:55:00.000Z') + expect(stale).toEqual([]) + }) + it('getThreadMessagesFor returns only same-thread replies to a handle', () => { const d = createDb() const outbound = d.insertMessage({ @@ -822,6 +944,25 @@ describe('OrchestrationDb', () => { expect(d.getMessageById('msg_v1')?.subject).toBe('pre-migration') }) + it('adds pane-identity columns (v6) and persists them', () => { + const path = createV1Snapshot() + const d = new OrchestrationDb(path) + db = d + + const task = d.createTask({ spec: 'work' }) + const ctx = d.createDispatchContext(task.id, 'term_a', 'tab_1:leaf_1') + expect(d.getDispatchContextById(ctx.id)?.assignee_pane_key).toBe('tab_1:leaf_1') + + const msg = d.insertMessage({ + from: 'w', + to: 'c', + subject: 'done', + type: 'worker_done', + senderPaneKey: 'tab_1:leaf_1' + }) + expect(d.getMessageById(msg.id)?.sender_pane_key).toBe('tab_1:leaf_1') + }) + it('is idempotent: opening an already-migrated DB is a no-op', () => { const path = createV1Snapshot() const first = new OrchestrationDb(path) diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts index 0c137fe78a5..2525d30c851 100644 --- a/src/main/runtime/orchestration/db.ts +++ b/src/main/runtime/orchestration/db.ts @@ -15,6 +15,18 @@ import type { CoordinatorRun } from './types' import { buildOrchestrationTaskDisplayMetadata } from '../../../shared/orchestration-task-display' +import { parsePaneKey } from '../../../shared/stable-pane-id' + +// Why: leaf UUID is the remint-stable pane identity; the tab half changes on +// break-out. Exact string match covers legacy/unparseable keys. +function isEquivalentPaneKey(a: string, b: string): boolean { + if (a === b) { + return true + } + const aLeaf = parsePaneKey(a)?.leafId + const bLeaf = parsePaneKey(b)?.leafId + return Boolean(aLeaf && bLeaf && aLeaf === bLeaf) +} export type { MessageType, @@ -34,6 +46,22 @@ function generateId(prefix: string): string { return `${prefix}_${randomBytes(6).toString('hex')}` } +function addLifecycleRejectionMarker(payload: string | null, reason: string): string { + let parsed: Record = {} + try { + const value: unknown = payload ? JSON.parse(payload) : {} + if (value && typeof value === 'object' && !Array.isArray(value)) { + parsed = value as Record + } + } catch { + // Authority reconciliation only reaches this path with object payloads. + } + return JSON.stringify({ + ...parsed, + _orcaLifecycleRejection: { code: 'sender_not_assignee', reason } + }) +} + // Why: v1 → v2 added `'heartbeat'` to messages.type CHECK + `last_heartbeat_at` // column (preamble-hardening PR). v2 → v3 adds `delivered_at` column so // push-on-idle can distinguish queued-but-undelivered from user-acknowledged @@ -41,7 +69,10 @@ function generateId(prefix: string): string { // the terminal that created a task so task-record worktree creation can infer // the parent workspace even when no dispatch context exists. v4 → v5 adds // explicit task_title/display_name fields for orchestration worker UI labels. -const SCHEMA_VERSION = 5 +// v5 → v6 adds pane-identity columns (dispatch_contexts.assignee_pane_key, +// messages.sender_pane_key) so worker_done ownership survives terminal handle +// remints without accepting completions from unrelated panes. +const SCHEMA_VERSION = 6 export class OrchestrationDb { private db: Database.Database @@ -75,7 +106,8 @@ export class OrchestrationDb { read INTEGER NOT NULL DEFAULT 0, sequence INTEGER PRIMARY KEY AUTOINCREMENT, created_at TEXT NOT NULL DEFAULT (datetime('now')), - delivered_at TEXT + delivered_at TEXT, + sender_pane_key TEXT ); CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_id ON messages(id); @@ -107,6 +139,7 @@ export class OrchestrationDb { id TEXT PRIMARY KEY, task_id TEXT NOT NULL, assignee_handle TEXT, + assignee_pane_key TEXT, status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'dispatched', 'completed', 'failed', 'circuit_broken')), failure_count INTEGER NOT NULL DEFAULT 0, @@ -246,6 +279,14 @@ export class OrchestrationDb { this.db.exec(`ALTER TABLE tasks ADD COLUMN display_name TEXT`) } } + if (current < 6) { + if (!this.hasColumn('dispatch_contexts', 'assignee_pane_key')) { + this.db.exec(`ALTER TABLE dispatch_contexts ADD COLUMN assignee_pane_key TEXT`) + } + if (!this.hasColumn('messages', 'sender_pane_key')) { + this.db.exec(`ALTER TABLE messages ADD COLUMN sender_pane_key TEXT`) + } + } this.createUndeliveredInboxIndexIfPossible() this.db.pragma(`user_version = ${SCHEMA_VERSION}`) @@ -293,11 +334,12 @@ export class OrchestrationDb { priority?: MessagePriority threadId?: string payload?: string + senderPaneKey?: string }): MessageRow { const id = generateId('msg') const stmt = this.db.prepare(` - INSERT INTO messages (id, from_handle, to_handle, subject, body, type, priority, thread_id, payload) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO messages (id, from_handle, to_handle, subject, body, type, priority, thread_id, payload, sender_pane_key) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) stmt.run( id, @@ -308,7 +350,8 @@ export class OrchestrationDb { msg.type ?? 'status', msg.priority ?? 'normal', msg.threadId ?? null, - msg.payload ?? null + msg.payload ?? null, + msg.senderPaneKey ?? null ) return this.db.prepare('SELECT * FROM messages WHERE id = ?').get(id) as MessageRow } @@ -327,6 +370,27 @@ export class OrchestrationDb { .all(toHandle) as MessageRow[] } + convertLifecycleMessageToRejection(messageId: string, reason: string): MessageRow | undefined { + const message = this.getMessageById(messageId) + if (!message || (message.type !== 'worker_done' && message.type !== 'heartbeat')) { + return message + } + + const originalBody = message.body ? `\n\nOriginal body:\n${message.body}` : '' + const body = `Orca rejected this ${message.type}: ${reason}${originalBody}` + const payload = addLifecycleRejectionMarker(message.payload, reason) + // Why: rejected lifecycle signals must remain auditable without reaching + // later read paths as actionable completion or liveness events. + this.db + .prepare( + `UPDATE messages + SET priority = 'high', subject = ?, body = ?, payload = ? + WHERE id = ?` + ) + .run(`Rejected ${message.type}: ${message.subject}`, body, payload, messageId) + return this.getMessageById(messageId) + } + // Why: push-on-idle delivery must not replay messages that were already // injected into the PTY. `read` flips only when a check-caller consumes a // message, so delivered-but-unread rows would otherwise be re-injected on @@ -382,6 +446,20 @@ export class OrchestrationDb { .run(...ids) } + markAsReadAndDelivered(ids: string[]): void { + if (ids.length === 0) { + return + } + const placeholders = ids.map(() => '?').join(',') + // Why: superseded lifecycle messages stay queryable through history but + // must not be consumed or injected after their dispatch has finished. + this.db + .prepare( + `UPDATE messages SET read = 1, delivered_at = COALESCE(delivered_at, datetime('now')) WHERE id IN (${placeholders})` + ) + .run(...ids) + } + getInbox(limit = 20): MessageRow[] { return this.db .prepare('SELECT * FROM messages ORDER BY sequence DESC LIMIT ?') @@ -568,7 +646,14 @@ export class OrchestrationDb { // ── Dispatch Contexts ── - createDispatchContext(taskId: string, assigneeHandle: string): DispatchContextRow { + createDispatchContext( + taskId: string, + assigneeHandle: string, + // Why: the pane key is the remint-stable identity behind the handle; + // recording it at dispatch time lets worker_done ownership survive + // restarts that reissue the handle. + assigneePaneKey?: string + ): DispatchContextRow { const task = this.getTask(taskId) if (!task) { throw new Error(`Task not found: ${taskId}`) @@ -577,11 +662,11 @@ export class OrchestrationDb { throw new Error(`Task ${taskId} is ${task.status}; only ready tasks can be dispatched`) } - const existing = this.db - .prepare( - "SELECT * FROM dispatch_contexts WHERE assignee_handle = ? AND status IN ('pending', 'dispatched')" - ) - .get(assigneeHandle) as DispatchContextRow | undefined + // Why: handle match covers legacy rows without pane keys; when both the + // new assignee and an active row have usable pane keys, also lock on + // equivalent pane identity so a reminted handle cannot open a second + // concurrent dispatch on the same pane. + const existing = this.findActiveDispatchForAssignee(assigneeHandle, assigneePaneKey) if (existing) { throw new Error( @@ -599,10 +684,10 @@ export class OrchestrationDb { const id = generateId('ctx') this.db .prepare( - `INSERT INTO dispatch_contexts (id, task_id, assignee_handle, status, failure_count, dispatched_at) - VALUES (?, ?, ?, 'dispatched', ?, datetime('now'))` + `INSERT INTO dispatch_contexts (id, task_id, assignee_handle, assignee_pane_key, status, failure_count, dispatched_at) + VALUES (?, ?, ?, ?, 'dispatched', ?, datetime('now'))` ) - .run(id, taskId, assigneeHandle, priorFailures) + .run(id, taskId, assigneeHandle, assigneePaneKey ?? null, priorFailures) this.db.prepare("UPDATE tasks SET status = 'dispatched' WHERE id = ?").run(taskId) @@ -624,11 +709,38 @@ export class OrchestrationDb { } getActiveDispatchForTerminal(handle: string): DispatchContextRow | undefined { - return this.db + return this.findActiveDispatchForAssignee(handle) + } + + private findActiveDispatchForAssignee( + assigneeHandle: string, + assigneePaneKey?: string + ): DispatchContextRow | undefined { + const byHandle = this.db .prepare( "SELECT * FROM dispatch_contexts WHERE assignee_handle = ? AND status IN ('pending', 'dispatched') LIMIT 1" ) - .get(handle) as DispatchContextRow | undefined + .get(assigneeHandle) as DispatchContextRow | undefined + if (byHandle) { + return byHandle + } + + if (!assigneePaneKey) { + return undefined + } + + const actives = this.db + .prepare( + "SELECT * FROM dispatch_contexts WHERE assignee_pane_key IS NOT NULL AND status IN ('pending', 'dispatched')" + ) + .all() as DispatchContextRow[] + + for (const row of actives) { + if (row.assignee_pane_key && isEquivalentPaneKey(row.assignee_pane_key, assigneePaneKey)) { + return row + } + } + return undefined } getLatestDispatchForTerminal(handle: string): DispatchContextRow | undefined { @@ -686,17 +798,20 @@ export class OrchestrationDb { // failed / circuit_broken row with an old-or-null last_heartbeat_at would // warn every tick (warning storm). Without `dispatched_at < :threshold`, // a freshly-dispatched worker would trip the warning during its first - // heartbeat interval (false positive). Callers supply the threshold as an - // ISO timestamp so the SQLite string-compare ordering works correctly - // (ISO-8601 compares lexicographically in time order). + // heartbeat interval (false positive). The stored columns are space-format + // (datetime('now'), "2026-07-12 12:00:00") while the threshold is ISO-Z, so + // raw TEXT ordering compares ' ' (0x20) below 'T' (0x54) at index 10 and + // flags fresh same-date rows as stale (#8452). julianday() parses both + // formats as UTC for a correct numeric comparison; a malformed timestamp + // yields NULL, so that row simply isn't flagged. getStaleDispatches(thresholdIso: string): DispatchContextRow[] { return this.db .prepare( `SELECT * FROM dispatch_contexts WHERE status = 'dispatched' AND dispatched_at IS NOT NULL - AND dispatched_at < ? - AND (last_heartbeat_at IS NULL OR last_heartbeat_at < ?)` + AND julianday(dispatched_at) < julianday(?) + AND (last_heartbeat_at IS NULL OR julianday(last_heartbeat_at) < julianday(?))` ) .all(thresholdIso, thresholdIso) as DispatchContextRow[] } diff --git a/src/main/runtime/orchestration/formatter.test.ts b/src/main/runtime/orchestration/formatter.test.ts index 28182e3dbfb..ea8ae94bc19 100644 --- a/src/main/runtime/orchestration/formatter.test.ts +++ b/src/main/runtime/orchestration/formatter.test.ts @@ -17,6 +17,7 @@ function makeMessage(overrides: Partial = {}): MessageRow { sequence: 1, created_at: '2026-01-01T00:00:00Z', delivered_at: null, + sender_pane_key: null, ...overrides } } @@ -70,7 +71,9 @@ describe('formatMessageBanner', () => { it('includes reply hint with message ID', () => { const banner = formatMessageBanner(makeMessage({ id: 'msg_xyz789' })) - expect(banner).toContain('[Reply: orca orchestration reply --id msg_xyz789 --body "..."]') + expect(banner).toContain( + '[Reply: orca orchestration reply --id msg_xyz789 --from term_coord --body "..."]' + ) }) it('ends with a separator line', () => { diff --git a/src/main/runtime/orchestration/formatter.ts b/src/main/runtime/orchestration/formatter.ts index f5526553d3b..a86f3c61edf 100644 --- a/src/main/runtime/orchestration/formatter.ts +++ b/src/main/runtime/orchestration/formatter.ts @@ -25,7 +25,11 @@ export function formatMessageBanner(msg: MessageRow): string { lines.push(`[Payload: ${msg.payload}]`) } - lines.push(`[Reply: orca orchestration reply --id ${msg.id} --body "..."]`) + // Why: injected reply commands must retain the receiving pane's identity + // even when an older shell lacks Orca's terminal environment variables. + lines.push( + `[Reply: orca orchestration reply --id ${msg.id} --from ${msg.to_handle} --body "..."]` + ) lines.push(SEPARATOR) return lines.join('\n') diff --git a/src/main/runtime/orchestration/groups.test.ts b/src/main/runtime/orchestration/groups.test.ts index 483c8a1fe83..81c2345c8b2 100644 --- a/src/main/runtime/orchestration/groups.test.ts +++ b/src/main/runtime/orchestration/groups.test.ts @@ -30,6 +30,8 @@ describe('isGroupAddress', () => { expect(isGroupAddress('@idle')).toBe(true) expect(isGroupAddress('@claude')).toBe(true) expect(isGroupAddress('@droid')).toBe(true) + expect(isGroupAddress('@grok')).toBe(true) + expect(isGroupAddress('@cursor')).toBe(true) expect(isGroupAddress('@worktree:wt_1')).toBe(true) }) @@ -169,6 +171,136 @@ describe('resolveGroupAddress', () => { const result = resolveGroupAddress('@Claude', 'term_a', terminals, noStatus) expect(result).toEqual(['term_b']) }) + + it('matches @grok as a standalone title token and excludes sender', () => { + const terminals = [ + makeSummary('term_a', { title: 'Grok' }), + makeSummary('term_b', { title: 'GROK CLI' }), + makeSummary('term_c', { title: '⠋ Grok' }), + makeSummary('term_d', { title: 'ngrok' }), + makeSummary('term_e', { title: '/tmp/grok' }), + makeSummary('term_f', { title: 'my-grok-worker' }), + makeSummary('term_g', { title: 'Codex CLI' }) + ] + + const result = resolveGroupAddress('@GrOk', 'term_a', terminals, noStatus) + + expect(result).toEqual(['term_b', 'term_c']) + }) + + // Why: the resolver sees the raw OSC title, and Grok CLI's real working/session + // titles carry a trailing " - grok" identity or a spinner-collapsed "⠋ grok" + // (see terminal-title-agent-type.ts). Prove those production shapes resolve. + it('matches real Grok OSC working and session titles', () => { + const terminals = [ + makeSummary('coordinator', { title: 'Coordinator' }), + makeSummary('term_rotating', { title: '⠋ - fix the flaky suite - grok' }), + makeSummary('term_collapsed', { title: '⠋ grok' }), + makeSummary('term_session', { title: 'Fix the auth bug - grok' }) + ] + + const result = resolveGroupAddress('@grok', 'coordinator', terminals, noStatus) + + expect(result).toEqual(['term_rotating', 'term_collapsed', 'term_session']) + }) + + // Why: Windows agent titles can surface the launcher process name (`grok.exe`); + // the shared matcher accepts .exe/.cmd/.bat/.ps1 suffixes but still rejects + // arbitrary dotted fragments like `grok.py`. + it('matches Windows launcher-suffix titles but not arbitrary dotted tokens', () => { + const terminals = [ + makeSummary('coordinator', { title: 'Coordinator' }), + makeSummary('term_exe', { title: 'grok.exe' }), + makeSummary('term_cmd', { title: 'grok.cmd running' }), + makeSummary('term_dotted', { title: 'grok.py' }) + ] + + const result = resolveGroupAddress('@grok', 'coordinator', terminals, noStatus) + + expect(result).toEqual(['term_exe', 'term_cmd']) + }) + + // Why: these are the titles cursor-agent natively emits and the ones Orca synthesizes + // from Cursor hooks, so each must resolve. + it('matches native and Orca-synthesized Cursor titles', () => { + const terminals = [ + makeSummary('coordinator', { title: 'Coordinator' }), + makeSummary('term_native', { title: 'Cursor Agent' }), + makeSummary('term_working', { title: '⠋ Cursor Agent' }), + makeSummary('term_idle', { title: 'Cursor ready' }), + makeSummary('term_permission', { title: 'Cursor - action required' }) + ] + + const result = resolveGroupAddress('@cursor', 'coordinator', terminals, noStatus) + + expect(result).toEqual(['term_native', 'term_working', 'term_idle', 'term_permission']) + }) + + it('is case-insensitive for @cursor', () => { + const terminals = [ + makeSummary('coordinator', { title: 'Coordinator' }), + makeSummary('term_b', { title: 'CURSOR READY' }) + ] + + const result = resolveGroupAddress('@CuRsOr', 'coordinator', terminals, noStatus) + + expect(result).toEqual(['term_b']) + }) + + // Why: this is the false positive that a whole-token matcher cannot catch. Claude + // and Codex put their task summary in the OSC title, and none of Claude's status + // prefixes carries its own name, so "cursor" as ordinary editor vocabulary is the + // only agent token such a title has. Routing @cursor there would write into a live + // non-Cursor agent's prompt. + it('does not match another agent whose task title mentions a text cursor', () => { + const terminals = [ + makeSummary('coordinator', { title: 'Coordinator' }), + makeSummary('term_claude_working', { + title: '⠋ preserve cursor visibility across replays' + }), + makeSummary('term_claude_idle', { title: '✳ Fix the text cursor blink' }), + makeSummary('term_claude_dot', { title: '. fix cursor position' }), + makeSummary('term_claude_star', { title: '* cursor rendering done' }), + makeSummary('term_codex', { title: '⠋ Codex: fix cursor offsets' }), + makeSummary('term_grok', { title: '⠋ - restoring cursor state - grok' }), + makeSummary('term_shell', { title: 'Terminal Cursor and Orca slows down' }) + ] + + const result = resolveGroupAddress('@cursor', 'coordinator', terminals, noStatus) + + expect(result).toEqual([]) + }) + + // Why: pin the deliberate tradeoff. A renamed Cursor pane outranks its OSC title, so it + // is group-unaddressable and resolves to nothing instead of resolving loosely. A silent + // miss (address it by handle) is preferred over delivering into another agent's prompt. + it('does not match a renamed Cursor terminal or the bare process name', () => { + const terminals = [ + makeSummary('coordinator', { title: 'Coordinator' }), + makeSummary('term_renamed', { title: 'Cursor - reviewer' }), + makeSummary('term_worker', { title: 'cursor worker 2' }), + makeSummary('term_process', { title: 'cursor-agent' }) + ] + + const result = resolveGroupAddress('@cursor', 'coordinator', terminals, noStatus) + + expect(result).toEqual([]) + }) + + it('does not match cursor paths, hyphenated compounds, or dotted tokens', () => { + const terminals = [ + makeSummary('coordinator', { title: 'Coordinator' }), + makeSummary('term_rules', { title: '~/cursor-rules' }), + makeSummary('term_path', { title: '/tmp/cursor' }), + makeSummary('term_worker', { title: 'my-cursor-worker' }), + makeSummary('term_file', { title: 'render-cursor-after-bracketed-paste' }), + makeSummary('term_dotted', { title: 'cursor.ts' }) + ] + + const result = resolveGroupAddress('@cursor', 'coordinator', terminals, noStatus) + + expect(result).toEqual([]) + }) }) describe('unknown groups', () => { diff --git a/src/main/runtime/orchestration/groups.ts b/src/main/runtime/orchestration/groups.ts index c897ae03866..5d24f68a107 100644 --- a/src/main/runtime/orchestration/groups.ts +++ b/src/main/runtime/orchestration/groups.ts @@ -1,3 +1,5 @@ +import { isCursorAgentTitle } from '../../../shared/agent-title-core' +import { buildAgentNameRe } from '../../../shared/agent-name-token-match' import type { RuntimeTerminalSummary } from '../../../shared/runtime-types' // Why: group addresses enable broadcast messaging to logical groups of agents. @@ -11,26 +13,36 @@ const AGENT_NAME_GROUPS = [ 'opencode', 'mimo', 'gemini', - 'droid' + 'droid', + 'grok', + 'cursor' ] as const -export type GroupAddress = - | '@all' - | '@idle' - | `@${(typeof AGENT_NAME_GROUPS)[number]}` - | `@worktree:${string}` +type AgentNameGroup = (typeof AGENT_NAME_GROUPS)[number] + +export type GroupAddress = '@all' | '@idle' | `@${AgentNameGroup}` | `@worktree:${string}` export function isGroupAddress(to: string): boolean { return to.startsWith('@') } -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +// Why: a name token identifies an agent only when the name is a coined word. `cursor` is +// also ordinary vocabulary in another agent's task-summary title ("fix the text cursor +// blink"), so token-matching it would route @cursor into a live Claude/Codex prompt. Names +// with that ambiguity register the identity predicate delivery already applies to them. +const GROUP_TITLE_MATCHERS: Partial boolean>> = { + cursor: isCursorAgentTitle } function titleMatchesAgentNameGroup(title: string, agentName: string): boolean { - const tokenRe = new RegExp(`(? { + let db: OrchestrationDb + + afterEach(() => db?.close()) + + it('rejects handle churn when neither side has stable pane identity', () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'work' }) + const dispatch = db.createDispatchContext(task.id, 'term_before_restart') + const logs: string[] = [] + const message = db.insertMessage({ + from: 'term_after_restart', + to: 'term_coordinator', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }) + }) + + expect(reconcileLifecycleMessage(db, message, (line) => logs.push(line))).toMatchObject({ + action: 'rejected', + code: 'sender_not_assignee' + }) + expect(db.getTask(task.id)?.status).toBe('dispatched') + expect(db.getDispatchContextById(dispatch.id)?.status).toBe('dispatched') + expect(logs.some((line) => line.includes('expected handle'))).toBe(true) + }) + + // Real leaf UUIDs: pane keys are `${tabId}:${leafUuid}` and only the leaf + // half is identity (the tab half changes on pane break-out). + const LEAF_A = '11111111-1111-1111-8111-111111111111' + const LEAF_B = '22222222-2222-4222-9222-222222222222' + + it('completes worker_done from the dispatched pane after a handle remint', () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'work' }) + const dispatch = db.createDispatchContext(task.id, 'term_before_restart', `tab_w:${LEAF_A}`) + const message = db.insertMessage({ + from: 'term_after_restart', + to: 'term_coordinator', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }), + senderPaneKey: `tab_w:${LEAF_A}` + }) + + expect(reconcileLifecycleMessage(db, message).action).toBe('completed') + expect(db.getTask(task.id)?.status).toBe('completed') + }) + + it('completes worker_done from the same leaf after a pane break-out changed the tab half', () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'work' }) + // Dispatch recorded the post-break-out pane key; the worker shell still + // holds the spawn-time key with the old tab id. + const dispatch = db.createDispatchContext(task.id, 'term_before_restart', `tab_new:${LEAF_A}`) + const message = db.insertMessage({ + from: 'term_after_restart', + to: 'term_coordinator', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }), + senderPaneKey: `tab_old:${LEAF_A}` + }) + + expect(reconcileLifecycleMessage(db, message).action).toBe('completed') + expect(db.getTask(task.id)?.status).toBe('completed') + }) + + it('rejects mismatched opaque pane keys instead of treating them as legacy', () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'work' }) + const dispatch = db.createDispatchContext(task.id, 'term_owner', `tab_w:${LEAF_A}`) + const message = db.insertMessage({ + from: 'term_reminted', + to: 'term_coordinator', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }), + senderPaneKey: 'tab_w:42' + }) + + expect(reconcileLifecycleMessage(db, message).action).toBe('rejected') + expect(db.getTask(task.id)?.status).toBe('dispatched') + }) + + it('rejects worker_done from a foreign pane that claims the assignee handle', () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'work' }) + const dispatch = db.createDispatchContext(task.id, 'term_owner', `tab_w1:${LEAF_A}`) + const message = db.insertMessage({ + from: 'term_owner', + to: 'term_coordinator', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }), + senderPaneKey: `tab_w2:${LEAF_B}` + }) + + expect(reconcileLifecycleMessage(db, message)).toMatchObject({ + action: 'rejected', + code: 'sender_not_assignee', + reason: expect.stringContaining('expected handle term_owner') + }) + expect(db.getTask(task.id)?.status).toBe('dispatched') + expect(db.getMessageById(message.id)).toMatchObject({ + type: 'worker_done', + priority: 'high', + subject: 'Rejected worker_done: Done', + body: expect.stringContaining('Orca rejected this worker_done') + }) + const persisted = db.getMessageById(message.id) + expect(JSON.parse(persisted?.payload ?? '{}')).toMatchObject({ + taskId: task.id, + dispatchId: dispatch.id, + _orcaLifecycleRejection: { code: 'sender_not_assignee' } + }) + const rereadLogs: string[] = [] + expect( + persisted && reconcileLifecycleMessage(db, persisted, (line) => rereadLogs.push(line)) + ).toMatchObject({ + action: 'rejected', + code: 'sender_not_assignee' + }) + // Why: the send-path conversion logs nothing (no-op logger), so the + // coordinator's re-read must still surface the rejection to its log stream. + expect(rereadLogs.some((line) => line.includes('worker_done rejected'))).toBe(true) + }) + + it('does not let a caller-supplied rejection marker turn completion into success', () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'work' }) + const dispatch = db.createDispatchContext(task.id, 'term_worker', `tab_w:${LEAF_A}`) + const message = db.insertMessage({ + from: 'term_worker', + to: 'term_coordinator', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ + taskId: task.id, + dispatchId: dispatch.id, + _orcaLifecycleRejection: { + code: 'sender_not_assignee', + reason: 'caller supplied' + } + }), + senderPaneKey: `tab_w:${LEAF_A}` + }) + + expect(reconcileLifecycleMessage(db, message)).toEqual({ + action: 'rejected', + code: 'sender_not_assignee', + reason: 'caller supplied' + }) + expect(db.getTask(task.id)?.status).toBe('dispatched') + }) + + it('rejects a coordinator completion for a pane-bound dispatch', () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'work' }) + const dispatch = db.createDispatchContext(task.id, 'term_worker', `tab_w:${LEAF_A}`) + const message = db.insertMessage({ + from: 'term_coordinator', + to: 'term_coordinator', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }) + }) + + expect(reconcileLifecycleMessage(db, message)).toMatchObject({ + action: 'rejected', + code: 'sender_not_assignee' + }) + expect(db.getTask(task.id)?.status).toBe('dispatched') + }) + + it('uses exact handle equality only for a legacy dispatch without a pane key', () => { + db = new OrchestrationDb(':memory:') + const acceptedTask = db.createTask({ spec: 'legacy work' }) + const acceptedDispatch = db.createDispatchContext(acceptedTask.id, 'term_legacy') + const accepted = db.insertMessage({ + from: 'term_legacy', + to: 'term_coordinator', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: acceptedTask.id, dispatchId: acceptedDispatch.id }) + }) + expect(reconcileLifecycleMessage(db, accepted).action).toBe('completed') + + const rejectedTask = db.createTask({ spec: 'other legacy work' }) + const rejectedDispatch = db.createDispatchContext(rejectedTask.id, 'term_other_legacy') + const rejected = db.insertMessage({ + from: 'term_foreign', + to: 'term_coordinator', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: rejectedTask.id, dispatchId: rejectedDispatch.id }) + }) + expect(reconcileLifecycleMessage(db, rejected)).toMatchObject({ + action: 'rejected', + code: 'sender_not_assignee' + }) + expect(db.getTask(rejectedTask.id)?.status).toBe('dispatched') + }) + + it('does not release a dependent when a foreign completion wins the arrival race', () => { + db = new OrchestrationDb(':memory:') + const parent = db.createTask({ spec: 'parent' }) + const child = db.createTask({ spec: 'child', deps: [parent.id] }) + const dispatch = db.createDispatchContext(parent.id, 'term_worker', `tab_w:${LEAF_A}`) + const payload = JSON.stringify({ taskId: parent.id, dispatchId: dispatch.id }) + + const foreign = db.insertMessage({ + from: 'term_coordinator', + to: 'term_coordinator', + subject: 'Done', + type: 'worker_done', + payload, + senderPaneKey: `tab_c:${LEAF_B}` + }) + expect(reconcileLifecycleMessage(db, foreign)).toMatchObject({ + action: 'rejected', + code: 'sender_not_assignee' + }) + expect(db.getTask(child.id)?.status).toBe('pending') + + const owner = db.insertMessage({ + from: 'term_worker_reminted', + to: 'term_coordinator', + subject: 'Done', + type: 'worker_done', + payload, + senderPaneKey: `tab_w_after:${LEAF_A}` + }) + expect(reconcileLifecycleMessage(db, owner).action).toBe('completed') + expect(db.getTask(child.id)?.status).toBe('ready') + }) + + it('does not let a foreign replay overwrite an authorized completion', () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'work' }) + const dispatch = db.createDispatchContext(task.id, 'term_worker', `tab_w:${LEAF_A}`) + const payload = JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }) + const owner = db.insertMessage({ + from: 'term_worker', + to: 'term_coordinator', + subject: 'Done', + type: 'worker_done', + payload, + senderPaneKey: `tab_w:${LEAF_A}` + }) + expect(reconcileLifecycleMessage(db, owner).action).toBe('completed') + const result = db.getTask(task.id)?.result + + const replay = db.insertMessage({ + from: 'term_foreign', + to: 'term_coordinator', + subject: 'Forged replay', + type: 'worker_done', + payload, + senderPaneKey: `tab_f:${LEAF_B}` + }) + expect(reconcileLifecycleMessage(db, replay)).toMatchObject({ + action: 'rejected', + code: 'sender_not_assignee' + }) + expect(db.getTask(task.id)?.result).toBe(result) + }) + + it('surfaces worker_done sent from a different pane as rejected', () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'work' }) + const dispatch = db.createDispatchContext(task.id, 'term_owner', `tab_w1:${LEAF_A}`) + const logs: string[] = [] + const message = db.insertMessage({ + from: 'term_other_worker', + to: 'term_coordinator', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }), + senderPaneKey: `tab_w2:${LEAF_B}` + }) + + expect(reconcileLifecycleMessage(db, message, (line) => logs.push(line))).toMatchObject({ + action: 'rejected', + code: 'sender_not_assignee' + }) + expect(db.getTask(task.id)?.status).toBe('dispatched') + expect(db.getDispatchContextById(dispatch.id)?.status).toBe('dispatched') + expect(logs.some((line) => line.includes('worker_done rejected'))).toBe(true) + }) + + it('surfaces a heartbeat sent from a different pane without recording liveness', () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'work' }) + const dispatch = db.createDispatchContext(task.id, 'term_owner', `tab_w1:${LEAF_A}`) + const heartbeat = db.insertMessage({ + from: 'term_other_worker', + to: 'term_coordinator', + subject: 'alive', + type: 'heartbeat', + payload: JSON.stringify({ dispatchId: dispatch.id }), + senderPaneKey: `tab_w2:${LEAF_B}` + }) + + expect(reconcileLifecycleMessage(db, heartbeat)).toMatchObject({ + action: 'rejected', + code: 'sender_not_assignee' + }) + expect(db.getDispatchContextById(dispatch.id)?.last_heartbeat_at).toBeNull() + expect(db.getMessageById(heartbeat.id)).toMatchObject({ + type: 'heartbeat', + subject: 'Rejected heartbeat: alive' + }) + // Why: the coordinator's re-read of the already-converted heartbeat must + // still log the rejection, since the send-path conversion logged nothing. + const persisted = db.getMessageById(heartbeat.id) + const rereadLogs: string[] = [] + expect( + persisted && reconcileLifecycleMessage(db, persisted, (line) => rereadLogs.push(line)) + ).toMatchObject({ + action: 'rejected' + }) + expect(rereadLogs.some((line) => line.includes('Heartbeat rejected'))).toBe(true) + }) + + it('surfaces a foreign heartbeat that claims the assignee handle', () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'work' }) + const dispatch = db.createDispatchContext(task.id, 'term_owner', `tab_w1:${LEAF_A}`) + const heartbeat = db.insertMessage({ + from: 'term_owner', + to: 'term_coordinator', + subject: 'alive', + type: 'heartbeat', + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }), + senderPaneKey: `tab_w2:${LEAF_B}` + }) + + expect(reconcileLifecycleMessage(db, heartbeat)).toMatchObject({ + action: 'rejected', + code: 'sender_not_assignee' + }) + expect(db.getDispatchContextById(dispatch.id)?.last_heartbeat_at).toBeNull() + }) + + it('records a heartbeat whose pane key drifted only in the tab half', () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'work' }) + const dispatch = db.createDispatchContext(task.id, 'term_owner', `tab_new:${LEAF_A}`) + const heartbeat = db.insertMessage({ + from: 'term_owner', + to: 'term_coordinator', + subject: 'alive', + type: 'heartbeat', + payload: JSON.stringify({ dispatchId: dispatch.id }), + senderPaneKey: `tab_old:${LEAF_A}` + }) + + expect(reconcileLifecycleMessage(db, heartbeat)).toEqual({ + action: 'heartbeat_recorded', + dispatchId: dispatch.id + }) + expect(db.getDispatchContextById(dispatch.id)?.last_heartbeat_at).not.toBeNull() + }) + + it('suppresses same-dispatch heartbeats once worker_done is reconciled', () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'work' }) + const dispatch = db.createDispatchContext(task.id, 'term_worker') + const otherTask = db.createTask({ spec: 'other work' }) + const otherDispatch = db.createDispatchContext(otherTask.id, 'term_other') + const insertHeartbeat = (dispatchId: string, from: string) => + db.insertMessage({ + from, + to: 'term_coordinator', + subject: 'alive', + type: 'heartbeat', + payload: JSON.stringify({ dispatchId }) + }) + const staleHeartbeat = insertHeartbeat(dispatch.id, 'term_worker') + const otherHeartbeat = insertHeartbeat(otherDispatch.id, 'term_other') + reconcileLifecycleMessage(db, staleHeartbeat) + reconcileLifecycleMessage(db, otherHeartbeat) + const done = db.insertMessage({ + from: 'term_worker', + to: 'term_coordinator', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }) + }) + + reconcileLifecycleMessage(db, done) + + expect(db.getUnreadMessages('term_coordinator', ['heartbeat']).map((row) => row.id)).toEqual([ + otherHeartbeat.id + ]) + const archived = db + .getAllMessagesForHandle('term_coordinator') + .find((row) => row.id === staleHeartbeat.id) + expect(archived).toMatchObject({ read: 1 }) + expect(archived?.delivered_at).not.toBeNull() + + const lateHeartbeat = insertHeartbeat(dispatch.id, 'term_worker') + expect(reconcileLifecycleMessage(db, lateHeartbeat)).toEqual({ action: 'suppressed' }) + expect(db.getMessageById(lateHeartbeat.id)).toMatchObject({ read: 1 }) + expect(db.getMessageById(lateHeartbeat.id)?.delivered_at).not.toBeNull() + }) +}) diff --git a/src/main/runtime/orchestration/lifecycle-reconciliation.ts b/src/main/runtime/orchestration/lifecycle-reconciliation.ts index a413924c59a..96917babaf6 100644 --- a/src/main/runtime/orchestration/lifecycle-reconciliation.ts +++ b/src/main/runtime/orchestration/lifecycle-reconciliation.ts @@ -1,11 +1,48 @@ import type { OrchestrationDb } from './db' import type { MessageRow } from './types' +import { parsePaneKey } from '../../../shared/stable-pane-id' + +// Why: the tab half can change on pane break-out, while opaque legacy keys +// have no safe equivalence beyond exact equality. +function isSamePane(assigneePaneKey: string, senderPaneKey: string): boolean { + if (assigneePaneKey === senderPaneKey) { + return true + } + const assigneeLeaf = parsePaneKey(assigneePaneKey)?.leafId + const senderLeaf = parsePaneKey(senderPaneKey)?.leafId + return Boolean(assigneeLeaf && senderLeaf && assigneeLeaf === senderLeaf) +} + +function hasLifecycleAuthority( + dispatch: { assignee_handle: string | null; assignee_pane_key: string | null }, + msg: MessageRow +): boolean { + if (dispatch.assignee_pane_key) { + return Boolean( + msg.sender_pane_key && isSamePane(dispatch.assignee_pane_key, msg.sender_pane_key) + ) + } + // Why: rows created before pane identity existed can only use the exact + // handle recorded at dispatch; payload knowledge alone is not authority. + return dispatch.assignee_handle === msg.from_handle +} export type LifecycleReconciliationResult = | { action: 'ignored' } + // Why: `suppressed` means the message was consumed at reconcile time (marked + // read); senders must not wake waiters for it, unlike `ignored` rows that + // stay unread and still need delivery. + | { action: 'suppressed' } + | LifecycleRejectionResult | { action: 'completed'; taskId: string; dispatchId: string } | { action: 'heartbeat_recorded'; dispatchId: string } +export type LifecycleRejectionResult = { + action: 'rejected' + code: 'sender_not_assignee' + reason: string +} + type LogFn = (msg: string) => void const noopLog: LogFn = () => {} @@ -24,6 +61,27 @@ function parseObjectPayload(msg: MessageRow, onInvalidJson: () => void): Record< } } +function getPersistedLifecycleRejection( + payload: Record +): LifecycleRejectionResult | undefined { + const rejection = payload._orcaLifecycleRejection + if ( + !rejection || + typeof rejection !== 'object' || + (rejection as { code?: unknown }).code !== 'sender_not_assignee' || + typeof (rejection as { reason?: unknown }).reason !== 'string' + ) { + return undefined + } + // Why: the marker is reserved persistence state; treating it as a rejection + // also prevents caller-supplied markers from turning lifecycle sends into success. + return { + action: 'rejected', + code: 'sender_not_assignee', + reason: (rejection as { reason: string }).reason + } +} + export function reconcileLifecycleMessage( db: OrchestrationDb, msg: MessageRow, @@ -57,12 +115,37 @@ function reconcileHeartbeatMessage( const payload = parseObjectPayload(msg, () => { onLog(`Heartbeat from ${msg.from_handle} has invalid JSON payload; ignored`) }) + const persistedRejection = getPersistedLifecycleRejection(payload) + if (persistedRejection) { + // Why: the send-path reconcile converts with a no-op logger, so the + // coordinator's re-read is the only chance to surface the rejection. + onLog(`Heartbeat rejected: ${persistedRejection.reason}`) + return persistedRejection + } const dispatchId = payload.dispatchId if (typeof dispatchId !== 'string' || dispatchId.length === 0) { onLog(`Heartbeat from ${msg.from_handle} missing dispatchId; ignored`) return { action: 'ignored' } } + const dispatch = db.getDispatchContextById(dispatchId) + if (!dispatch || dispatch.status !== 'dispatched') { + // Why: an in-flight heartbeat can arrive after completion; retain it for + // audit history without surfacing obsolete liveness to the coordinator. + db.markAsReadAndDelivered([msg.id]) + onLog(`Heartbeat for inactive dispatch ${dispatchId} suppressed`) + return { action: 'suppressed' } + } + + if (!hasLifecycleAuthority(dispatch, msg)) { + // Why: a wrong-pane heartbeat must not refresh liveness — it would mask + // a hung assignee behind another agent's timer. + const reason = buildLifecycleAuthorityRejectionReason(dispatchId, dispatch, msg) + onLog(`Heartbeat rejected: ${reason}`) + db.convertLifecycleMessageToRejection(msg.id, reason) + return { action: 'rejected', code: 'sender_not_assignee', reason } + } + // Why: dispatchId-specific writes let the DB ignore late heartbeats for // completed/failed retries without masking a newer hung dispatch. db.recordHeartbeat(dispatchId, msg.created_at) @@ -79,6 +162,13 @@ function reconcileWorkerDoneMessage( const payload = parseObjectPayload(msg, () => { onLog(`Warning: invalid payload in worker_done from ${msg.from_handle}`) }) + const persistedRejection = getPersistedLifecycleRejection(payload) + if (persistedRejection) { + // Why: the send-path reconcile converts with a no-op logger, so the + // coordinator's re-read is the only chance to surface the rejection. + onLog(`Warning: worker_done rejected: ${persistedRejection.reason}`) + return persistedRejection + } const taskId = payload.taskId if (typeof taskId !== 'string' || taskId.length === 0) { @@ -111,11 +201,11 @@ function reconcileWorkerDoneMessage( ) return { action: 'ignored' } } - if (dispatch.assignee_handle !== msg.from_handle) { - onLog( - `Warning: worker_done for dispatch ${dispatchId} came from ${msg.from_handle}, expected ${dispatch.assignee_handle ?? ''}` - ) - return { action: 'ignored' } + if (!hasLifecycleAuthority(dispatch, msg)) { + const reason = buildLifecycleAuthorityRejectionReason(dispatchId, dispatch, msg) + onLog(`Warning: worker_done rejected: ${reason}`) + db.convertLifecycleMessageToRejection(msg.id, reason) + return { action: 'rejected', code: 'sender_not_assignee', reason } } // Why: `orchestration.send` can release the DB lock before waking the // coordinator; the later coordinator read still needs to observe completion. @@ -143,7 +233,38 @@ function reconcileWorkerDoneMessage( completedAt: new Date().toISOString() }) db.updateTaskStatus(taskId, 'completed', result) + suppressEarlierHeartbeats(db, msg, dispatchId) onLog(`Task ${taskId} completed`) return { action: 'completed', taskId, dispatchId } } + +function buildLifecycleAuthorityRejectionReason( + dispatchId: string, + dispatch: { assignee_handle: string | null; assignee_pane_key: string | null }, + msg: MessageRow +): string { + return ( + `dispatch ${dispatchId} expected handle ${dispatch.assignee_handle ?? ''}, ` + + `pane ${dispatch.assignee_pane_key ?? ''}; received handle ${msg.from_handle}, ` + + `pane ${msg.sender_pane_key ?? ''}` + ) +} + +function suppressEarlierHeartbeats( + db: OrchestrationDb, + workerDone: MessageRow, + dispatchId: string +): void { + const heartbeatIds = db + .getUnreadMessages(workerDone.to_handle, ['heartbeat']) + .filter((message) => { + if (message.sequence >= workerDone.sequence) { + return false + } + const payload = parseObjectPayload(message, () => undefined) + return payload.dispatchId === dispatchId + }) + .map((message) => message.id) + db.markAsReadAndDelivered(heartbeatIds) +} diff --git a/src/main/runtime/orchestration/preamble.test.ts b/src/main/runtime/orchestration/preamble.test.ts index 23fac4843ab..ef133551034 100644 --- a/src/main/runtime/orchestration/preamble.test.ts +++ b/src/main/runtime/orchestration/preamble.test.ts @@ -8,6 +8,7 @@ function baseParams(overrides: Partial[ dispatchId: 'ctx_def456', taskSpec: 'Implement the login form', coordinatorHandle: 'term_coord', + workerHandle: 'term_worker', ...overrides } } @@ -46,6 +47,7 @@ describe('buildDispatchPreamble', () => { expect(result).toContain('--dispatch-id ctx_def456') expect(result).toContain('--files-modified "path/a,path/b"') expect(result).toContain('--report-path ""') + expect(result).toMatch(/orchestration send --to term_coord --from term_worker/) }) it( @@ -83,11 +85,12 @@ describe('buildDispatchPreamble', () => { expect(result).toContain('--task-id task_abc123') expect(result).toContain('--dispatch-id ctx_def456') expect(result).toContain('--phase ""') + expect(result).toMatch(/orchestration send --to term_coord --from term_worker/) }) it('includes ask block with BEHAVIOR RULE #1 forbidding AskUserQuestion', () => { const result = buildDispatchPreamble(baseParams()) - expect(result).toContain('orchestration ask') + expect(result).toMatch(/orchestration ask --to term_coord --from term_worker/) expect(result).toContain('--question') expect(result).toContain('--timeout-ms 600000') // Why: the exact phrase is asserted so the rule can't be trimmed away by @@ -103,6 +106,16 @@ describe('buildDispatchPreamble', () => { expect(occurrences).toBe(3) }) + it('binds every injected worker command to the dispatched terminal', () => { + const result = buildDispatchPreamble(baseParams()) + + expect(result).toMatch(/orchestration ask --to term_coord --from term_worker/) + expect(result).toMatch( + /orchestration send --to term_coord --from term_worker \\\n --type escalation/ + ) + expect(result).toContain('orchestration check --terminal term_worker') + }) + it('tells prompt-returning workers to idle without post-done polling', () => { const result = buildDispatchPreamble(baseParams()) const section = afterWorkerDoneSection(result) @@ -146,7 +159,7 @@ describe('buildDispatchPreamble', () => { }) it('uses orca-dev CLI when devMode is true', () => { - const result = buildDispatchPreamble(baseParams({ devMode: true })) + const result = buildDispatchPreamble(baseParams({ devMode: true, cliCommand: 'orca-ide' })) expect(result).toContain('orca-dev orchestration send') expect(result).toContain('orca-dev orchestration check') expect(result).toContain('orca-dev orchestration ask') @@ -162,12 +175,22 @@ describe('buildDispatchPreamble', () => { expect(result).toContain('orca orchestration check') }) + it('uses the exact orca-ide command for packaged WSL workers', () => { + const result = buildDispatchPreamble(baseParams({ cliCommand: 'orca-ide' })) + + expect(result).toContain('orca-ide orchestration send') + expect(result).toContain('orca-ide orchestration check') + expect(result).toContain('orca-ide orchestration ask') + expect(result).not.toMatch(/(^|\s)orca orchestration/m) + }) + it('appends a BASE DRIFT section when baseDrift.behind > 0', () => { const result = buildDispatchPreamble({ taskId: 'task_x', dispatchId: 'ctx_x', taskSpec: 'do stuff', coordinatorHandle: 'term_c', + workerHandle: 'term_w', baseDrift: { base: 'origin/main', behind: 7, @@ -190,6 +213,7 @@ describe('buildDispatchPreamble', () => { dispatchId: 'ctx_x', taskSpec: 'do stuff', coordinatorHandle: 'term_c', + workerHandle: 'term_w', baseDrift: { base: 'origin/main', behind: 0, @@ -206,7 +230,8 @@ describe('buildDispatchPreamble', () => { taskId: 'task_x', dispatchId: 'ctx_x', taskSpec: 'do stuff', - coordinatorHandle: 'term_c' + coordinatorHandle: 'term_c', + workerHandle: 'term_w' }) expect(result).not.toContain('--- BASE DRIFT ---') @@ -219,6 +244,7 @@ describe('buildDispatchPreamble', () => { dispatchId: 'ctx_x', taskSpec: 'do stuff', coordinatorHandle: 'term_c', + workerHandle: 'term_w', baseDrift: { base: 'origin/main', behind: 3, @@ -241,7 +267,8 @@ describe('buildDispatchPreamble', () => { taskId: 'task_SNAP', dispatchId: 'ctx_SNAP', taskSpec: 'TASK_BODY', - coordinatorHandle: 'term_COORD' + coordinatorHandle: 'term_COORD', + workerHandle: 'term_WORKER' }) expect(result).toMatchSnapshot() }) diff --git a/src/main/runtime/orchestration/preamble.ts b/src/main/runtime/orchestration/preamble.ts index fdee65505e5..a8012c56854 100644 --- a/src/main/runtime/orchestration/preamble.ts +++ b/src/main/runtime/orchestration/preamble.ts @@ -1,3 +1,5 @@ +import type { OrchestrationCliCommand } from './cli-command' + export type PreambleParams = { taskId: string // Why: completion and heartbeat payloads attribute activity to a specific @@ -8,7 +10,11 @@ export type PreambleParams = { dispatchId: string taskSpec: string coordinatorHandle: string + workerHandle: string devMode?: boolean + // Why: packaged WSL panes install the scoped launcher as `orca-ide`; + // other execution hosts keep their existing bare `orca` bridge. + cliCommand?: OrchestrationCliCommand // Why: populated by the coordinator's dispatch pre-flight (§3.1) only // when the target worktree is behind its tracking remote. When absent // or when `behind === 0`, the preamble emits no drift section. Callers @@ -41,7 +47,7 @@ export function buildDispatchPreamble(params: PreambleParams): string { // Why: in dev mode, agents must use orca-dev to connect to the dev runtime's // socket. Without this, agents inside the dev Electron app would call the // production CLI and talk to the wrong Orca instance (Section 6.4). - const cli = params.devMode ? 'orca-dev' : 'orca' + const cli = params.devMode ? 'orca-dev' : (params.cliCommand ?? 'orca') const postDoneInstructions = buildPostWorkerDoneInstructions({ cli, workerKind: params.workerKind ?? 'prompt-returning-agent' @@ -68,7 +74,7 @@ Slack, GitHub comments, or any other channel to reach a human during the run. # with subject like "Failed: " — never silently exit. # Include BOTH taskId and dispatchId in the payload so a late completion # from a failed retry cannot complete the current dispatch. - ${cli} orchestration send --to ${params.coordinatorHandle} \\ + ${cli} orchestration send --to ${params.coordinatorHandle} --from ${params.workerHandle} \\ --type worker_done --subject "" \\ --body "<3-sentence summary: what you did, what you found, what's left>" \\ --task-id ${params.taskId} --dispatch-id ${params.dispatchId} \\ @@ -85,7 +91,7 @@ Slack, GitHub comments, or any other channel to reach a human during the run. # attributes the heartbeat to the specific dispatch context, not just # the task, so a straggler heartbeat from a previously-failed dispatch # cannot mask a hung retry. - ${cli} orchestration send --to ${params.coordinatorHandle} \\ + ${cli} orchestration send --to ${params.coordinatorHandle} --from ${params.workerHandle} \\ --type heartbeat --subject "alive" \\ --task-id ${params.taskId} --dispatch-id ${params.dispatchId} \\ --phase "" @@ -102,20 +108,20 @@ Slack, GitHub comments, or any other channel to reach a human during the run. # blocks on \`check --wait\` until the coordinator replies, then prints the # reply body. Use it anywhere you would otherwise have reached for # AskUserQuestion. - ${cli} orchestration ask --to ${params.coordinatorHandle} \\ + ${cli} orchestration ask --to ${params.coordinatorHandle} --from ${params.workerHandle} \\ --question "" \\ --options "" \\ --timeout-ms 600000 # Escalate a blocker or failure (pre-completion, when you need the # coordinator to do something before you can continue): - ${cli} orchestration send --to ${params.coordinatorHandle} \\ + ${cli} orchestration send --to ${params.coordinatorHandle} --from ${params.workerHandle} \\ --type escalation --subject "Blocked: " \\ --body "
" \\ --task-id ${params.taskId} # Check for messages from the coordinator: - ${cli} orchestration check + ${cli} orchestration check --terminal ${params.workerHandle} ${postDoneInstructions}` diff --git a/src/main/runtime/orchestration/types.ts b/src/main/runtime/orchestration/types.ts index cf3c780287d..58b86e41b08 100644 --- a/src/main/runtime/orchestration/types.ts +++ b/src/main/runtime/orchestration/types.ts @@ -32,6 +32,7 @@ export type MessageRow = { sequence: number created_at: string delivered_at: string | null + sender_pane_key: string | null } export type TaskRow = { @@ -52,6 +53,7 @@ export type DispatchContextRow = { id: string task_id: string assignee_handle: string | null + assignee_pane_key: string | null status: DispatchStatus failure_count: number last_failure: string | null diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index a9cf333da50..bb800b54617 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -51,6 +51,9 @@ const RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ const COMPUTER_PASSTHROUGH_CODES: ReadonlySet = new Set(Object.values(COMPUTER_ERROR_CODES)) const LINEAR_PASSTHROUGH_CODES: ReadonlySet = new Set(LINEAR_ERROR_CODES) +const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ + 'worktree_id_requires_full_path' +]) export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknown): RpcFailure { const message = error instanceof Error ? error.message : String(error) @@ -91,6 +94,20 @@ export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknow (error as { data?: unknown }).data ) } + if ( + error instanceof Error && + 'code' in error && + typeof (error as { code: unknown }).code === 'string' && + STRUCTURED_RUNTIME_PASSTHROUGH_CODES.has((error as { code: string }).code) + ) { + return errorResponse( + id, + meta, + (error as { code: string }).code, + message, + (error as { data?: unknown }).data + ) + } if (RUNTIME_PASSTHROUGH_CODES.has(message)) { return errorResponse(id, meta, message, message) } diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts new file mode 100644 index 00000000000..5c1823a4e8e --- /dev/null +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -0,0 +1,267 @@ +import { z } from 'zod' +import { + isFeatureInteractionId, + type FeatureInteractionId +} from '../../../../shared/feature-interactions' +import { isFeatureTipId } from '../../../../shared/feature-tips' +import { + normalizeTuiAgentArgsRecord, + normalizeTuiAgentEnvRecord +} from '../../../../shared/tui-agent-launch-defaults' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { isTaskProvider } from '../../../../shared/task-providers' +import { normalizeDisabledTuiAgents } from '../../../../shared/tui-agent-selection' +import { normalizePRBotAuthorOverrides } from '../../../../shared/pr-bot-author-overrides' +import { normalizeWorktreeCardProperties } from '../../../../shared/worktree-card-properties' +import type { TaskProvider } from '../../../../shared/types' + +const NullableString = z.string().nullable() +const StringArray = z.array(z.string()) +const TaskProviderParam = z.custom(isTaskProvider, { + message: 'Unknown task provider' +}) +const FeatureTipIds = z.array(z.custom(isFeatureTipId, { message: 'Unknown feature tip id' })) +const UnknownRecord = z.record(z.string(), z.unknown()) +const UnknownRecordArray = z.array(UnknownRecord) +const LegacyWorktreeCardProperty = z.enum([ + 'status', + 'unread', + 'ci', + 'branch', + 'issue', + 'linear-issue', + 'pr', + 'automation', + 'comment', + 'ports', + 'inline-agents' +]) +const WorktreeCardProperties = z + .array(LegacyWorktreeCardProperty) + .transform((value) => normalizeWorktreeCardProperties(value)) +const AgentActivityDisplayMode = z.enum(['compact', 'full']) +const StatusBarItem = z.enum([ + 'claude', + 'codex', + 'gemini', + 'antigravity', + 'opencode-go', + 'kimi', + 'minimax', + 'grok', + 'ssh', + 'resource-usage', + 'ports' +]) +const WorkspaceStatusDefinition = z.object({ + id: z.string(), + label: z.string(), + color: z.string().optional(), + icon: z.string().optional() +}) +const TaskResumeState = z + .object({ + githubMode: z.enum(['items', 'project']).optional(), + githubItemsPreset: z.string().nullable().optional(), + githubItemsQuery: z.string().optional(), + githubProjectHiddenFieldIdsByView: z.record(z.string(), z.array(z.string())).optional(), + linearPreset: z.enum(['assigned', 'created', 'all', 'completed']).optional(), + linearQuery: z.string().optional() + }) + .strict() +const WorkspaceCleanupDismissal = z + .object({ + worktreeId: z.string(), + dismissedAt: z.number().finite(), + fingerprint: z.string(), + classifierVersion: z.number().finite() + }) + .strict() +const WorkspaceCleanup = z + .object({ + dismissals: z.record(z.string(), WorkspaceCleanupDismissal) + }) + .strict() +const FeatureInteractionRecord = z + .object({ + firstInteractedAt: z.number().finite().nonnegative(), + interactionCount: z.number().int().positive().optional() + }) + .strict() +const FeatureInteractions = z + .record(z.string(), FeatureInteractionRecord) + .superRefine((value, ctx) => { + for (const id of Object.keys(value)) { + if (!isFeatureInteractionId(id)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Unknown feature interaction id: ${id}`, + path: [id] + }) + } + } + }) +export const FeatureInteractionIdParam = z.custom(isFeatureInteractionId, { + message: 'Unknown feature interaction id' +}) +export const PRBotAuthorOverrideUpdate = z + .object({ author: z.string(), isBot: z.boolean() }) + .strict() +const GitHubProjectRef = z + .object({ + owner: z.string(), + ownerType: z.enum(['organization', 'user']), + number: z.number().int() + }) + .strict() +const GitHubProjectSettings = z + .object({ + pinned: z.array(GitHubProjectRef), + recent: z.array( + GitHubProjectRef.extend({ + lastOpenedAt: z.string() + }).strict() + ), + lastViewByProject: z.record(z.string(), z.object({ viewId: z.string() }).strict()), + activeProject: GitHubProjectRef.nullable() + }) + .strict() + +export const SettingsUpdate = z + .object({ + defaultTuiAgent: z + .unknown() + .transform((value) => + value === null || value === 'blank' || isTuiAgent(value) ? value : undefined + ) + .optional(), + disabledTuiAgents: z + .unknown() + .transform((value) => normalizeDisabledTuiAgents(value)) + .optional(), + agentDefaultArgs: z + .unknown() + .transform((value) => normalizeTuiAgentArgsRecord(value)) + .optional(), + agentDefaultEnv: z + .unknown() + .transform((value) => normalizeTuiAgentEnvRecord(value)) + .optional(), + defaultTaskSource: TaskProviderParam.optional(), + visibleTaskProviders: z.array(TaskProviderParam).optional(), + defaultTaskViewPreset: z + .enum(['issues', 'my-issues', 'prs', 'my-prs', 'review', 'all']) + .optional(), + experimentalNewWorktreeCardStyle: z.boolean().optional(), + agentStatusHooksEnabled: z.boolean().optional(), + defaultRepoSelection: z.array(z.string()).nullable().optional(), + defaultLinearTeamSelection: z.array(z.string()).nullable().optional(), + compactWorktreeCards: z.boolean().optional(), + minimaxGroupId: z.string().optional(), + minimaxUsageModels: z.string().optional(), + githubProjects: GitHubProjectSettings.optional(), + prBotAuthorOverrides: z + .unknown() + .transform((value) => normalizePRBotAuthorOverrides(value)) + .optional() + }) + .strict() + .default({}) + +export const UiUpdate = z + .object({ + lastActiveRepoId: NullableString.optional(), + lastActiveWorktreeId: NullableString.optional(), + sidebarWidth: z.number().finite().optional(), + rightSidebarOpen: z.boolean().optional(), + rightSidebarTab: z + .enum(['explorer', 'search', 'vault', 'source-control', 'checks', 'ports']) + .optional(), + rightSidebarExplorerView: z.enum(['files', 'search']).optional(), + rightSidebarWidth: z.number().finite().optional(), + markdownTocPanelWidth: z.number().finite().optional(), + groupBy: z.enum(['none', 'workspace-status', 'repo', 'pr-status']).optional(), + showWorkspaceLineage: z.boolean().optional(), + sortBy: z.enum(['name', 'smart', 'recent', 'repo', 'manual']).optional(), + projectOrderBy: z.enum(['manual', 'recent']).optional(), + showActiveOnly: z.boolean().optional(), + hideSleepingWorkspaces: z.boolean().optional(), + showSleepingWorkspaces: z.boolean().optional(), + showInactiveWorkspaces: z.boolean().optional(), + workspaceHostScope: z.string().optional(), + visibleWorkspaceHostIds: z.array(z.string()).nullable().optional(), + workspaceHostOrder: z.array(z.string()).optional(), + hideDefaultBranchWorkspace: z.boolean().optional(), + hideAutomationGeneratedWorkspaces: z.boolean().optional(), + filterRepoIds: StringArray.optional(), + collapsedGroups: StringArray.optional(), + uiZoomLevel: z.number().finite().optional(), + editorFontZoomLevel: z.number().finite().optional(), + worktreeCardProperties: WorktreeCardProperties.optional(), + _worktreeCardModeDefaulted: z.boolean().optional(), + agentActivityDisplayMode: AgentActivityDisplayMode.optional(), + workspaceStatuses: z.array(WorkspaceStatusDefinition).optional(), + workspaceBoardOpacity: z.number().finite().optional(), + workspaceBoardColumnWidth: z.number().finite().optional(), + syncTaskStatusFromWorkspaceBoard: z.boolean().optional(), + _workspaceStatusesDefaultOrderMigrated: z.boolean().optional(), + _workspaceStatusesReorderedDefaultRepaired: z.boolean().optional(), + _workspaceStatusesDefaultWorkflowMigrated: z.boolean().optional(), + _workspaceStatusesDefaultVisualsMigrated: z.boolean().optional(), + statusBarItems: z.array(StatusBarItem).optional(), + _portsStatusBarDefaultAdded: z.boolean().optional(), + _kimiStatusBarDefaultAdded: z.boolean().optional(), + _minimaxStatusBarDefaultAdded: z.boolean().optional(), + _antigravityStatusBarDefaultAdded: z.boolean().optional(), + _grokStatusBarDefaultAdded: z.boolean().optional(), + statusBarVisible: z.boolean().optional(), + usagePercentageDisplay: z.enum(['used', 'remaining']).optional(), + dismissedUpdateVersion: NullableString.optional(), + lastUpdateCheckAt: z.number().finite().nullable().optional(), + pendingUpdateNudgeId: NullableString.optional(), + dismissedUpdateNudgeId: NullableString.optional(), + notificationPermissionRequested: z.boolean().optional(), + updateReassuranceSeen: z.boolean().optional(), + acknowledgedAgentsByPaneKey: z.record(z.string(), z.number().finite()).optional(), + browserDefaultUrl: NullableString.optional(), + browserDefaultSearchEngine: z + .enum(['google', 'duckduckgo', 'bing', 'kagi']) + .nullable() + .optional(), + browserDefaultZoomLevel: z.number().finite().optional(), + browserKagiSessionLink: NullableString.optional(), + windowBounds: z + .object({ + x: z.number().finite(), + y: z.number().finite(), + width: z.number().finite(), + height: z.number().finite() + }) + .nullable() + .optional(), + windowMaximized: z.boolean().optional(), + _sortBySmartMigrated: z.boolean().optional(), + _inlineAgentsDefaultedForExperiment: z.boolean().optional(), + _inlineAgentsDefaultedForAllUsers: z.boolean().optional(), + trustedOrcaHooks: z.record(z.string(), z.unknown()).optional(), + setupScriptPromptDismissedRepoIds: StringArray.optional(), + projectOrderManualDefaultNoticeDismissed: z.boolean().optional(), + usagePercentageDisplayChangeNoticeDismissed: z.boolean().optional(), + usageEmptyStateDismissed: z.boolean().optional(), + petVisible: z.boolean().optional(), + petId: z.string().optional(), + customPets: UnknownRecordArray.optional(), + petSize: z.number().finite().optional(), + sidekickVisible: z.boolean().optional(), + sidekickId: z.string().optional(), + customSidekicks: UnknownRecordArray.optional(), + sidekickSize: z.number().finite().optional(), + taskResumeState: TaskResumeState.optional(), + workspaceCleanup: WorkspaceCleanup.optional(), + featureTipsSeenIds: FeatureTipIds.optional(), + featureInteractions: FeatureInteractions.optional(), + contextualToursSeenIds: StringArray.optional(), + contextualToursAutoEligible: z.boolean().optional() + }) + .strict() + .default({}) diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 01a3f784e17..a3f3dbe88c2 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -117,6 +117,63 @@ describe('client UI RPC methods', () => { }) }) + it('normalizes manual bot-author overrides before persisting', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateClientSettings: vi.fn(() => ({})) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) + + await dispatcher.dispatch( + makeRequest('settings.update', { + prBotAuthorOverrides: [' GretelFlux ', 'gretelflux', 42, '', 'another-bot'] + }) + ) + + expect(runtime.updateClientSettings).toHaveBeenCalledWith({ + prBotAuthorOverrides: ['another-bot', 'gretelflux'] + }) + }) + + it('caps oversized bot-author override payloads', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateClientSettings: vi.fn(() => ({})) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) + + await dispatcher.dispatch( + makeRequest('settings.update', { + prBotAuthorOverrides: Array.from( + { length: 600 }, + (_, i) => `bot-${String(i).padStart(4, '0')}` + ) + }) + ) + + const [update] = vi.mocked(runtime.updateClientSettings).mock.calls[0]! + expect((update as { prBotAuthorOverrides: string[] }).prBotAuthorOverrides).toHaveLength(500) + }) + + it('routes bot-author deltas to the runtime-owned atomic update', async () => { + const settings = { prBotAuthorOverrides: ['alice', 'bob'] } + const runtime = { + getRuntimeId: () => 'test-runtime', + updateClientPRBotAuthorOverride: vi.fn(() => settings) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('settings.updatePRBotAuthorOverride', { author: ' Bob ', isBot: true }) + ) + + expect(runtime.updateClientPRBotAuthorOverride).toHaveBeenCalledWith({ + author: ' Bob ', + isBot: true + }) + expect(response).toMatchObject({ ok: true, result: { settings } }) + }) + it('returns the runtime host persisted UI state', async () => { const ui: PersistedUIState = { ...getDefaultUIState(), diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index 9b08e6decd3..7945203f958 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -1,261 +1,11 @@ -import { z } from 'zod' -import { - isFeatureInteractionId, - type FeatureInteractionId -} from '../../../../shared/feature-interactions' -import { isFeatureTipId } from '../../../../shared/feature-tips' -import { - normalizeTuiAgentArgsRecord, - normalizeTuiAgentEnvRecord -} from '../../../../shared/tui-agent-launch-defaults' -import { isTuiAgent } from '../../../../shared/tui-agent-config' -import { isTaskProvider } from '../../../../shared/task-providers' -import { normalizeDisabledTuiAgents } from '../../../../shared/tui-agent-selection' -import { normalizeWorktreeCardProperties } from '../../../../shared/worktree-card-properties' -import type { PersistedUIState, TaskProvider } from '../../../../shared/types' +import type { PersistedUIState } from '../../../../shared/types' import { defineMethod, type RpcMethod } from '../core' - -const NullableString = z.string().nullable() -const StringArray = z.array(z.string()) -const TaskProviderParam = z.custom(isTaskProvider, { - message: 'Unknown task provider' -}) -const FeatureTipIds = z.array(z.custom(isFeatureTipId, { message: 'Unknown feature tip id' })) -const UnknownRecord = z.record(z.string(), z.unknown()) -const UnknownRecordArray = z.array(UnknownRecord) -const LegacyWorktreeCardProperty = z.enum([ - 'status', - 'unread', - 'ci', - 'branch', - 'issue', - 'linear-issue', - 'pr', - 'automation', - 'comment', - 'ports', - 'inline-agents' -]) -const WorktreeCardProperties = z - .array(LegacyWorktreeCardProperty) - .transform((value) => normalizeWorktreeCardProperties(value)) -const AgentActivityDisplayMode = z.enum(['compact', 'full']) -const StatusBarItem = z.enum([ - 'claude', - 'codex', - 'gemini', - 'antigravity', - 'opencode-go', - 'kimi', - 'minimax', - 'grok', - 'ssh', - 'resource-usage', - 'ports' -]) -const WorkspaceStatusDefinition = z.object({ - id: z.string(), - label: z.string(), - color: z.string().optional(), - icon: z.string().optional() -}) -const TaskResumeState = z - .object({ - githubMode: z.enum(['items', 'project']).optional(), - githubItemsPreset: z.string().nullable().optional(), - githubItemsQuery: z.string().optional(), - githubProjectHiddenFieldIdsByView: z.record(z.string(), z.array(z.string())).optional(), - linearPreset: z.enum(['assigned', 'created', 'all', 'completed']).optional(), - linearQuery: z.string().optional() - }) - .strict() -const WorkspaceCleanupDismissal = z - .object({ - worktreeId: z.string(), - dismissedAt: z.number().finite(), - fingerprint: z.string(), - classifierVersion: z.number().finite() - }) - .strict() -const WorkspaceCleanup = z - .object({ - dismissals: z.record(z.string(), WorkspaceCleanupDismissal) - }) - .strict() -const FeatureInteractionRecord = z - .object({ - firstInteractedAt: z.number().finite().nonnegative(), - interactionCount: z.number().int().positive().optional() - }) - .strict() -const FeatureInteractions = z - .record(z.string(), FeatureInteractionRecord) - .superRefine((value, ctx) => { - for (const id of Object.keys(value)) { - if (!isFeatureInteractionId(id)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Unknown feature interaction id: ${id}`, - path: [id] - }) - } - } - }) -const FeatureInteractionIdParam = z.custom(isFeatureInteractionId, { - message: 'Unknown feature interaction id' -}) -const GitHubProjectRef = z - .object({ - owner: z.string(), - ownerType: z.enum(['organization', 'user']), - number: z.number().int() - }) - .strict() -const GitHubProjectSettings = z - .object({ - pinned: z.array(GitHubProjectRef), - recent: z.array( - GitHubProjectRef.extend({ - lastOpenedAt: z.string() - }).strict() - ), - lastViewByProject: z.record(z.string(), z.object({ viewId: z.string() }).strict()), - activeProject: GitHubProjectRef.nullable() - }) - .strict() - -const SettingsUpdate = z - .object({ - defaultTuiAgent: z - .unknown() - .transform((value) => - value === null || value === 'blank' || isTuiAgent(value) ? value : undefined - ) - .optional(), - disabledTuiAgents: z - .unknown() - .transform((value) => normalizeDisabledTuiAgents(value)) - .optional(), - agentDefaultArgs: z - .unknown() - .transform((value) => normalizeTuiAgentArgsRecord(value)) - .optional(), - agentDefaultEnv: z - .unknown() - .transform((value) => normalizeTuiAgentEnvRecord(value)) - .optional(), - defaultTaskSource: TaskProviderParam.optional(), - visibleTaskProviders: z.array(TaskProviderParam).optional(), - defaultTaskViewPreset: z - .enum(['issues', 'my-issues', 'prs', 'my-prs', 'review', 'all']) - .optional(), - experimentalNewWorktreeCardStyle: z.boolean().optional(), - agentStatusHooksEnabled: z.boolean().optional(), - defaultRepoSelection: z.array(z.string()).nullable().optional(), - defaultLinearTeamSelection: z.array(z.string()).nullable().optional(), - compactWorktreeCards: z.boolean().optional(), - minimaxGroupId: z.string().optional(), - minimaxUsageModels: z.string().optional(), - githubProjects: GitHubProjectSettings.optional() - }) - .strict() - .default({}) - -const UiUpdate = z - .object({ - lastActiveRepoId: NullableString.optional(), - lastActiveWorktreeId: NullableString.optional(), - sidebarWidth: z.number().finite().optional(), - rightSidebarOpen: z.boolean().optional(), - rightSidebarTab: z - .enum(['explorer', 'search', 'vault', 'source-control', 'checks', 'ports']) - .optional(), - rightSidebarExplorerView: z.enum(['files', 'search']).optional(), - rightSidebarWidth: z.number().finite().optional(), - markdownTocPanelWidth: z.number().finite().optional(), - groupBy: z.enum(['none', 'workspace-status', 'repo', 'pr-status']).optional(), - showWorkspaceLineage: z.boolean().optional(), - sortBy: z.enum(['name', 'smart', 'recent', 'repo', 'manual']).optional(), - projectOrderBy: z.enum(['manual', 'recent']).optional(), - showActiveOnly: z.boolean().optional(), - hideSleepingWorkspaces: z.boolean().optional(), - showSleepingWorkspaces: z.boolean().optional(), - showInactiveWorkspaces: z.boolean().optional(), - workspaceHostScope: z.string().optional(), - visibleWorkspaceHostIds: z.array(z.string()).nullable().optional(), - workspaceHostOrder: z.array(z.string()).optional(), - hideDefaultBranchWorkspace: z.boolean().optional(), - hideAutomationGeneratedWorkspaces: z.boolean().optional(), - filterRepoIds: StringArray.optional(), - collapsedGroups: StringArray.optional(), - uiZoomLevel: z.number().finite().optional(), - editorFontZoomLevel: z.number().finite().optional(), - worktreeCardProperties: WorktreeCardProperties.optional(), - _worktreeCardModeDefaulted: z.boolean().optional(), - agentActivityDisplayMode: AgentActivityDisplayMode.optional(), - workspaceStatuses: z.array(WorkspaceStatusDefinition).optional(), - workspaceBoardOpacity: z.number().finite().optional(), - workspaceBoardColumnWidth: z.number().finite().optional(), - syncTaskStatusFromWorkspaceBoard: z.boolean().optional(), - _workspaceStatusesDefaultOrderMigrated: z.boolean().optional(), - _workspaceStatusesReorderedDefaultRepaired: z.boolean().optional(), - _workspaceStatusesDefaultWorkflowMigrated: z.boolean().optional(), - _workspaceStatusesDefaultVisualsMigrated: z.boolean().optional(), - statusBarItems: z.array(StatusBarItem).optional(), - _portsStatusBarDefaultAdded: z.boolean().optional(), - _kimiStatusBarDefaultAdded: z.boolean().optional(), - _minimaxStatusBarDefaultAdded: z.boolean().optional(), - _antigravityStatusBarDefaultAdded: z.boolean().optional(), - _grokStatusBarDefaultAdded: z.boolean().optional(), - statusBarVisible: z.boolean().optional(), - dismissedUpdateVersion: NullableString.optional(), - lastUpdateCheckAt: z.number().finite().nullable().optional(), - pendingUpdateNudgeId: NullableString.optional(), - dismissedUpdateNudgeId: NullableString.optional(), - notificationPermissionRequested: z.boolean().optional(), - updateReassuranceSeen: z.boolean().optional(), - acknowledgedAgentsByPaneKey: z.record(z.string(), z.number().finite()).optional(), - browserDefaultUrl: NullableString.optional(), - browserDefaultSearchEngine: z - .enum(['google', 'duckduckgo', 'bing', 'kagi']) - .nullable() - .optional(), - browserDefaultZoomLevel: z.number().finite().optional(), - browserKagiSessionLink: NullableString.optional(), - windowBounds: z - .object({ - x: z.number().finite(), - y: z.number().finite(), - width: z.number().finite(), - height: z.number().finite() - }) - .nullable() - .optional(), - windowMaximized: z.boolean().optional(), - _sortBySmartMigrated: z.boolean().optional(), - _inlineAgentsDefaultedForExperiment: z.boolean().optional(), - _inlineAgentsDefaultedForAllUsers: z.boolean().optional(), - trustedOrcaHooks: z.record(z.string(), z.unknown()).optional(), - setupScriptPromptDismissedRepoIds: StringArray.optional(), - projectOrderManualDefaultNoticeDismissed: z.boolean().optional(), - usageEmptyStateDismissed: z.boolean().optional(), - petVisible: z.boolean().optional(), - petId: z.string().optional(), - customPets: UnknownRecordArray.optional(), - petSize: z.number().finite().optional(), - sidekickVisible: z.boolean().optional(), - sidekickId: z.string().optional(), - customSidekicks: UnknownRecordArray.optional(), - sidekickSize: z.number().finite().optional(), - taskResumeState: TaskResumeState.optional(), - workspaceCleanup: WorkspaceCleanup.optional(), - featureTipsSeenIds: FeatureTipIds.optional(), - featureInteractions: FeatureInteractions.optional(), - contextualToursSeenIds: StringArray.optional(), - contextualToursAutoEligible: z.boolean().optional() - }) - .strict() - .default({}) +import { + FeatureInteractionIdParam, + PRBotAuthorOverrideUpdate, + SettingsUpdate, + UiUpdate +} from './client-ui-schemas' export const CLIENT_UI_METHODS: RpcMethod[] = [ defineMethod({ @@ -268,6 +18,13 @@ export const CLIENT_UI_METHODS: RpcMethod[] = [ params: SettingsUpdate, handler: (params, { runtime }) => ({ settings: runtime.updateClientSettings(params) }) }), + defineMethod({ + name: 'settings.updatePRBotAuthorOverride', + params: PRBotAuthorOverrideUpdate, + handler: (params, { runtime }) => ({ + settings: runtime.updateClientPRBotAuthorOverride(params) + }) + }), defineMethod({ name: 'ui.get', params: null, diff --git a/src/main/runtime/rpc/methods/files.test.ts b/src/main/runtime/rpc/methods/files.test.ts index 7282489c706..9f535d9a637 100644 --- a/src/main/runtime/rpc/methods/files.test.ts +++ b/src/main/runtime/rpc/methods/files.test.ts @@ -128,7 +128,12 @@ describe('file RPC methods', () => { await vi.waitFor(() => { expect(replies).toHaveLength(1) }) - expect(runtime.watchFileExplorer).toHaveBeenCalledWith('id:wt-1', expect.any(Function)) + expect(runtime.watchFileExplorer).toHaveBeenCalledWith( + 'id:wt-1', + expect.any(Function), + expect.any(Function), + undefined + ) expect(replies[0]).toMatchObject({ ok: true, streaming: true, @@ -172,17 +177,63 @@ describe('file RPC methods', () => { } }) + it('ends a ready file-watch stream when its watcher fails terminally', async () => { + let onTerminalError: ((error: Error) => void) | undefined + const unwatch = vi.fn() + const cleanups = new Map void>() + let emitWatchChange: + | ((events: { kind: 'overflow'; absolutePath: string }[]) => void) + | undefined + const runtime = { + getRuntimeId: () => 'test-runtime', + watchFileExplorer: vi.fn(async (_worktree, callback, nextTerminalError) => { + emitWatchChange = callback + onTerminalError = nextTerminalError + return unwatch + }), + registerSubscriptionCleanup: vi.fn((id, cleanup) => cleanups.set(id, cleanup)), + cleanupSubscription: vi.fn((id) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + const replies: { result?: { type?: string; message?: string } }[] = [] + + const dispatch = dispatcher.dispatchStreaming( + makeRequest('files.watch', { worktree: 'id:wt-1' }), + (response) => replies.push(JSON.parse(response)) + ) + await vi.waitFor(() => expect(replies[0]?.result?.type).toBe('ready')) + + emitWatchChange?.([{ kind: 'overflow', absolutePath: '/repo' }]) + onTerminalError?.(new Error('file watcher process crashed repeatedly')) + await dispatch + + expect(replies.map((reply) => reply.result?.type)).toEqual(['ready', 'changed', 'error', 'end']) + expect(replies[2]?.result?.message).toContain('crashed repeatedly') + expect(unwatch).toHaveBeenCalledTimes(1) + expect(cleanups.size).toBe(0) + }) + it('tears down a file watch that resolves after the connection already closed', async () => { type WatchCallback = ( events: { kind: 'update'; absolutePath: string; isDirectory?: boolean }[] ) => void const unwatch = vi.fn() let resolveWatch: (value: () => void) => void = () => {} - const watchFileExplorer = vi.fn((_worktree: string, _callback: WatchCallback) => { - return new Promise<() => void>((resolve) => { - resolveWatch = resolve - }) - }) + const watchFileExplorer = vi.fn( + ( + _worktree: string, + _callback: WatchCallback, + _onTerminalError?: (error: Error) => void, + _signal?: AbortSignal + ) => + new Promise<() => void>((resolve) => { + resolveWatch = resolve + }) + ) const runtime = { getRuntimeId: () => 'test-runtime', watchFileExplorer, @@ -200,6 +251,7 @@ describe('file RPC methods', () => { await vi.waitFor(() => { expect(watchFileExplorer).toHaveBeenCalled() }) + expect(watchFileExplorer.mock.calls[0]?.[3]).toBe(abortController.signal) abortController.abort() await dispatch diff --git a/src/main/runtime/rpc/methods/files.ts b/src/main/runtime/rpc/methods/files.ts index 37224f18ed5..b1fb70234a6 100644 --- a/src/main/runtime/rpc/methods/files.ts +++ b/src/main/runtime/rpc/methods/files.ts @@ -392,6 +392,7 @@ export const FILE_METHODS: RpcAnyMethod[] = [ await new Promise((resolve, reject) => { let settled = false let unwatch: (() => void) | null = null + let terminalError: Error | null = null const eventBatcher = createFileWatchEventBatcher(params.worktree, emit) const finish = (): void => { if (settled) { @@ -402,11 +403,32 @@ export const FILE_METHODS: RpcAnyMethod[] = [ resolve() } const cleanup = (): void => { - eventBatcher.flush() - eventBatcher.dispose() + // Mark cleanup complete before emitting so a synchronous transport + // abort cannot re-enter this function and duplicate error/end. + finish() + if (terminalError) { + // Why: recovery emits overflow before giving up. Flush that final + // refresh so clients never end on a knowingly stale snapshot. + eventBatcher.flush() + eventBatcher.dispose() + emit({ type: 'error', message: terminalError.message }) + } else { + eventBatcher.flush() + eventBatcher.dispose() + } unwatch?.() emit({ type: 'end' }) - finish() + } + const handleTerminalError = (error: Error): void => { + if (settled || terminalError) { + return + } + terminalError = error + if (unwatch) { + runtime.cleanupSubscription(subscriptionId) + } else { + cleanup() + } } const handleAbort = (): void => { if (unwatch) { @@ -420,9 +442,14 @@ export const FILE_METHODS: RpcAnyMethod[] = [ } signal?.addEventListener('abort', handleAbort, { once: true }) void runtime - .watchFileExplorer(params.worktree, (events) => { - eventBatcher.push(events) - }) + .watchFileExplorer( + params.worktree, + (events) => { + eventBatcher.push(events) + }, + handleTerminalError, + signal + ) .then((nextUnwatch) => { if (signal?.aborted || settled) { // Why: the connection can close while watch setup is still @@ -438,7 +465,9 @@ export const FILE_METHODS: RpcAnyMethod[] = [ }) .catch((error) => { if (!settled) { + settled = true signal?.removeEventListener('abort', handleAbort) + eventBatcher.dispose() reject(error) } }) diff --git a/src/main/runtime/rpc/methods/notifications.ts b/src/main/runtime/rpc/methods/notifications.ts index 45fe73604b1..017c75bf328 100644 --- a/src/main/runtime/rpc/methods/notifications.ts +++ b/src/main/runtime/rpc/methods/notifications.ts @@ -13,6 +13,17 @@ const NotificationUnsubscribeParams = z.object({ .pipe(z.string().min(1, 'Missing subscriptionId')) }) +// Why: notifications.getMissedSince is the catch-up RPC for mobile reconnect +// (#8129). The client passes the highest seq it has already delivered; the +// runtime returns only notifications dispatched after that seq. Because the +// desktop assigns a monotonic seq to every dispatched notification, the cut is +// exact and idempotent — re-requesting with the same watermark can never +// return an already-delivered event, so reconnects never duplicate local +// pushes (the adversarial-review gate for #8129). +const NotificationGetMissedSinceParams = z.object({ + lastSeenSeq: z.number().int().min(0, 'lastSeenSeq must be a non-negative integer') +}) + // Why: notifications.subscribe streams desktop notification events to mobile // clients over WebSocket. The mobile client shows a local push notification // for each event. This avoids requiring Firebase/APNs — the existing @@ -52,5 +63,16 @@ export const NOTIFICATION_METHODS: readonly RpcAnyMethod[] = [ runtime.cleanupSubscription(params.subscriptionId) return { unsubscribed: true } } + }), + defineMethod({ + name: 'notifications.getMissedSince', + params: NotificationGetMissedSinceParams, + // Why: returns only notifications with seq > lastSeenSeq. The runtime owns + // the monotonic seq, so this is the single source of truth for what the + // client missed while its socket was reaped. + handler: async (params, { runtime }) => { + const missed = runtime.getMissedNotificationsSince(params.lastSeenSeq) + return { notifications: missed } + } }) ] diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index e5255595a6a..95339cb3190 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -90,6 +90,153 @@ describe('orchestration RPC methods', () => { expect(runtime.deliverPendingMessagesForHandle).toHaveBeenCalledWith('term_b') }) + it('stores the sender pane key on the message row', async () => { + setup() + vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + + const result = (await call('orchestration.send', { + from: 'term_a', + to: 'term_b', + subject: 'hello', + senderPaneKey: 'tab_a:leaf_a' + })) as { message: { id: string } } + + expect(db.getMessageById(result.message.id)?.sender_pane_key).toBe('tab_a:leaf_a') + }) + + it('recovers missing sender pane identity from the resolved handle', async () => { + setup() + vi.spyOn(runtime, 'getTerminalPaneKey').mockReturnValue('tab_worker:leaf_worker') + vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + + const result = (await call('orchestration.send', { + from: 'term_worker', + to: 'term_coord', + subject: 'hello' + })) as { message: { id: string } } + + expect(runtime.getTerminalPaneKey).toHaveBeenCalledWith('term_worker') + expect(db.getMessageById(result.message.id)?.sender_pane_key).toBe('tab_worker:leaf_worker') + }) + + it('completes an identity-less injected send through its explicit worker handle', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + const dispatch = db.createDispatchContext(task.id, 'term_worker', 'tab_worker:leaf_worker') + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_worker' ? 'tab_worker:leaf_worker' : null + ) + vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + + await call('orchestration.send', { + from: 'term_worker', + to: 'term_coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }) + }) + + expect(db.getTask(task.id)?.status).toBe('completed') + expect(db.getDispatchContextById(dispatch.id)?.status).toBe('completed') + }) + + it('rejects an identity-less lifecycle send resolved through the coordinator handle', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + const dependent = db.createTask({ spec: 'dependent', deps: [task.id] }) + const dispatch = db.createDispatchContext(task.id, 'term_worker', 'tab_worker:leaf_worker') + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_coord' ? 'tab_coord:leaf_coord' : null + ) + vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + + await call('orchestration.send', { + from: 'term_coord', + to: 'term_coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }) + }) + + expect(db.getTask(task.id)?.status).toBe('dispatched') + expect(db.getTask(dependent.id)?.status).toBe('pending') + }) + + it('does not replace a foreign sender pane with its claimed assignee handle pane', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + const dispatch = db.createDispatchContext(task.id, 'term_worker', 'tab_worker:leaf_worker') + vi.spyOn(runtime, 'getTerminalPaneKey').mockReturnValue('tab_worker:leaf_worker') + vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + + const result = (await call('orchestration.send', { + from: 'term_worker', + to: 'term_coord', + subject: 'Done', + type: 'worker_done', + senderPaneKey: 'tab_foreign:leaf_foreign', + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }) + })) as { + message: { id: string; type: string; subject: string } + lifecycle: { action: string; code: string; reason: string } + } + + expect(db.getTask(task.id)?.status).toBe('dispatched') + expect(result.lifecycle).toMatchObject({ + action: 'rejected', + code: 'sender_not_assignee', + reason: expect.stringContaining('expected handle term_worker') + }) + expect(result.message).toMatchObject({ + type: 'worker_done', + subject: 'Rejected worker_done: Done' + }) + expect(db.getUnreadMessages('term_coord')).toEqual([ + expect.objectContaining({ id: result.message.id, type: 'worker_done' }) + ]) + expect(runtime.notifyMessageArrived).toHaveBeenCalledWith('term_coord', 'worker_done') + }) + + it('does not wake waiters for a heartbeat suppressed at send time', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + const dispatch = db.createDispatchContext(task.id, 'term_worker') + db.updateTaskStatus(task.id, 'completed') + vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + const notify = vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + + const result = (await call('orchestration.send', { + from: 'term_worker', + to: 'term_coord', + subject: 'alive', + type: 'heartbeat', + payload: JSON.stringify({ dispatchId: dispatch.id }) + })) as { message: { id: string } } + + expect(notify).not.toHaveBeenCalled() + expect(db.getMessageById(result.message.id)).toMatchObject({ read: 1 }) + }) + + it('still wakes waiters for a heartbeat on an active dispatch', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + const dispatch = db.createDispatchContext(task.id, 'term_worker') + vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + const notify = vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + + await call('orchestration.send', { + from: 'term_worker', + to: 'term_coord', + subject: 'alive', + type: 'heartbeat', + payload: JSON.stringify({ dispatchId: dispatch.id }) + }) + + expect(notify).toHaveBeenCalledWith('term_coord', 'heartbeat') + }) + it('rejects missing --to', () => { const method = findMethod('orchestration.send') expect(() => method.params!.parse({ subject: 'hi' })).toThrow() @@ -319,6 +466,23 @@ describe('orchestration RPC methods', () => { expect(result.messages[0].to_handle).toBe('term_b') }) + it('fans out @cursor by title match without claiming a cursor-mentioning title', async () => { + setupWithTerminals([ + makeSummary('term_a', { title: 'Codex' }), + makeSummary('term_b', { title: 'Cursor ready' }), + makeSummary('term_c', { title: '✳ Fix the text cursor blink' }) + ]) + + const result = (await call('orchestration.send', { + from: 'term_a', + to: '@cursor', + subject: 'cursor only' + })) as { messages: { to_handle: string }[]; recipients: number } + + expect(result.recipients).toBe(1) + expect(result.messages[0].to_handle).toBe('term_b') + }) + it('fans out @worktree: to matching worktree', async () => { setupWithTerminals([ makeSummary('term_a', { worktreeId: 'wt_1' }), @@ -442,9 +606,9 @@ describe('orchestration RPC methods', () => { }) describe('orchestration.check', () => { - function createDispatchedTask(assigneeHandle = 'term_worker') { + function createDispatchedTask(assigneeHandle = 'term_worker', assigneePaneKey?: string) { const task = db.createTask({ spec: 'manual check work' }) - const dispatch = db.createDispatchContext(task.id, assigneeHandle) + const dispatch = db.createDispatchContext(task.id, assigneeHandle, assigneePaneKey) return { task, dispatch } } @@ -454,6 +618,7 @@ describe('orchestration RPC methods', () => { taskId?: string dispatchId?: string filesModified?: string[] + senderPaneKey?: string }): void { const payload: Record = {} if (params.taskId !== undefined) { @@ -471,7 +636,8 @@ describe('orchestration RPC methods', () => { to: params.to ?? 'term_coord', subject: 'Done', type: 'worker_done', - payload: JSON.stringify(payload) + payload: JSON.stringify(payload), + senderPaneKey: params.senderPaneKey }) } @@ -598,13 +764,15 @@ describe('orchestration RPC methods', () => { expect(db.getDispatchContextById(dispatch.id)?.status).toBe('dispatched') }) - it('does not complete worker_done from a terminal that does not own the dispatch', async () => { + it('completes worker_done by payload IDs when the sender handle changed', async () => { setup() - const { task, dispatch } = createDispatchedTask('term_owner') + const leafId = '11111111-1111-4111-8111-111111111111' + const { task, dispatch } = createDispatchedTask('term_owner', `tab_before:${leafId}`) insertWorkerDone({ - from: 'term_intruder', + from: 'term_reminted', taskId: task.id, - dispatchId: dispatch.id + dispatchId: dispatch.id, + senderPaneKey: `tab_after:${leafId}` }) const result = (await call('orchestration.check', { @@ -613,8 +781,8 @@ describe('orchestration RPC methods', () => { })) as { count: number } expect(result.count).toBe(1) - expect(db.getTask(task.id)?.status).toBe('dispatched') - expect(db.getDispatchContextById(dispatch.id)?.status).toBe('dispatched') + expect(db.getTask(task.id)?.status).toBe('completed') + expect(db.getDispatchContextById(dispatch.id)?.status).toBe('completed') }) it('does not complete worker_done for a stale inactive dispatch', async () => { @@ -640,6 +808,34 @@ describe('orchestration RPC methods', () => { expect(db.getDispatchContextById(activeDispatch.id)?.status).toBe('dispatched') }) + it('returns a persisted foreign completion as a rejection diagnostic', async () => { + setup() + const { task, dispatch } = createDispatchedTask('term_worker', 'tab_worker:leaf_worker') + insertWorkerDone({ + from: 'term_foreign', + taskId: task.id, + dispatchId: dispatch.id, + senderPaneKey: 'tab_foreign:leaf_foreign' + }) + + const result = (await call('orchestration.check', { + terminal: 'term_coord', + types: 'worker_done' + })) as { count: number; messages: { type: string; subject: string; body: string }[] } + + expect(result).toMatchObject({ + count: 1, + messages: [ + { + type: 'worker_done', + subject: 'Rejected worker_done: Done', + body: expect.stringContaining('expected handle term_worker') + } + ] + }) + expect(db.getTask(task.id)?.status).toBe('dispatched') + }) + it('records heartbeat returned by unread manual check', async () => { setup() const { task, dispatch } = createDispatchedTask() @@ -670,6 +866,11 @@ describe('orchestration RPC methods', () => { ).rejects.toThrow('Invalid --types') }) + it('rejects conflicting message read modes', () => { + const method = findMethod('orchestration.check') + expect(() => method.params!.parse({ unread: true, peek: true })).toThrow(/read mode/) + }) + it('default (unread only) marks returned rows as read', async () => { setup() db.insertMessage({ from: 'a', to: 'b', subject: 'one' }) @@ -686,6 +887,36 @@ describe('orchestration RPC methods', () => { expect(second.count).toBe(0) }) + it('--peek returns unread messages without marking them read', async () => { + setup() + db.insertMessage({ from: 'a', to: 'b', subject: 'one' }) + + const result = (await call('orchestration.check', { + terminal: 'b', + peek: true + })) as { count: number } + + expect(result.count).toBe(1) + expect(db.getUnreadMessages('b')).toHaveLength(1) + }) + + it("treats the CLI's {peek, unread:false} compat pair as peek, not all", async () => { + setup() + const seen = db.insertMessage({ from: 'a', to: 'b', subject: 'seen' }) + db.markAsRead([seen.id]) + db.insertMessage({ from: 'a', to: 'b', subject: 'fresh' }) + + const result = (await call('orchestration.check', { + terminal: 'b', + peek: true, + unread: false + })) as { messages: { subject: string }[]; count: number } + + expect(result.count).toBe(1) + expect(result.messages[0]?.subject).toBe('fresh') + expect(db.getUnreadMessages('b')).toHaveLength(1) + }) + it('--all returns every message for the handle without marking read', async () => { setup() db.insertMessage({ from: 'a', to: 'b', subject: 'one' }) @@ -1000,6 +1231,24 @@ describe('orchestration RPC methods', () => { }) }) + describe('orchestration.taskList --brief', () => { + it('abbreviates specs server-side so full text never crosses the wire', async () => { + setup() + db.createTask({ spec: `First line\n${'detail '.repeat(40)}` }) + db.createTask({ spec: 'Short task' }) + + const result = (await call('orchestration.taskList', { brief: true })) as { + tasks: { spec: string; spec_truncated: boolean }[] + } + + const [long, short] = result.tasks + expect(long.spec).toHaveLength(160) + expect(long.spec_truncated).toBe(true) + expect(short.spec).toBe('Short task') + expect(short.spec_truncated).toBe(false) + }) + }) + describe('orchestration.taskUpdate', () => { it('updates task status', async () => { setup() @@ -1050,6 +1299,20 @@ describe('orchestration RPC methods', () => { expect(result.dispatch.status).toBe('dispatched') }) + it('records the assignee pane key on the dispatch context', async () => { + setup() + vi.spyOn(runtime, 'getTerminalPaneKey').mockReturnValue('tab_w:leaf_w') + const task = db.createTask({ spec: 'work' }) + + const result = (await call('orchestration.dispatch', { + task: task.id, + to: 'term_a' + })) as { dispatch: { id: string } } + + expect(runtime.getTerminalPaneKey).toHaveBeenCalledWith('term_a') + expect(db.getDispatchContextById(result.dispatch.id)?.assignee_pane_key).toBe('tab_w:leaf_w') + }) + it('rejects dispatch for a pending task', async () => { setup() const parent = db.createTask({ spec: 'parent' }) @@ -1106,6 +1369,22 @@ describe('orchestration RPC methods', () => { ) }) + it('uses the target pane CLI command for the returned preamble', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + vi.spyOn(runtime, 'getTerminalOrchestrationCliCommand').mockReturnValue('orca-ide') + + const result = (await call('orchestration.dispatch', { + task: task.id, + to: 'term_wsl', + returnPreamble: true + })) as { preamble: string } + + expect(runtime.getTerminalOrchestrationCliCommand).toHaveBeenCalledWith('term_wsl') + expect(result.preamble).toContain('orca-ide orchestration send') + expect(result.preamble).not.toMatch(/(^|\s)orca orchestration/m) + }) + it('injects preamble through the agent prompt path instead of raw terminal send', async () => { setup() const task = db.createTask({ spec: 'line one\nline two' }) diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index f22ad62d8f4..bd2721d1b99 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -7,6 +7,7 @@ import { buildDispatchPreamble } from '../../orchestration/preamble' import { formatMessageBanner } from '../../orchestration/formatter' import { isGroupAddress, resolveGroupAddress } from '../../orchestration/groups' import { reconcileLifecycleMessage } from '../../orchestration/lifecycle-reconciliation' +import { abbreviateOrchestrationTasks } from '../../../../shared/orchestration-task-summary' import { ORCHESTRATION_GATE_METHODS } from './orchestration-gates' const MESSAGE_TYPES: MessageType[] = [ @@ -54,6 +55,9 @@ const SendParams = z priority: z.enum(['normal', 'high', 'urgent']).optional(), threadId: OptionalString, payload: OptionalString, + // Why: the sender's pane key is the remint-stable identity used to verify + // worker_done/heartbeat ownership; the from handle stays routing metadata. + senderPaneKey: OptionalString, devMode: OptionalBoolean }) .superRefine((params, ctx) => { @@ -72,18 +76,36 @@ const SendParams = z }) }) -const CheckParams = z.object({ - terminal: OptionalString, - unread: OptionalBoolean, - // Why: `all` surfaces every message for the handle and skips mark-read. - // Previously the only way to ask for "all" was the hidden RPC trick - // `{unread: false}`. See design doc §3.2 / §3.3. - all: OptionalBoolean, - types: OptionalString, - inject: OptionalBoolean, - wait: OptionalBoolean, - timeoutMs: OptionalFiniteNumber -}) +const CheckParams = z + .object({ + terminal: OptionalString, + unread: OptionalBoolean, + peek: OptionalBoolean, + // Why: `all` surfaces every message for the handle and skips mark-read. + // Previously the only way to ask for "all" was the hidden RPC trick + // `{unread: false}`. See design doc §3.2 / §3.3. + all: OptionalBoolean, + types: OptionalString, + inject: OptionalBoolean, + wait: OptionalBoolean, + timeoutMs: OptionalFiniteNumber + }) + .superRefine((params, ctx) => { + // Why: the CLI encodes --peek as {peek:true, unread:false} so pre-peek + // runtimes degrade to the non-consuming all mode; that pair is one mode, + // not a conflict. + const modes = [ + params.unread === true, + params.peek === true, + params.all === true || (params.unread === false && params.peek !== true) + ].filter(Boolean) + if (modes.length > 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose at most one message read mode: --unread, --peek, or --all.' + }) + } + }) const ReplyParams = z.object({ id: requiredString('Missing --id'), @@ -110,7 +132,10 @@ const TaskCreateParams = z.object({ const TaskListParams = z.object({ status: z.enum(['pending', 'ready', 'dispatched', 'completed', 'failed', 'blocked']).optional(), - ready: OptionalBoolean + ready: OptionalBoolean, + // Why: truncating specs server-side keeps `--brief` cheap over SSH/relay + // transports instead of shipping full specs the CLI then throws away. + brief: OptionalBoolean }) const TaskUpdateParams = z.object({ @@ -184,6 +209,9 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => { const db = runtime.getOrchestrationDb() const from = params.from ?? 'unknown' + // Why: older live shells may lack ORCA_PANE_KEY, but the runtime still + // knows the pane behind their resolved handle; persist that authority. + const senderPaneKey = params.senderPaneKey ?? runtime.getTerminalPaneKey(from) ?? undefined if (!isGroupAddress(params.to)) { // Point-to-point — existing single-recipient behavior @@ -195,14 +223,26 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ type: params.type as MessageType, priority: params.priority as MessagePriority, threadId: params.threadId, - payload: params.payload + payload: params.payload, + senderPaneKey }) // Why: worker_done/heartbeat sent via `send` must release the dispatch // lock before waking recipients — a coordinator woken by delivery may // immediately dispatch to the same terminal, which fails if the lock // is still held. if (msg.type === 'worker_done' || msg.type === 'heartbeat') { - reconcileLifecycleMessage(db, msg) + const reconciled = reconcileLifecycleMessage(db, msg) + // Why: a suppressed message is already read; waking a `check --wait` + // waiter for it would return an empty result before the deadline. + if (reconciled.action === 'suppressed') { + return { message: msg } + } + if (reconciled.action === 'rejected') { + const rejection = db.getMessageById(msg.id) ?? msg + runtime.deliverPendingMessagesForHandle(params.to) + runtime.notifyMessageArrived(params.to, rejection.type) + return { message: rejection, lifecycle: reconciled } + } } runtime.deliverPendingMessagesForHandle(params.to) runtime.notifyMessageArrived(params.to, msg.type) @@ -231,7 +271,8 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ type: params.type as MessageType, priority: params.priority as MessagePriority, threadId, - payload: params.payload + payload: params.payload, + senderPaneKey }) ) for (const message of messages) { @@ -264,30 +305,34 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ // Explicit `unread: false` is also honored for one release as a compat // shim so in-flight callers don't break (see design doc §5). Otherwise // today's behavior is preserved: default is unread-only + mark-read. - const showAll = params.all === true || params.unread === false - const showUnread = !showAll + const showAll = params.all === true || (params.unread === false && params.peek !== true) + const consumeUnread = !showAll && params.peek !== true const readAndReturn = () => { - const messages = showUnread - ? db.getUnreadMessages(handle, typeFilter) - : db.getAllMessagesForHandle(handle, undefined, typeFilter) + const messages = showAll + ? db.getAllMessagesForHandle(handle, undefined, typeFilter) + : db.getUnreadMessages(handle, typeFilter) - if (showUnread && messages.length > 0) { + let visibleMessages = messages + if (consumeUnread && messages.length > 0) { // Why: manual coordinators can consume lifecycle messages before // the coordinator loop sees them, but unread `check` is still an // authoritative read path for worker_done/heartbeat. - for (const message of messages) { - reconcileLifecycleMessage(db, message) - } + visibleMessages = messages.map((message) => { + const reconciled = reconcileLifecycleMessage(db, message) + return reconciled.action === 'rejected' + ? (db.getMessageById(message.id) ?? message) + : message + }) db.markAsRead(messages.map((m) => m.id)) } if (params.inject) { - const formatted = messages.map(formatMessageBanner).join('\n\n') - return { messages, formatted, count: messages.length } + const formatted = visibleMessages.map(formatMessageBanner).join('\n\n') + return { messages: visibleMessages, formatted, count: visibleMessages.length } } - return { messages, count: messages.length } + return { messages: visibleMessages, count: visibleMessages.length } } if (signal?.aborted) { @@ -406,7 +451,10 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ } return base }) - return { tasks, count: tasks.length } + return { + tasks: params.brief ? abbreviateOrchestrationTasks(tasks) : tasks, + count: tasks.length + } } }), @@ -446,7 +494,11 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ dispatchId: 'ctx_dryrun', taskSpec: task.spec, coordinatorHandle: params.from ?? 'coordinator', - devMode: params.devMode + workerHandle: params.to ?? 'worker', + devMode: params.devMode, + ...(params.to + ? { cliCommand: runtime.getTerminalOrchestrationCliCommand(params.to) } + : {}) }) return { dispatch: null, injected: false, dryRun: true, preamble } } @@ -469,13 +521,17 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ if (!hasAgent) { throw new Error( `Cannot dispatch --inject to terminal ${to}: no recognized agent detected. ` + - 'Start an agent CLI (e.g. claude, codex, gemini, droid) in the terminal first, ' + + 'Start an agent CLI (e.g. claude, codex, gemini, droid, cursor) in the terminal first, ' + 'or dispatch without --inject and send the prompt manually.' ) } } - const ctx = db.createDispatchContext(params.task, to) + const ctx = db.createDispatchContext( + params.task, + to, + runtime.getTerminalPaneKey(to) ?? undefined + ) // Why: preamble is built here (not before ctx) so `dispatchId` can be // the real ctx.id — the preamble-hardening PR made dispatchId required @@ -486,7 +542,9 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ dispatchId: ctx.id, taskSpec: task.spec, coordinatorHandle: params.from ?? 'coordinator', - devMode: params.devMode + workerHandle: to, + devMode: params.devMode, + cliCommand: runtime.getTerminalOrchestrationCliCommand(to) }) let injected = false @@ -529,6 +587,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ if (!task) { throw new Error(`Task not found: ${params.task}`) } + const workerHandle = ctx?.assignee_handle ?? 'worker' const preamble = buildDispatchPreamble({ taskId: task.id, // Why: prefer the existing dispatch context's id if we have one @@ -537,7 +596,9 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ dispatchId: ctx?.id ?? 'ctx_preview', taskSpec: task.spec, coordinatorHandle: params.from ?? 'coordinator', - devMode: params.devMode + workerHandle, + devMode: params.devMode, + ...(ctx ? { cliCommand: runtime.getTerminalOrchestrationCliCommand(workerHandle) } : {}) }) return { dispatch: ctx ?? null, preamble } } diff --git a/src/main/runtime/rpc/methods/session-tabs-schemas.ts b/src/main/runtime/rpc/methods/session-tabs-schemas.ts index 0c21af7de72..1b8afbd2f46 100644 --- a/src/main/runtime/rpc/methods/session-tabs-schemas.ts +++ b/src/main/runtime/rpc/methods/session-tabs-schemas.ts @@ -130,6 +130,7 @@ export const CreateTerminalTab = WorktreeTabSelector.extend({ message: 'Unknown launch agent' }) .optional(), + viewMode: z.enum(['terminal', 'chat']).optional(), activate: z.boolean().optional(), // Why: idempotency key so a retried create (double-tap, reconnect replay) // returns the in-flight operation instead of spawning a duplicate terminal. diff --git a/src/main/runtime/rpc/methods/session-tabs.test.ts b/src/main/runtime/rpc/methods/session-tabs.test.ts index 625959f6d64..d81faa169cd 100644 --- a/src/main/runtime/rpc/methods/session-tabs.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs.test.ts @@ -148,6 +148,7 @@ describe('session tab RPC methods', () => { agentEnv: { CODEX_PROFILE: 'captured' } }, launchAgent: 'codex', + viewMode: 'chat', activate: true }) ) @@ -167,6 +168,7 @@ describe('session tab RPC methods', () => { agentEnv: { CODEX_PROFILE: 'captured' } }, launchAgent: 'codex', + viewMode: 'chat', activate: true }) }) diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index d27d771f321..55be4d0b21e 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -53,6 +53,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ ...(params.launchConfig ? { launchConfig: params.launchConfig } : {}), ...(params.launchToken ? { launchToken: params.launchToken } : {}), ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), + ...(params.viewMode ? { viewMode: params.viewMode } : {}), activate: params.activate, clientMutationId: params.clientMutationId, // Why: a dead client connection must cancel the surface wait instead diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 9c39a65f456..d252651b56b 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -348,6 +348,31 @@ function isTerminalSendGuardNotWritable(error: unknown): boolean { return message.includes('terminal_guard_not_writable') } +function isTerminalAgentStatusNotWritable(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + return [ + 'terminal_not_writable', + 'terminal_handle_stale', + 'terminal_gone', + 'terminal_exited' + ].some((code) => message.includes(code)) +} + +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, @@ -710,6 +735,10 @@ const TerminalHandle = z.object({ terminal: requiredString('Missing terminal handle') }) +const TerminalClose = TerminalHandle.extend({ + expectedRuntimeId: z.string().min(1).optional() +}) + const TerminalListParams = z.object({ worktree: OptionalString, limit: OptionalFiniteNumber, @@ -1148,10 +1177,22 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const assertSendPreconditions = params.requireAgentStatus === 'sendable' ? async (ptyId?: string): Promise => { + assertTerminalSendExactPtyBinding(runtime, params.terminal, ptyId) if (ptyId && isTerminalInputLockedForClient(runtime, ptyId, params.client)) { throw new Error('terminal_guard_not_writable') } - const agentStatus = await runtime.getTerminalAgentStatus(params.terminal) + let agentStatus + try { + agentStatus = await runtime.getTerminalAgentStatus(params.terminal) + } catch (error) { + if (isTerminalAgentStatusNotWritable(error)) { + throw new Error('terminal_guard_not_writable') + } + throw error + } + // Why: a send callback can race a pane reconnect; status evidence + // must never authorize the callback's replacement PTY. + assertTerminalSendExactPtyBinding(runtime, params.terminal, ptyId) if (!agentStatus.isRunningAgent) { throw new Error('terminal_guard_no_agent') } @@ -1326,10 +1367,13 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ }), defineMethod({ name: 'terminal.close', - params: TerminalHandle, - handler: async (params, { runtime }) => ({ - close: await runtime.closeTerminal(params.terminal) - }) + params: TerminalClose, + handler: async (params, { runtime }) => { + if (params.expectedRuntimeId && params.expectedRuntimeId !== runtime.getRuntimeId()) { + throw new Error('runtime_generation_mismatch') + } + return { close: await runtime.closeTerminal(params.terminal) } + } }), defineMethod({ name: 'agentTeams.tmuxCompat', diff --git a/src/main/runtime/rpc/methods/worktree.test.ts b/src/main/runtime/rpc/methods/worktree.test.ts index ef7ef3910f0..8bc68319d2d 100644 --- a/src/main/runtime/rpc/methods/worktree.test.ts +++ b/src/main/runtime/rpc/methods/worktree.test.ts @@ -472,14 +472,18 @@ describe('worktree RPC methods', () => { const runtime = { getRuntimeId: () => 'test-runtime', showRepo: vi.fn().mockResolvedValue(repo), - createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' } }) + createManagedWorktree: vi.fn().mockResolvedValue({ + worktree: { id: 'wt-1' }, + startupTerminal: { spawned: true, handle: 'term_agent' } + }) } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) - await dispatcher.dispatch( + const response = await dispatcher.dispatch( makeRequest('worktree.create', { repo: 'repo-1', name: 'agent-startup', + startupAgent: 'codex', startupCommand: "codex 'summarize repo'", startupCommandDelivery: 'shell-ready', startupEnv: { ORCA_AGENT_MODE: 'direct' }, @@ -492,11 +496,17 @@ describe('worktree RPC methods', () => { }) ) + expect(response).toMatchObject({ + ok: true, + result: { agentTerminalHandle: 'term_agent' } + }) + expect(runtime.createManagedWorktree).toHaveBeenCalledWith( expect.objectContaining({ repoSelector: 'repo-1', name: 'agent-startup', activate: true, + startupAgent: 'codex', startup: { command: "codex 'summarize repo'", startupCommandDelivery: 'shell-ready', diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index f84a26d1d87..8132a96e3f8 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -133,7 +133,11 @@ export const WORKTREE_METHODS: RpcMethod[] = [ } }) finishAutomationWorkspaceProvenanceRequest(params.automationProvenanceRequest) - return result + // Why: agent callers need a stable dispatch target without traversing + // terminal-list layout duplicates after creating the worktree. + return params.startupAgent && result.startupTerminal?.handle + ? { ...result, agentTerminalHandle: result.startupTerminal.handle } + : result } catch (error) { releaseAutomationWorkspaceProvenanceRequest(params.automationProvenanceRequest) throw error diff --git a/src/main/runtime/rpc/schemas.test.ts b/src/main/runtime/rpc/schemas.test.ts index 9712f1c23d5..010ef2bea11 100644 --- a/src/main/runtime/rpc/schemas.test.ts +++ b/src/main/runtime/rpc/schemas.test.ts @@ -84,4 +84,37 @@ describe('RPC optional pipe schemas', () => { }) expectParses(methodParams(WORKTREE_METHODS, 'worktree.prefetchCreateBase'), { repo: 'repo-1' }) }) + + it('accepts worktree.create payloads sent by the previous mobile protocol', () => { + const create = methodParams(WORKTREE_METHODS, 'worktree.create') + + expectParses(create, { + repo: 'id:repo-github', + name: 'fix-mobile-tasks', + displayName: 'Fix mobile tasks', + setupDecision: 'run', + activate: true, + startupDraft: 'https://github.com/acme/app/pull/123', + createdWithAgent: 'codex', + linkedPR: 123, + baseBranch: 'origin/main', + compareBaseRef: 'origin/main', + branchNameOverride: 'feature/mobile-tasks', + pushTarget: { remoteName: 'origin', branchName: 'feature/mobile-tasks' } + }) + expectParses(create, { + repo: 'id:repo-gitlab', + name: 'mr-7', + linkedGitLabMR: 7, + sparseCheckout: { directories: ['mobile'], presetId: 'mobile' }, + comment: 'keep mobile parity' + }) + expectParses(create, { + repo: 'id:repo-linear', + name: 'eng-42', + linkedLinearIssue: 'ENG-42', + linkedLinearIssueWorkspaceId: 'workspace-1', + linkedLinearIssueOrganizationUrlKey: 'acme' + }) + }) }) diff --git a/src/main/runtime/rpc/terminal-close-generation.test.ts b/src/main/runtime/rpc/terminal-close-generation.test.ts new file mode 100644 index 00000000000..c6111000149 --- /dev/null +++ b/src/main/runtime/rpc/terminal-close-generation.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../orca-runtime' +import type { RpcRequest } from './core' +import { RpcDispatcher } from './dispatcher' +import { TERMINAL_METHODS } from './methods/terminal' + +function request(params: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method: 'terminal.close', params } +} + +describe('terminal close runtime generation', () => { + it('rejects a close fenced to a replaced runtime before touching the handle', async () => { + const closeTerminal = vi.fn() + const dispatcher = new RpcDispatcher({ + runtime: { + getRuntimeId: () => 'runtime-b', + closeTerminal + } as unknown as OrcaRuntimeService, + methods: TERMINAL_METHODS + }) + + const response = await dispatcher.dispatch( + request({ terminal: 'terminal-1', expectedRuntimeId: 'runtime-a' }) + ) + + expect(response).toMatchObject({ + ok: false, + error: { message: expect.stringContaining('runtime_generation_mismatch') }, + _meta: { runtimeId: 'runtime-b' } + }) + expect(closeTerminal).not.toHaveBeenCalled() + }) + + it('closes a handle when the expected runtime generation still owns it', async () => { + const closeTerminal = vi.fn().mockResolvedValue({ closed: true }) + const dispatcher = new RpcDispatcher({ + runtime: { + getRuntimeId: () => 'runtime-a', + closeTerminal + } as unknown as OrcaRuntimeService, + methods: TERMINAL_METHODS + }) + + await expect( + dispatcher.dispatch(request({ terminal: 'terminal-1', expectedRuntimeId: 'runtime-a' })) + ).resolves.toMatchObject({ ok: true }) + expect(closeTerminal).toHaveBeenCalledWith('terminal-1') + }) +}) diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts index 12e40c2c977..58b37bcd7fc 100644 --- a/src/main/runtime/rpc/terminal-send.test.ts +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -525,6 +525,84 @@ describe('terminal send RPC', () => { ) }) + it('writes zero bytes when a guarded callback observes a handle rebind during status', async () => { + let boundPtyId = 'pty-1' + let statusCalls = 0 + const write = vi.fn() + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn(() => ({ ptyId: boundPtyId })), + getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), + getTerminalAgentStatus: vi.fn().mockImplementation(async () => { + statusCalls += 1 + if (statusCalls === 2) { + boundPtyId = 'pty-2' + } + return { + handle: 'terminal-1', + isRunningAgent: true, + status: 'working' + } + }), + sendTerminal: vi.fn().mockImplementation(async (_handle, _action, options) => { + await options.beforeWrite('pty-1') + write('pty-1', '\r') + return { handle: 'terminal-1', accepted: true, bytesWritten: 1 } + }) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + enter: true, + requireAgentStatus: 'sendable', + client: { id: 'desktop-1', type: 'desktop' } + }) + ) + + expect(response).toMatchObject({ + ok: true, + result: { send: { accepted: false, bytesWritten: 0 } } + }) + expect(runtime.getTerminalAgentStatus).toHaveBeenCalledTimes(2) + expect(write).not.toHaveBeenCalled() + }) + + it('rejects a guarded callback whose actual write PTY differs from the handle binding', async () => { + const write = vi.fn() + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn(() => ({ ptyId: 'pty-1' })), + getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), + getTerminalAgentStatus: vi.fn().mockResolvedValue({ + handle: 'terminal-1', + isRunningAgent: true, + status: 'working' + }), + sendTerminal: vi.fn().mockImplementation(async (_handle, _action, options) => { + await options.beforeWrite('pty-2') + write('pty-2', '\r') + return { handle: 'terminal-1', accepted: true, bytesWritten: 1 } + }) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + enter: true, + requireAgentStatus: 'sendable', + client: { id: 'desktop-1', type: 'desktop' } + }) + ) + + expect(response).toMatchObject({ + ok: true, + result: { send: { accepted: false, bytesWritten: 0 } } + }) + expect(runtime.getTerminalAgentStatus).toHaveBeenCalledTimes(1) + expect(write).not.toHaveBeenCalled() + }) + it('refuses guarded combined text and submit sends before any PTY write', async () => { const runtime = stubRuntime({ resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index ace969138c4..522d3f0e3ed 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -232,11 +232,17 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'github.updatePRState', 'github.repoSlug', 'github.workItem', + // Cross-repo GitHub work-item lookup: lets the mobile create-workspace Smart + // picker resolve a pasted github.com URL that points at a different repo. + 'github.workItemByOwnerRepo', 'github.workItemDetails', 'gitlab.createIssue', 'gitlab.addIssueComment', 'gitlab.addMRComment', 'gitlab.listWorkItems', + // Mobile create-workspace Smart picker: resolve a pasted GitLab URL to an exact + // issue/MR. (MR listing reuses gitlab.listWorkItems, which returns issues + MRs.) + 'gitlab.workItemByPath', 'gitlab.mergeMR', 'gitlab.resolveMRDiscussion', 'gitlab.todos', @@ -279,6 +285,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'linear.updateIssue', 'markdown.readTab', 'markdown.saveTab', + 'notifications.getMissedSince', 'notifications.subscribe', 'notifications.unsubscribe', 'preflight.check', diff --git a/src/main/runtime/windows-firewall-remote-scope.test.ts b/src/main/runtime/windows-firewall-remote-scope.test.ts new file mode 100644 index 00000000000..6e2462c0334 --- /dev/null +++ b/src/main/runtime/windows-firewall-remote-scope.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest' +import { hasSufficientWindowsFirewallRemoteScope } from './windows-firewall-remote-scope' + +type RuleScope = { remoteAddresses: unknown } + +function rule(remoteAddresses: unknown): RuleScope { + return { remoteAddresses } +} + +describe('Windows firewall remote-address scope', () => { + it.each([['Any'], ['any'], ['LocalSubnet']])('accepts the documented %s scope', (scope) => { + expect(hasSufficientWindowsFirewallRemoteScope([rule(scope)], undefined, undefined)).toBe(true) + }) + + it.each([['Any4'], ['LocalSubnet4']])( + 'accepts address-family-specific %s on the selected IPv4 interface', + (scope) => { + expect(hasSufficientWindowsFirewallRemoteScope([rule([scope])], '192.168.0.108', 24)).toBe( + true + ) + } + ) + + it('keeps address-family-specific keywords on the selected interface family', () => { + expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any6'])], 'fd7a:115c:a1e0::5', 64)).toBe( + true + ) + expect( + hasSufficientWindowsFirewallRemoteScope([rule(['LocalSubnet6'])], 'fd7a:115c:a1e0::5', 64) + ).toBe(true) + expect( + hasSufficientWindowsFirewallRemoteScope([rule(['LocalSubnet6'])], '192.168.0.108', 24) + ).toBe(false) + }) + + it.each([ + ['192.168.0.0/24', '192.168.0.108', 24], + ['192.168.0.0-192.168.0.255', '192.168.0.108', 24], + ['fd7a:115c:a1e0::/64', 'fd7a:115c:a1e0::5', 64], + ['fd7a:115c:a1e0::-fd7a:115c:a1e0:0:ffff:ffff:ffff:ffff', 'fd7a:115c:a1e0::5', 64] + ])('accepts explicit scope %s covering the selected local subnet', (scope, address, prefix) => { + expect(hasSufficientWindowsFirewallRemoteScope([rule([scope])], address, prefix)).toBe(true) + }) + + it.each([ + ['192.168.1.0/24', '192.168.0.108', 24], + ['192.168.0.64/26', '192.168.0.108', 24], + ['192.168.0.108', '192.168.0.108', 24], + ['fd7a:115c:a1e1::/64', 'fd7a:115c:a1e0::5', 64], + ['Internet', '192.168.0.108', 24] + ])('rejects restrictive or unsupported scope %s', (scope, address, prefix) => { + expect(hasSufficientWindowsFirewallRemoteScope([rule([scope])], address, prefix)).toBe(false) + }) + + it('does not infer explicit scope coverage without selected interface subnet data', () => { + expect( + hasSufficientWindowsFirewallRemoteScope([rule(['192.168.0.0/24'])], undefined, undefined) + ).toBe(false) + expect( + hasSufficientWindowsFirewallRemoteScope([rule(['192.168.0.0/24'])], '192.168.0.108', 40) + ).toBe(false) + expect( + hasSufficientWindowsFirewallRemoteScope([rule(['100.64.0.0/10'])], '100.64.1.20', 32) + ).toBe(false) + }) + + it.each([ + undefined, + null, + [], + {}, + [rule(undefined)], + [rule([])], + [rule([''])], + [rule(['not-an-address'])], + [{ remoteAddresses: [42] }] + ])('rejects malformed or empty structured output %#', (rules) => { + expect(hasSufficientWindowsFirewallRemoteScope(rules, '192.168.0.108', 24)).toBe(false) + }) + + it('evaluates each rule independently instead of merging partial ranges', () => { + expect( + hasSufficientWindowsFirewallRemoteScope( + [rule(['192.168.0.0-192.168.0.127']), rule(['192.168.0.128-192.168.0.255'])], + '192.168.0.108', + 24 + ) + ).toBe(false) + expect( + hasSufficientWindowsFirewallRemoteScope( + [rule(['192.168.1.0/24']), rule(['192.168.0.0/24'])], + '192.168.0.108', + 24 + ) + ).toBe(true) + }) + + it('accepts PowerShell single-object and single-string JSON shapes', () => { + expect( + hasSufficientWindowsFirewallRemoteScope( + { remoteAddresses: '192.168.0.0/24' }, + '192.168.0.108', + 24 + ) + ).toBe(true) + }) + + it('accepts dotted-netmask CIDR with a contiguous mask and rejects a holey one', () => { + expect( + hasSufficientWindowsFirewallRemoteScope( + [rule(['192.168.0.0/255.255.255.0'])], + '192.168.0.108', + 24 + ) + ).toBe(true) + expect( + hasSufficientWindowsFirewallRemoteScope( + [rule(['192.168.0.0/255.0.255.0'])], + '192.168.0.108', + 24 + ) + ).toBe(false) + }) + + it('treats a single-host (/32) interface as coverable only by Any/LocalSubnet keywords', () => { + // A /32 subnet is just the desktop itself, so an explicit range cannot prove + // the phone (a different host) is allowed — only the keywords can. + expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any'])], '100.64.1.20', 32)).toBe(true) + expect( + hasSufficientWindowsFirewallRemoteScope([rule(['LocalSubnet'])], '100.64.1.20', 32) + ).toBe(true) + expect( + hasSufficientWindowsFirewallRemoteScope([rule(['100.64.0.0/10'])], '100.64.1.20', 32) + ).toBe(false) + }) + + it('fails address-family keywords closed when the interface family is unknown', () => { + expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any'])], undefined, undefined)).toBe( + true + ) + expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any4'])], undefined, undefined)).toBe( + false + ) + expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any6'])], undefined, undefined)).toBe( + false + ) + }) + + it.each([['Intranet'], ['DNS'], ['DHCP'], ['DefaultGateway'], ['PlayToDevice']])( + 'fails the policy-defined %s keyword closed rather than assuming subnet coverage', + (scope) => { + expect(hasSufficientWindowsFirewallRemoteScope([rule([scope])], '192.168.0.108', 24)).toBe( + false + ) + } + ) +}) diff --git a/src/main/runtime/windows-firewall-remote-scope.ts b/src/main/runtime/windows-firewall-remote-scope.ts new file mode 100644 index 00000000000..9845413d079 --- /dev/null +++ b/src/main/runtime/windows-firewall-remote-scope.ts @@ -0,0 +1,216 @@ +type IpVersion = 4 | 6 + +type ParsedIpAddress = { + version: IpVersion + bits: 32 | 128 + value: bigint +} + +type IpRange = { + version: IpVersion + start: bigint + end: bigint +} + +export function hasSufficientWindowsFirewallRemoteScope( + ruleScopes: unknown, + localAddress: unknown, + localPrefixLength: unknown +): boolean { + const rules = Array.isArray(ruleScopes) ? ruleScopes : [ruleScopes] + const localSubnet = parseSubnet(localAddress, localPrefixLength) + + // Why: coverage is checked per scope, never unioned across rules — this + // advisory check fails safe, so accepting fragmented rules only adds risk. + return rules.some((rule) => ruleHasSufficientScope(rule, localSubnet)) +} + +function ruleHasSufficientScope(rule: unknown, localSubnet: IpRange | null): boolean { + if (!isRecord(rule)) { + return false + } + const addresses = Array.isArray(rule.remoteAddresses) + ? rule.remoteAddresses + : [rule.remoteAddresses] + + return addresses.some( + (address) => typeof address === 'string' && addressScopeIsSufficient(address, localSubnet) + ) +} + +function addressScopeIsSufficient(scope: string, localSubnet: IpRange | null): boolean { + const normalized = scope.trim().toLowerCase() + if (normalized === 'any' || normalized === 'localsubnet') { + return true + } + if ( + normalized === 'any4' || + normalized === 'any6' || + normalized === 'localsubnet4' || + normalized === 'localsubnet6' + ) { + // Why: these keywords cover a single family, so they need the selected + // interface's family; an unknown family (null subnet) fails closed. + return localSubnet?.version === (normalized.endsWith('4') ? 4 : 6) + } + if (!localSubnet) { + return false + } + + // Why: without a phone IP we can only prove coverage when the rule spans the + // whole selected subnet; a single-host subnet (/32, /128 VPN/Tailscale) is just + // the desktop, so start !== end blocks a desktop-only rule from a false-allow. + const explicitRange = parseIpRange(scope) + return ( + localSubnet.start !== localSubnet.end && + explicitRange?.version === localSubnet.version && + explicitRange.start <= localSubnet.start && + explicitRange.end >= localSubnet.end + ) +} + +function parseSubnet(address: unknown, prefixLength: unknown): IpRange | null { + if (typeof address !== 'string' || typeof prefixLength !== 'number') { + return null + } + const parsed = parseIpAddress(address) + return parsed ? subnetFromParsed(parsed, prefixLength) : null +} + +function subnetFromParsed(parsed: ParsedIpAddress, prefixLength: number): IpRange | null { + if (!Number.isInteger(prefixLength) || prefixLength < 0 || prefixLength > parsed.bits) { + return null + } + const hostBits = BigInt(parsed.bits - prefixLength) + const hostMask = hostBits === 0n ? 0n : (1n << hostBits) - 1n + const start = parsed.value & ~hostMask + return { version: parsed.version, start, end: start | hostMask } +} + +// Why: Windows also accepts dotted-netmask CIDR (192.168.0.0/255.255.255.0); +// convert a contiguous mask to a prefix length and fail closed on holey masks. +function maskPrefixLength(maskText: string, version: IpVersion): number | null { + const mask = parseIpAddress(maskText) + if (!mask || mask.version !== version) { + return null + } + const fullMask = (1n << BigInt(mask.bits)) - 1n + const hostPart = ~mask.value & fullMask + if ((hostPart & (hostPart + 1n)) !== 0n) { + return null + } + let hostBits = 0 + for (let remaining = hostPart; remaining > 0n; remaining >>= 1n) { + hostBits += 1 + } + return mask.bits - hostBits +} + +function parseIpRange(scope: string): IpRange | null { + const trimmed = scope.trim() + const dashIndex = trimmed.indexOf('-') + if (dashIndex >= 0) { + if (dashIndex !== trimmed.lastIndexOf('-')) { + return null + } + const start = parseIpAddress(trimmed.slice(0, dashIndex)) + const end = parseIpAddress(trimmed.slice(dashIndex + 1)) + if (!start || !end || start.version !== end.version || start.value > end.value) { + return null + } + return { version: start.version, start: start.value, end: end.value } + } + + const slashIndex = trimmed.indexOf('/') + if (slashIndex >= 0) { + if (slashIndex !== trimmed.lastIndexOf('/')) { + return null + } + const address = parseIpAddress(trimmed.slice(0, slashIndex)) + if (!address) { + return null + } + const suffix = trimmed.slice(slashIndex + 1) + const prefixLength = /^\d+$/.test(suffix) + ? Number(suffix) + : maskPrefixLength(suffix, address.version) + return prefixLength === null ? null : subnetFromParsed(address, prefixLength) + } + + const address = parseIpAddress(trimmed) + return address ? { version: address.version, start: address.value, end: address.value } : null +} + +function parseIpAddress(input: string): ParsedIpAddress | null { + const trimmed = input.trim() + const bracketed = trimmed.startsWith('[') || trimmed.endsWith(']') + if (bracketed && !(trimmed.startsWith('[') && trimmed.endsWith(']'))) { + return null + } + const address = (bracketed ? trimmed.slice(1, -1) : trimmed).split('%', 1)[0] ?? '' + return address.includes(':') ? parseIpv6(address) : parseIpv4(address) +} + +function parseIpv4(address: string): ParsedIpAddress | null { + const octets = address.split('.') + if (octets.length !== 4 || octets.some((octet) => !/^\d{1,3}$/.test(octet))) { + return null + } + const values = octets.map(Number) + if (values.some((octet) => octet > 255)) { + return null + } + const value = values.reduce((result, octet) => (result << 8n) | BigInt(octet), 0n) + return { version: 4, bits: 32, value } +} + +function parseIpv6(address: string): ParsedIpAddress | null { + const expandedAddress = expandEmbeddedIpv4(address) + if (!expandedAddress) { + return null + } + const halves = expandedAddress.split('::') + if (halves.length > 2) { + return null + } + const left = splitIpv6Half(halves[0] ?? '') + const right = splitIpv6Half(halves[1] ?? '') + if (!left || !right) { + return null + } + + const hasCompression = halves.length === 2 + const missingGroups = 8 - left.length - right.length + if ((!hasCompression && missingGroups !== 0) || (hasCompression && missingGroups < 1)) { + return null + } + const groups = [...left, ...Array(missingGroups).fill('0'), ...right] + const value = groups.reduce((result, group) => (result << 16n) | BigInt(`0x${group}`), 0n) + return { version: 6, bits: 128, value } +} + +function expandEmbeddedIpv4(address: string): string | null { + if (!address.includes('.')) { + return address + } + const lastColon = address.lastIndexOf(':') + const ipv4 = parseIpv4(address.slice(lastColon + 1)) + if (lastColon < 0 || !ipv4) { + return null + } + const high = ((ipv4.value >> 16n) & 0xffffn).toString(16) + const low = (ipv4.value & 0xffffn).toString(16) + return `${address.slice(0, lastColon)}:${high}:${low}` +} + +function splitIpv6Half(half: string): string[] | null { + if (half === '') { + return [] + } + const groups = half.split(':') + return groups.every((group) => /^[\da-f]{1,4}$/i.test(group)) ? groups : null +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/main/runtime/windows-mobile-firewall.test.ts b/src/main/runtime/windows-mobile-firewall.test.ts new file mode 100644 index 00000000000..56464c15bf1 --- /dev/null +++ b/src/main/runtime/windows-mobile-firewall.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it, vi } from 'vitest' +import { + getWebSocketPort, + inspectWindowsMobileFirewall, + repairWindowsMobileFirewall, + type WindowsMobileFirewallEnvironment +} from './windows-mobile-firewall' + +function environment( + runPowerShell: WindowsMobileFirewallEnvironment['runPowerShell'], + overrides: Partial = {} +): WindowsMobileFirewallEnvironment { + return { + platform: 'win32', + isPackaged: true, + executablePath: "C:\\Users\\O'Brien\\Orca\\Orca.exe", + systemRoot: 'C:\\Windows', + runPowerShell, + ...overrides + } +} + +describe('windows mobile firewall', () => { + it('inspects the exact executable, port, and selected interface profile', async () => { + const runPowerShell = vi.fn().mockResolvedValue( + JSON.stringify({ + matchingRuleScopes: [{ remoteAddresses: ['192.168.0.0/24'] }], + localAddress: '192.168.0.108', + localPrefixLength: 24, + privateFirewallEnabled: true, + networkCategory: 'Private' + }) + ) + + await expect( + inspectWindowsMobileFirewall(6768, '192.168.0.108', environment(runPowerShell)) + ).resolves.toEqual({ + supported: true, + port: 6768, + ruleAllowed: true, + privateFirewallEnabled: true, + networkCategory: 'private', + inspectionAvailable: true + }) + + const script = runPowerShell.mock.calls[0]![0] as string + expect(script).toContain("LocalPort | Where-Object { [string]$_ -eq 'Any'") + expect(script).toContain("[string]$_ -eq '6768'") + expect(script).toContain("C:\\Users\\O''Brien\\Orca\\Orca.exe") + expect(script).toContain("$profile -match 'Private'") + expect(script).toContain("Get-NetIPAddress -IPAddress '192.168.0.108'") + expect(script).toContain('Get-NetFirewallAddressFilter') + expect(script).toContain('remoteAddresses = @($addressFilter.RemoteAddress') + expect(script).toContain('$localPrefixLength = [int]$ip.PrefixLength') + }) + + it('does not accept a qualifying rule whose remote-address scope excludes the phone subnet', async () => { + const runPowerShell = vi.fn().mockResolvedValue( + JSON.stringify({ + matchingRuleScopes: [{ remoteAddresses: ['192.168.1.0/24'] }], + localAddress: '192.168.0.108', + localPrefixLength: 24, + privateFirewallEnabled: true, + networkCategory: 'Private' + }) + ) + + await expect( + inspectWindowsMobileFirewall(6768, '192.168.0.108', environment(runPowerShell)) + ).resolves.toMatchObject({ + supported: true, + ruleAllowed: false, + inspectionAvailable: true + }) + }) + + it('does not support non-Windows or unpackaged development builds', async () => { + const runPowerShell = vi.fn() + await expect( + inspectWindowsMobileFirewall( + 6768, + undefined, + environment(runPowerShell, { platform: 'darwin' }) + ) + ).resolves.toEqual({ supported: false }) + await expect( + repairWindowsMobileFirewall(6768, environment(runPowerShell, { isPackaged: false })) + ).resolves.toEqual({ ok: false, reason: 'unsupported' }) + expect(runPowerShell).not.toHaveBeenCalled() + }) + + it('returns an actionable status when inspection is unavailable', async () => { + await expect( + inspectWindowsMobileFirewall( + 6768, + undefined, + environment(vi.fn().mockRejectedValue(new Error('managed policy'))) + ) + ).resolves.toEqual({ + supported: true, + port: 6768, + ruleAllowed: false, + privateFirewallEnabled: true, + networkCategory: 'unknown', + inspectionAvailable: false + }) + }) + + it('returns an actionable status for malformed or empty PowerShell output', async () => { + for (const stdout of ['', 'not json']) { + await expect( + inspectWindowsMobileFirewall( + 6768, + undefined, + environment(vi.fn().mockResolvedValue(stdout)) + ) + ).resolves.toMatchObject({ + supported: true, + ruleAllowed: false, + inspectionAvailable: false + }) + } + }) + + it('repairs only Orca mobile pairing on private networks after elevation', async () => { + const runPowerShell = vi.fn().mockResolvedValue('{"launched":true,"exitCode":0}') + await expect(repairWindowsMobileFirewall(6769, environment(runPowerShell))).resolves.toEqual({ + ok: true + }) + + const outerScript = runPowerShell.mock.calls[0]![0] as string + const encoded = outerScript.match(/'-EncodedCommand', '([^']+)'/)?.[1] + expect(encoded).toBeTruthy() + const repairScript = Buffer.from(encoded!, 'base64').toString('utf16le') + expect(repairScript).toContain("-Name 'Orca.MobilePairing'") + expect(repairScript).toContain('-Profile Private') + expect(repairScript).toContain('-Protocol TCP') + expect(repairScript).toContain('-LocalPort 6769') + expect(repairScript).toContain("-Program 'C:\\Users\\O''Brien\\Orca\\Orca.exe'") + expect(repairScript).toContain('-EdgeTraversalPolicy Block') + }) + + it('distinguishes a cancelled UAC prompt from repair failure', async () => { + await expect( + repairWindowsMobileFirewall( + 6768, + environment(vi.fn().mockResolvedValue('{"launched":false,"nativeErrorCode":1223}')) + ) + ).resolves.toEqual({ ok: false, reason: 'cancelled' }) + }) + + it('extracts only explicit valid websocket ports', () => { + expect(getWebSocketPort('ws://0.0.0.0:6768')).toBe(6768) + expect(getWebSocketPort('ws://0.0.0.0')).toBeNull() + expect(getWebSocketPort('not a url')).toBeNull() + }) +}) diff --git a/src/main/runtime/windows-mobile-firewall.ts b/src/main/runtime/windows-mobile-firewall.ts new file mode 100644 index 00000000000..36797b83eb2 --- /dev/null +++ b/src/main/runtime/windows-mobile-firewall.ts @@ -0,0 +1,241 @@ +import { execFile } from 'node:child_process' +import { win32 } from 'node:path' +import type { + WindowsMobileFirewallRepairResult, + WindowsMobileFirewallStatus, + WindowsNetworkCategory +} from '../../shared/windows-mobile-firewall' +import { hasSufficientWindowsFirewallRemoteScope } from './windows-firewall-remote-scope' + +const FIREWALL_RULE_NAME = 'Orca.MobilePairing' +const FIREWALL_RULE_DISPLAY_NAME = 'Orca Mobile Pairing' +const POWERSHELL_TIMEOUT_MS = 10_000 +const ELEVATION_TIMEOUT_MS = 5 * 60_000 + +type PowerShellRunner = (script: string, timeoutMs: number) => Promise + +export type WindowsMobileFirewallEnvironment = { + platform: NodeJS.Platform + isPackaged: boolean + executablePath: string + systemRoot?: string + runPowerShell?: PowerShellRunner +} + +type FirewallInspection = { + matchingRuleScopes?: unknown + localAddress?: unknown + localPrefixLength?: unknown + privateFirewallEnabled: boolean + networkCategory: string +} + +type ElevationResult = { + launched: boolean + exitCode?: number + nativeErrorCode?: number +} + +export async function inspectWindowsMobileFirewall( + port: number | null, + address: string | undefined, + environment: WindowsMobileFirewallEnvironment = defaultEnvironment() +): Promise { + if (!isSupported(environment, port)) { + return { supported: false } + } + + try { + const stdout = await getRunner(environment)( + buildInspectionScript(port, environment.executablePath, address), + POWERSHELL_TIMEOUT_MS + ) + const result = JSON.parse(stdout.trim()) as FirewallInspection + return { + supported: true, + port, + ruleAllowed: hasSufficientWindowsFirewallRemoteScope( + result.matchingRuleScopes, + result.localAddress, + result.localPrefixLength + ), + privateFirewallEnabled: result.privateFirewallEnabled !== false, + networkCategory: parseNetworkCategory(result.networkCategory), + inspectionAvailable: true + } + } catch { + // Why: firewall inspection is advisory; unavailable PowerShell or managed + // policy must not block pairing or remove the explicit repair option. + return unavailableStatus(port) + } +} + +export async function repairWindowsMobileFirewall( + port: number | null, + environment: WindowsMobileFirewallEnvironment = defaultEnvironment() +): Promise { + if (!isSupported(environment, port)) { + return { ok: false, reason: 'unsupported' } + } + + const powershellPath = getWindowsPowerShellPath(environment.systemRoot) + const elevatedScript = buildRepairScript(port, environment.executablePath) + const outerScript = buildElevationScript(powershellPath, encodePowerShell(elevatedScript)) + try { + const stdout = await getRunner(environment)(outerScript, ELEVATION_TIMEOUT_MS) + const result = JSON.parse(stdout.trim()) as ElevationResult + if (!result.launched && result.nativeErrorCode === 1223) { + return { ok: false, reason: 'cancelled' } + } + return result.launched && result.exitCode === 0 ? { ok: true } : { ok: false, reason: 'failed' } + } catch { + return { ok: false, reason: 'failed' } + } +} + +export function getWebSocketPort(endpoint: string | null): number | null { + if (!endpoint) { + return null + } + try { + const parsed = new URL(endpoint) + const port = Number(parsed.port) + return Number.isInteger(port) && port > 0 && port <= 65_535 ? port : null + } catch { + return null + } +} + +function defaultEnvironment(): WindowsMobileFirewallEnvironment { + return { + platform: process.platform, + // Why: direct helper calls must fail closed; the IPC integration opts in + // only after Electron confirms this is a packaged Windows build. + isPackaged: false, + executablePath: process.execPath, + systemRoot: process.env.SystemRoot + } +} + +function isSupported( + environment: WindowsMobileFirewallEnvironment, + port: number | null +): port is number { + // Why: development Electron paths are transient and must never be persisted + // into an elevated firewall rule that outlives the checkout. + return environment.platform === 'win32' && environment.isPackaged && port !== null +} + +function unavailableStatus(port: number): WindowsMobileFirewallStatus { + return { + supported: true, + port, + ruleAllowed: false, + privateFirewallEnabled: true, + networkCategory: 'unknown', + inspectionAvailable: false + } +} + +function parseNetworkCategory(value: string): WindowsNetworkCategory { + if (value === 'Private') { + return 'private' + } + if (value === 'Public') { + return 'public' + } + if (value === 'DomainAuthenticated') { + return 'domain' + } + return 'unknown' +} + +function quotePowerShell(value: string): string { + return `'${value.replaceAll("'", "''")}'` +} + +function buildInspectionScript(port: number, executablePath: string, address?: string): string { + const addressLookup = address + ? ` +try { + $ip = Get-NetIPAddress -IPAddress ${quotePowerShell(address)} -ErrorAction Stop | Select-Object -First 1 + $localAddress = [string]$ip.IPAddress + $localPrefixLength = [int]$ip.PrefixLength + $profile = Get-NetConnectionProfile -InterfaceIndex $ip.InterfaceIndex -ErrorAction Stop | Select-Object -First 1 + if ($profile) { $networkCategory = [string]$profile.NetworkCategory } +} catch {}` + : '' + // Why: NetSecurity filter properties are stable across localized Windows + // display output and keep every rule's address scope independent. + return `$ErrorActionPreference = 'Stop' +$matchingRuleScopes = @() +$rules = @(Get-NetFirewallApplicationFilter -Program ${quotePowerShell(executablePath)} -ErrorAction SilentlyContinue | Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Inbound' -and $_.Action -eq 'Allow' }) +foreach ($rule in $rules) { + $portFilter = $rule | Get-NetFirewallPortFilter + $protocol = [string]$portFilter.Protocol + $profile = [string]$rule.Profile + $portMatches = @($portFilter.LocalPort | Where-Object { [string]$_ -eq 'Any' -or [string]$_ -eq '${port}' }).Count -gt 0 + if (($protocol -eq 'Any' -or $protocol -eq 'TCP' -or $protocol -eq '6') -and ($profile -eq 'Any' -or $profile -match 'Private') -and $portMatches) { + $addressFilter = $rule | Get-NetFirewallAddressFilter + $matchingRuleScopes += [pscustomobject]@{ + remoteAddresses = @($addressFilter.RemoteAddress | ForEach-Object { [string]$_ }) + } + } +} +$privateFirewallEnabled = [bool](Get-NetFirewallProfile -Name Private).Enabled +$networkCategory = 'Unknown'${addressLookup} +[pscustomobject]@{ + matchingRuleScopes = @($matchingRuleScopes) + localAddress = $localAddress + localPrefixLength = $localPrefixLength + privateFirewallEnabled = $privateFirewallEnabled + networkCategory = $networkCategory +} | ConvertTo-Json -Depth 4 -Compress` +} + +function buildRepairScript(port: number, executablePath: string): string { + return `$ErrorActionPreference = 'Stop' +Get-NetFirewallRule -Name ${quotePowerShell(FIREWALL_RULE_NAME)} -ErrorAction SilentlyContinue | Remove-NetFirewallRule +New-NetFirewallRule -Name ${quotePowerShell(FIREWALL_RULE_NAME)} -DisplayName ${quotePowerShell(FIREWALL_RULE_DISPLAY_NAME)} -Description 'Allows Orca Mobile to connect to this Orca desktop on private networks.' -Direction Inbound -Action Allow -Enabled True -Profile Private -Protocol TCP -LocalPort ${port} -Program ${quotePowerShell(executablePath)} -EdgeTraversalPolicy Block | Out-Null` +} + +function buildElevationScript(powershellPath: string, encodedRepairScript: string): string { + return `$ErrorActionPreference = 'Stop' +try { + $process = Start-Process -FilePath ${quotePowerShell(powershellPath)} -ArgumentList @('-NoProfile', '-NonInteractive', '-EncodedCommand', '${encodedRepairScript}') -Verb RunAs -Wait -PassThru + [pscustomobject]@{ launched = $true; exitCode = $process.ExitCode } | ConvertTo-Json -Compress +} catch { + [pscustomobject]@{ launched = $false; nativeErrorCode = $_.Exception.NativeErrorCode } | ConvertTo-Json -Compress +}` +} + +function encodePowerShell(script: string): string { + return Buffer.from(script, 'utf16le').toString('base64') +} + +function getWindowsPowerShellPath(systemRoot = 'C:\\Windows'): string { + return win32.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') +} + +function getRunner(environment: WindowsMobileFirewallEnvironment): PowerShellRunner { + return environment.runPowerShell ?? createPowerShellRunner(environment.systemRoot) +} + +function createPowerShellRunner(systemRoot?: string): PowerShellRunner { + const powershellPath = getWindowsPowerShellPath(systemRoot) + return (script, timeoutMs) => + new Promise((resolve, reject) => { + execFile( + powershellPath, + ['-NoProfile', '-NonInteractive', '-EncodedCommand', encodePowerShell(script)], + { encoding: 'utf8', timeout: timeoutMs, windowsHide: true, maxBuffer: 1024 * 1024 }, + (error, stdout) => { + if (error) { + reject(error) + return + } + resolve(stdout) + } + ) + }) +} diff --git a/src/main/runtime/worktree-teardown.test.ts b/src/main/runtime/worktree-teardown.test.ts index 63e207cd8da..41399938173 100644 --- a/src/main/runtime/worktree-teardown.test.ts +++ b/src/main/runtime/worktree-teardown.test.ts @@ -19,6 +19,7 @@ function createProviderStub( attach: vi.fn(), write: vi.fn(), resize: vi.fn(), + hasPty: vi.fn(() => false), shutdown: vi.fn().mockResolvedValue(undefined), sendSignal: vi.fn(), getCwd: vi.fn(), @@ -124,6 +125,21 @@ describe('killAllProcessesForWorktree', () => { expect(onPtyStopped).toHaveBeenCalledWith('w1@@aaaa') }) + it('preserves cleanup ownership when shutdown acceptance lacks exit proof', async () => { + const localProvider = createProviderStub(async () => [ + { id: 'w1@@alive', cwd: '/tmp/w1', title: 'shell' } + ]) + Object.defineProperty(localProvider, 'requiresShutdownExitProof', { value: true }) + ;(localProvider.hasPty as unknown as ReturnType).mockReturnValue(true) + listRegisteredPtysMock.mockReturnValue([]) + const onPtyStopped = vi.fn() + + const result = await killAllProcessesForWorktree('w1', { localProvider, onPtyStopped }) + + expect(result.providerStopped).toBe(0) + expect(onPtyStopped).not.toHaveBeenCalled() + }) + it('does not carry state between successive calls with distinct providers', async () => { // Guards against a future refactor that memoises provider or registry // reads inside the helper. diff --git a/src/main/runtime/worktree-teardown.ts b/src/main/runtime/worktree-teardown.ts index 7572b9bbd77..6763d26a4f6 100644 --- a/src/main/runtime/worktree-teardown.ts +++ b/src/main/runtime/worktree-teardown.ts @@ -79,11 +79,14 @@ async function sweepProviderByPrefix( } try { await provider.shutdown(s.id, { immediate: true }) + if (provider.requiresShutdownExitProof && provider.hasPty?.(s.id) !== false) { + continue + } clearStoppedPtyState(s.id, onPtyStopped) killed += 1 } catch { - // Already dead, or the backend dropped the session — treat as success. - killed += 1 + // Why: rejection is not absence proof. Preserve state so the retained + // owner or a later sweep can target the exact process again. } } return killed @@ -99,6 +102,12 @@ async function sweepRegistryForWorktree( for (const entry of entries) { try { await localProvider.shutdown(entry.ptyId, { immediate: true }) + if ( + localProvider.requiresShutdownExitProof && + localProvider.hasPty?.(entry.ptyId) !== false + ) { + continue + } clearStoppedPtyState(entry.ptyId, onPtyStopped) killed += 1 } catch { diff --git a/src/main/skills/discovery.test.ts b/src/main/skills/discovery.test.ts index dcee058f8e2..68ff5e9d017 100644 --- a/src/main/skills/discovery.test.ts +++ b/src/main/skills/discovery.test.ts @@ -57,6 +57,32 @@ describe('skill discovery', () => { expect(rootPaths).toContain('/workspace/current/.claude/skills') }) + it('scans each provider home skill root that npx skills --global writes to', () => { + const roots = buildSkillDiscoverySources({ + homeDir: '/home/test', + cwd: '/workspace/current' + }) + + const rootPaths = roots.map((root) => root.path.replace(/\\/g, '/')) + expect(rootPaths).toEqual( + expect.arrayContaining([ + '/home/test/.grok/skills', + '/home/test/.config/opencode/skills', + '/home/test/.pi/agent/skills', + '/home/test/.gemini/skills', + '/home/test/.gemini/antigravity/skills', + '/home/test/.cursor/skills' + ]) + ) + // Why: these live outside ~/.agents/skills, so they must carry the shared + // agent-skills provider to feed per-agent orchestration coverage. + for (const root of roots) { + if (root.path.replace(/\\/g, '/') === '/home/test/.grok/skills') { + expect(root.providers).toEqual(['agent-skills']) + } + } + }) + it('discovers skill packages through symlinked skill directories', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) const home = join(root, 'home') @@ -77,6 +103,27 @@ describe('skill discovery', () => { expect(skill?.directoryPath).toBe(linkedSkill) }) + it('discovers a symlinked skill inside a provider home root (#8256/#8503)', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) + const home = join(root, 'home') + const realSkill = join(root, 'central-skills', 'orchestration') + const linkedSkill = join(home, '.pi', 'agent', 'skills', 'orchestration') + await mkdir(realSkill, { recursive: true }) + await mkdir(join(home, '.pi', 'agent', 'skills'), { recursive: true }) + await writeFile(join(realSkill, 'SKILL.md'), '# orchestration\n\nCoordinate agents.') + await symlink(realSkill, linkedSkill, process.platform === 'win32' ? 'junction' : 'dir') + + const result = await discoverSkills({ + homeDir: home, + cwd: join(root, 'missing-cwd') + }) + + const skill = result.skills.find((entry) => entry.name === 'orchestration') + expect(skill?.sourceKind).toBe('home') + expect(skill?.directoryPath).toBe(linkedSkill) + expect(skill?.providers).toEqual(['agent-skills']) + }) + it('discovers worktree .agents skill symlinks from the requested cwd', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) const home = join(root, 'home') diff --git a/src/main/skills/skill-discovery-sources.ts b/src/main/skills/skill-discovery-sources.ts index c1bb1072b2e..bec34117c4c 100644 --- a/src/main/skills/skill-discovery-sources.ts +++ b/src/main/skills/skill-discovery-sources.ts @@ -41,7 +41,23 @@ export function buildSkillDiscoverySources( join(home, '.codex', 'plugins', 'cache'), 'plugin', ['codex', 'agent-skills'] - ) + ), + // Why: `npx skills add --global` writes into each agent's own home skills + // directory, so coverage misses them unless we scan every provider root. + source('home-grok', 'Grok home', join(home, '.grok', 'skills'), 'home', ['agent-skills']), + source('home-opencode', 'OpenCode home', join(home, '.config', 'opencode', 'skills'), 'home', [ + 'agent-skills' + ]), + source('home-pi', 'Pi home', join(home, '.pi', 'agent', 'skills'), 'home', ['agent-skills']), + source('home-gemini', 'Gemini home', join(home, '.gemini', 'skills'), 'home', ['agent-skills']), + source( + 'home-antigravity', + 'Antigravity home', + join(home, '.gemini', 'antigravity', 'skills'), + 'home', + ['agent-skills'] + ), + source('home-cursor', 'Cursor home', join(home, '.cursor', 'skills'), 'home', ['agent-skills']) ] const projectPaths = new Set() diff --git a/src/main/source-control/forge-provider.test.ts b/src/main/source-control/forge-provider.test.ts index 0317d6fc9e9..95c5c9d2676 100644 --- a/src/main/source-control/forge-provider.test.ts +++ b/src/main/source-control/forge-provider.test.ts @@ -11,7 +11,8 @@ const { getMergeRequestForBranchMock, getProjectSlugMock, getPRForBranchOutcomeMock, - getRepoSlugMock + getRepoSlugMock, + getEnterpriseGitHubRepoSlugMock } = vi.hoisted(() => ({ createGitHubPullRequestMock: vi.fn(), createGitLabMergeRequestMock: vi.fn(), @@ -23,7 +24,8 @@ const { getMergeRequestForBranchMock: vi.fn(), getProjectSlugMock: vi.fn(), getPRForBranchOutcomeMock: vi.fn(), - getRepoSlugMock: vi.fn() + getRepoSlugMock: vi.fn(), + getEnterpriseGitHubRepoSlugMock: vi.fn() })) vi.mock('../gitlab/client', () => ({ @@ -42,6 +44,10 @@ vi.mock('../github/client', () => ({ getPRForBranchOutcome: getPRForBranchOutcomeMock })) +vi.mock('../github/github-enterprise-repository', () => ({ + getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock +})) + vi.mock('../bitbucket/client', () => ({ getBitbucketRepoSlug: getBitbucketRepoSlugMock, getBitbucketPullRequestForBranch: vi.fn(), @@ -88,6 +94,7 @@ describe('forge provider interface', () => { getProjectSlugMock.mockReset() getPRForBranchOutcomeMock.mockReset() getRepoSlugMock.mockReset() + getEnterpriseGitHubRepoSlugMock.mockReset() }) it('preserves the existing hosted provider detection order', async () => { @@ -101,6 +108,45 @@ describe('forge provider interface', () => { expect(getRepoSlugMock).not.toHaveBeenCalled() }) + it('detects a GitHub Enterprise Server remote as the GitHub provider, not Gitea', async () => { + // Regression for #8312: a GHES host is not github.com, so github.com-only + // slug parsing returns null. Detection must claim it via the enterprise + // resolver instead of falling through to Gitea's demand for ORCA_GITEA_TOKEN. + getProjectSlugMock.mockResolvedValue(null) + getRepoSlugMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue({ + owner: 'team', + repo: 'orca', + host: 'github.acme-corp.com' + }) + + await expect(detectHostedReviewProvider({ repoPath: '/repo' })).resolves.toBe('github') + await expect(getForgeProviderForRepository({ repoPath: '/repo' })).resolves.toMatchObject({ + id: 'github' + }) + // Gitea must never be consulted once GitHub claims the enterprise host. + expect(getGiteaRepoSlugMock).not.toHaveBeenCalled() + }) + + it('leaves a genuinely non-GitHub remote for later providers when gh is not authenticated', async () => { + getProjectSlugMock.mockResolvedValue(null) + getRepoSlugMock.mockResolvedValue(null) + // gh is not logged in to this host, so the enterprise resolver declines and + // the Gitea provider is free to claim its own self-hosted remote. + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) + getBitbucketRepoSlugMock.mockResolvedValue(null) + getAzureDevOpsRepoSlugMock.mockResolvedValue(null) + getGiteaRepoSlugMock.mockResolvedValue({ + host: 'gitea.example.com', + owner: 'team', + repo: 'orca', + apiBaseUrl: 'https://gitea.example.com/api/v1', + webBaseUrl: 'https://gitea.example.com' + }) + + await expect(detectHostedReviewProvider({ repoPath: '/repo' })).resolves.toBe('gitea') + }) + it('keeps review creation capability scoped to providers with creation support', async () => { expect( FORGE_PROVIDERS.map((provider) => [provider.id, provider.supportsReviewCreation]) diff --git a/src/main/source-control/forge-provider.ts b/src/main/source-control/forge-provider.ts index d87efe9dcf1..a7d22ad48ae 100644 --- a/src/main/source-control/forge-provider.ts +++ b/src/main/source-control/forge-provider.ts @@ -22,6 +22,7 @@ import { } from '../gitea/client' import { createGiteaPullRequest } from '../gitea/pull-request-creation' import { createGitHubPullRequest, getPRForBranchOutcome, getRepoSlug } from '../github/client' +import { getEnterpriseGitHubRepoSlug } from '../github/github-enterprise-repository' import { getMergeRequest, getMergeRequestForBranch, getProjectSlug } from '../gitlab/client' import { createGitLabMergeRequest } from '../gitlab/merge-request-creation' import { @@ -122,8 +123,25 @@ function unwrapGitHubPRForBranchOutcome( const gitHubForgeProvider = { id: 'github', supportsReviewCreation: true, - resolveRepository: (context) => - getRepoSlug(context.repoPath, context.connectionId, ...hostedReviewExecutionArgs(context)), + resolveRepository: async (context) => { + const slug = await getRepoSlug( + context.repoPath, + context.connectionId, + ...hostedReviewExecutionArgs(context) + ) + if (slug) { + return slug + } + // Why: GHES remotes live on a custom host, so github.com-only slug parsing + // misses them and detection would otherwise fall through to Gitea (#8312). + // Claim the repo when gh is authenticated to its host — the same signal + // GitLab uses for self-hosted instances. + return getEnterpriseGitHubRepoSlug( + context.repoPath, + context.connectionId, + ...hostedReviewExecutionArgs(context) + ) + }, async getReviewForBranch(input) { const fallbackReviewNumber = input.linkedReviewNumber == null ? (input.fallbackReviewNumber ?? null) : null diff --git a/src/main/source-control/hosted-review-creation-eligibility.test.ts b/src/main/source-control/hosted-review-creation-eligibility.test.ts new file mode 100644 index 00000000000..010d950d50f --- /dev/null +++ b/src/main/source-control/hosted-review-creation-eligibility.test.ts @@ -0,0 +1,529 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + createGitHubPullRequestMock, + createGitLabMergeRequestMock, + createAzureDevOpsPullRequestMock, + createGiteaPullRequestMock, + isAzureDevOpsReviewCreationAuthenticatedMock, + isGiteaReviewCreationAuthenticatedMock, + getRepoSlugMock, + getProjectSlugMock, + getBitbucketRepoSlugMock, + getAzureDevOpsRepoSlugMock, + getGiteaRepoSlugMock, + getHostedReviewForBranchMock, + ghExecFileAsyncMock, + glabExecFileAsyncMock, + gitExecFileAsyncMock, + getUpstreamStatusMock, + getSshGitProviderMock, + getEnterpriseGitHubRepoSlugMock +} = vi.hoisted(() => ({ + createGitHubPullRequestMock: vi.fn(), + createGitLabMergeRequestMock: vi.fn(), + createAzureDevOpsPullRequestMock: vi.fn(), + createGiteaPullRequestMock: vi.fn(), + isAzureDevOpsReviewCreationAuthenticatedMock: vi.fn(), + isGiteaReviewCreationAuthenticatedMock: vi.fn(), + getRepoSlugMock: vi.fn(), + getProjectSlugMock: vi.fn(), + getBitbucketRepoSlugMock: vi.fn(), + getAzureDevOpsRepoSlugMock: vi.fn(), + getGiteaRepoSlugMock: vi.fn(), + getHostedReviewForBranchMock: vi.fn(), + ghExecFileAsyncMock: vi.fn(), + glabExecFileAsyncMock: vi.fn(), + gitExecFileAsyncMock: vi.fn(), + getUpstreamStatusMock: vi.fn(), + getSshGitProviderMock: vi.fn(), + getEnterpriseGitHubRepoSlugMock: vi.fn() +})) + +vi.mock('../github/client', () => ({ + createGitHubPullRequest: createGitHubPullRequestMock, + getRepoSlug: getRepoSlugMock, + getPRForBranch: vi.fn() +})) + +vi.mock('../github/github-enterprise-repository', () => ({ + getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock +})) + +vi.mock('../gitlab/client', () => ({ + getProjectSlug: getProjectSlugMock, + getMergeRequestForBranch: vi.fn(), + getMergeRequest: vi.fn() +})) + +vi.mock('../gitlab/merge-request-creation', () => ({ + createGitLabMergeRequest: createGitLabMergeRequestMock +})) + +vi.mock('../bitbucket/client', () => ({ + getBitbucketRepoSlug: getBitbucketRepoSlugMock, + getBitbucketPullRequestForBranch: vi.fn(), + getBitbucketPullRequest: vi.fn() +})) + +vi.mock('../azure-devops/client', () => ({ + getAzureDevOpsRepoSlug: getAzureDevOpsRepoSlugMock, + getAzureDevOpsPullRequestForBranch: vi.fn(), + getAzureDevOpsPullRequest: vi.fn() +})) + +vi.mock('../azure-devops/pull-request-creation', () => ({ + createAzureDevOpsPullRequest: createAzureDevOpsPullRequestMock, + isAzureDevOpsReviewCreationAuthenticated: isAzureDevOpsReviewCreationAuthenticatedMock +})) + +vi.mock('../gitea/client', () => ({ + getGiteaRepoSlug: getGiteaRepoSlugMock, + getGiteaPullRequestForBranch: vi.fn(), + getGiteaPullRequest: vi.fn() +})) + +vi.mock('../gitea/pull-request-creation', () => ({ + createGiteaPullRequest: createGiteaPullRequestMock, + isGiteaReviewCreationAuthenticated: isGiteaReviewCreationAuthenticatedMock +})) + +vi.mock('../github/gh-utils', () => ({ + acquire: vi.fn(), + release: vi.fn(), + ghExecFileAsync: ghExecFileAsyncMock, + gitExecFileAsync: gitExecFileAsyncMock +})) + +vi.mock('../gitlab/gl-utils', () => ({ + acquire: vi.fn(), + release: vi.fn(), + glabExecFileAsync: glabExecFileAsyncMock, + glabRepoExecOptions: (repoPath: string, connectionId?: string | null) => + connectionId ? {} : { cwd: repoPath } +})) + +vi.mock('../git/upstream', () => ({ + getUpstreamStatus: getUpstreamStatusMock +})) + +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock +})) + +vi.mock('./hosted-review', () => ({ + getHostedReviewForBranch: getHostedReviewForBranchMock +})) + +import { getHostedReviewCreationEligibility } from './hosted-review-creation' + +function resetMocks(): void { + for (const mock of [ + createGitHubPullRequestMock, + createGitLabMergeRequestMock, + createAzureDevOpsPullRequestMock, + createGiteaPullRequestMock, + isAzureDevOpsReviewCreationAuthenticatedMock, + isGiteaReviewCreationAuthenticatedMock, + getRepoSlugMock, + getProjectSlugMock, + getBitbucketRepoSlugMock, + getAzureDevOpsRepoSlugMock, + getGiteaRepoSlugMock, + getHostedReviewForBranchMock, + ghExecFileAsyncMock, + glabExecFileAsyncMock, + gitExecFileAsyncMock, + getUpstreamStatusMock, + getSshGitProviderMock, + getEnterpriseGitHubRepoSlugMock + ]) { + mock.mockReset() + } +} + +function mockGitHubProvider(): void { + getProjectSlugMock.mockResolvedValue(null) + getRepoSlugMock.mockResolvedValue({ owner: 'acme', repo: 'orca' }) + getBitbucketRepoSlugMock.mockResolvedValue(null) + getAzureDevOpsRepoSlugMock.mockResolvedValue(null) + getGiteaRepoSlugMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) +} + +// GHES: github.com-only slug parsing misses the custom host, so the enterprise +// resolver claims the repo and reports the host for the gh auth probe (#8312). +function mockGitHubEnterpriseProvider(): void { + getProjectSlugMock.mockResolvedValue(null) + getRepoSlugMock.mockResolvedValue(null) + getBitbucketRepoSlugMock.mockResolvedValue(null) + getAzureDevOpsRepoSlugMock.mockResolvedValue(null) + getGiteaRepoSlugMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue({ + owner: 'acme', + repo: 'orca', + host: 'github.acme-corp.com' + }) +} + +function mockGitLabProvider(): void { + getProjectSlugMock.mockResolvedValue({ host: 'gitlab.com', path: 'acme/orca' }) + getRepoSlugMock.mockResolvedValue(null) + getBitbucketRepoSlugMock.mockResolvedValue(null) + getAzureDevOpsRepoSlugMock.mockResolvedValue(null) + getGiteaRepoSlugMock.mockResolvedValue(null) +} + +function mockAzureDevOpsProvider(): void { + getProjectSlugMock.mockResolvedValue(null) + getRepoSlugMock.mockResolvedValue(null) + getBitbucketRepoSlugMock.mockResolvedValue(null) + getAzureDevOpsRepoSlugMock.mockResolvedValue({ + host: 'dev.azure.com', + project: 'Project', + repository: 'orca', + apiBaseUrl: 'https://dev.azure.com/acme/Project', + webBaseUrl: 'https://dev.azure.com/acme/Project/_git/orca' + }) + getGiteaRepoSlugMock.mockResolvedValue(null) +} + +function mockGiteaProvider(): void { + getProjectSlugMock.mockResolvedValue(null) + getRepoSlugMock.mockResolvedValue(null) + getBitbucketRepoSlugMock.mockResolvedValue(null) + getAzureDevOpsRepoSlugMock.mockResolvedValue(null) + getGiteaRepoSlugMock.mockResolvedValue({ + host: 'git.example.com', + owner: 'acme', + repo: 'orca', + apiBaseUrl: 'https://git.example.com/api/v1', + webBaseUrl: 'https://git.example.com' + }) +} + +describe('getHostedReviewCreationEligibility', () => { + beforeEach(() => { + resetMocks() + + mockGitHubProvider() + getHostedReviewForBranchMock.mockResolvedValue(null) + ghExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'Feature title\n', stderr: '' }) + isAzureDevOpsReviewCreationAuthenticatedMock.mockReturnValue(true) + isGiteaReviewCreationAuthenticatedMock.mockReturnValue(true) + }) + + it('treats short remote base refs as the default branch name', async () => { + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'main', + base: 'origin/main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + canCreate: false, + blockedReason: 'default_branch', + defaultBaseRef: 'origin/main' + }) + }) + + // Stacked-worktree base resolution (Change 1/2). `stackedArgs` defaults to a + // bare local-only parent; `mockRefs` controls the remote-tracking snapshot. + const stackedArgs = ( + overrides: Partial[0]> = {} + ): Parameters[0] => ({ + repoPath: '/repo', + branch: 'feature/stacked', + base: 'stacked-parent', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0, + ...overrides + }) + + const mockRefs = (opts: { + symbolicRef?: string + forEachRef?: string + forEachThrows?: boolean + revParseThrows?: boolean + }): void => { + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'symbolic-ref') { + return { stdout: opts.symbolicRef ?? '', stderr: '' } + } + if (args[0] === 'for-each-ref') { + if (opts.forEachThrows) { + throw new Error('ssh: connect: connection refused') + } + return { stdout: opts.forEachRef ?? '', stderr: '' } + } + if (args[0] === 'rev-parse' && opts.revParseThrows) { + throw new Error('unknown revision') + } + return { stdout: 'refs/remotes/origin/main\n', stderr: '' } + }) + } + + it('falls back to the repo default when a stacked parent base is local-only', async () => { + mockRefs({ symbolicRef: 'refs/remotes/origin/main\n' }) + await expect(getHostedReviewCreationEligibility(stackedArgs())).resolves.toMatchObject({ + canCreate: true, + blockedReason: null, + defaultBaseRef: 'origin/main' + }) + }) + + it('preserves a stacked parent base that exists on the remote', async () => { + mockRefs({ forEachRef: 'refs/remotes/origin/parent-pushed\n' }) + await expect( + getHostedReviewCreationEligibility(stackedArgs({ base: 'parent-pushed' })) + ).resolves.toMatchObject({ + canCreate: true, + blockedReason: null, + defaultBaseRef: 'parent-pushed' + }) + }) + + it('keeps the candidate base when no repo default can be resolved', async () => { + mockRefs({ revParseThrows: true }) + await expect(getHostedReviewCreationEligibility(stackedArgs())).resolves.toMatchObject({ + canCreate: true, + blockedReason: null, + defaultBaseRef: 'stacked-parent' + }) + }) + + it('preserves the candidate base when the remote probe cannot reach the host', async () => { + // Transport failure must not be read as "absent" — that would demote a + // legitimately-pushed parent to the repo default on a transient SSH blip. + mockRefs({ forEachThrows: true }) + await expect( + getHostedReviewCreationEligibility(stackedArgs({ base: 'parent-pushed' })) + ).resolves.toMatchObject({ canCreate: true, defaultBaseRef: 'parent-pushed' }) + }) + + it('blocks dirty tracked GitHub branches before PR creation', async () => { + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/create-pr', + base: 'main', + hasUncommittedChanges: true, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + provider: 'github', + canCreate: false, + blockedReason: 'dirty', + nextAction: 'commit', + head: 'feature/create-pr' + }) + }) + + it('keeps dirty feature branches eligible for PR preparation when review lookup fails', async () => { + getHostedReviewForBranchMock.mockRejectedValueOnce(new Error('gh lookup failed')) + + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/create-pr', + base: 'main', + hasUncommittedChanges: true, + hasUpstream: false, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + provider: 'github', + canCreate: false, + blockedReason: 'dirty', + nextAction: 'commit', + head: 'feature/create-pr' + }) + }) + + it('enables creation for clean, in-sync, authenticated GitHub feature branches', async () => { + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'refs/heads/feature/create-pr', + base: 'origin/main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + provider: 'github', + canCreate: true, + blockedReason: null, + nextAction: null, + defaultBaseRef: 'origin/main', + head: 'feature/create-pr' + }) + }) + + it('detects a GitHub Enterprise Server branch as the GitHub provider (#8312)', async () => { + mockGitHubEnterpriseProvider() + + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/create-pr', + base: 'origin/main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + provider: 'github', + canCreate: true, + blockedReason: null, + nextAction: null + }) + + // Enterprise auth was already confirmed during detection; the gate must not + // fire a redundant gh probe. + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('resolves remote eligibility through SSH repo metadata without generating PR copy', async () => { + const remoteGit = { + exec: vi.fn(async () => ({ stdout: '', stderr: '' })) + } + getSshGitProviderMock.mockReturnValue(remoteGit) + + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/remote/repo', + connectionId: 'ssh-1', + branch: 'feature/create-pr', + base: 'origin/main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + provider: 'github', + canCreate: true, + head: 'feature/create-pr' + }) + + expect(getProjectSlugMock).toHaveBeenCalledWith('/remote/repo', 'ssh-1') + expect(getRepoSlugMock).toHaveBeenCalledWith('/remote/repo', 'ssh-1') + expect(getHostedReviewForBranchMock).toHaveBeenCalledWith( + expect.objectContaining({ repoPath: '/remote/repo', connectionId: 'ssh-1' }) + ) + // Why: the base-on-remote probe must run on the SSH host that will execute + // the provider create, so it flows through the relay exec, not local git. + expect(remoteGit.exec).toHaveBeenCalledWith( + ['for-each-ref', '--count=1', '--format=%(refname)', 'refs/remotes/*/main'], + '/remote/repo' + ) + }) + + it('offers push as the next action for authenticated branches with local-only commits', async () => { + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/create-pr', + base: 'main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 2, + behind: 0 + }) + ).resolves.toMatchObject({ + canCreate: false, + blockedReason: 'needs_push', + nextAction: 'push' + }) + }) + + it('enables creation for clean, in-sync, authenticated GitLab feature branches', async () => { + mockGitLabProvider() + + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/gitlab', + base: 'main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + provider: 'gitlab', + canCreate: true, + blockedReason: null, + nextAction: null, + head: 'feature/gitlab' + }) + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + ['auth', 'status', '--hostname', 'gitlab.com'], + { cwd: '/repo' } + ) + }) + + it('enables creation for clean, in-sync, token-configured Azure DevOps feature branches', async () => { + mockAzureDevOpsProvider() + + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/azure', + base: 'main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + provider: 'azure-devops', + canCreate: true, + blockedReason: null, + nextAction: null, + head: 'feature/azure' + }) + expect(isAzureDevOpsReviewCreationAuthenticatedMock).toHaveBeenCalledOnce() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + expect(glabExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('enables creation for clean, in-sync, token-configured Gitea feature branches', async () => { + mockGiteaProvider() + + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/gitea', + base: 'main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + provider: 'gitea', + canCreate: true, + blockedReason: null, + nextAction: null, + head: 'feature/gitea' + }) + expect(isGiteaReviewCreationAuthenticatedMock).toHaveBeenCalledOnce() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + expect(glabExecFileAsyncMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/source-control/hosted-review-creation.test.ts b/src/main/source-control/hosted-review-creation.test.ts index c03447d4043..04c751d7c3b 100644 --- a/src/main/source-control/hosted-review-creation.test.ts +++ b/src/main/source-control/hosted-review-creation.test.ts @@ -17,7 +17,8 @@ const { glabExecFileAsyncMock, gitExecFileAsyncMock, getUpstreamStatusMock, - getSshGitProviderMock + getSshGitProviderMock, + getEnterpriseGitHubRepoSlugMock } = vi.hoisted(() => ({ createGitHubPullRequestMock: vi.fn(), createGitLabMergeRequestMock: vi.fn(), @@ -35,7 +36,8 @@ const { glabExecFileAsyncMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), getUpstreamStatusMock: vi.fn(), - getSshGitProviderMock: vi.fn() + getSshGitProviderMock: vi.fn(), + getEnterpriseGitHubRepoSlugMock: vi.fn() })) vi.mock('../github/client', () => ({ @@ -44,6 +46,10 @@ vi.mock('../github/client', () => ({ getPRForBranch: vi.fn() })) +vi.mock('../github/github-enterprise-repository', () => ({ + getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock +})) + vi.mock('../gitlab/client', () => ({ getProjectSlug: getProjectSlugMock, getMergeRequestForBranch: vi.fn(), @@ -109,7 +115,7 @@ vi.mock('./hosted-review', () => ({ getHostedReviewForBranch: getHostedReviewForBranchMock })) -import { createHostedReview, getHostedReviewCreationEligibility } from './hosted-review-creation' +import { createHostedReview } from './hosted-review-creation' function resetMocks(): void { for (const mock of [ @@ -129,7 +135,8 @@ function resetMocks(): void { glabExecFileAsyncMock, gitExecFileAsyncMock, getUpstreamStatusMock, - getSshGitProviderMock + getSshGitProviderMock, + getEnterpriseGitHubRepoSlugMock ]) { mock.mockReset() } @@ -141,6 +148,22 @@ function mockGitHubProvider(): void { getBitbucketRepoSlugMock.mockResolvedValue(null) getAzureDevOpsRepoSlugMock.mockResolvedValue(null) getGiteaRepoSlugMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) +} + +// GHES: github.com-only slug parsing misses the custom host, so the enterprise +// resolver claims the repo and reports the host for the gh auth probe (#8312). +function mockGitHubEnterpriseProvider(): void { + getProjectSlugMock.mockResolvedValue(null) + getRepoSlugMock.mockResolvedValue(null) + getBitbucketRepoSlugMock.mockResolvedValue(null) + getAzureDevOpsRepoSlugMock.mockResolvedValue(null) + getGiteaRepoSlugMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue({ + owner: 'acme', + repo: 'orca', + host: 'github.acme-corp.com' + }) } function mockGitLabProvider(): void { @@ -200,6 +223,11 @@ describe('createHostedReview', () => { if (args[0] === 'status') { return { stdout: '', stderr: '' } } + // Why: base-on-remote probe (Change 2 enforcement) — the default base + // resolves to a remote-tracking branch so create-time validation passes. + if (args[0] === 'for-each-ref') { + return { stdout: 'refs/remotes/origin/main\n', stderr: '' } + } if (args[0] === 'log' && args.includes('--pretty=%s')) { return { stdout: 'Feature title\n', stderr: '' } } @@ -278,6 +306,32 @@ describe('createHostedReview', () => { expect(createGitHubPullRequestMock).not.toHaveBeenCalled() }) + it('blocks creation with actionable copy when the submitted base is local-only', async () => { + // for-each-ref falls through to '' → the submitted stacked parent is not on + // the remote, so create-time enforcement blocks with actionable copy. + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'rev-parse') { + return { stdout: 'feature\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + + await expect( + createHostedReview('/repo', { + provider: 'github', + base: 'stacked-parent', + head: 'feature', + title: 'Feature' + }) + ).resolves.toEqual({ + ok: false, + code: 'validation', + error: + 'Create PR failed: the base branch "stacked-parent" hasn\'t been pushed to the remote. Choose a pushed base or push it first.' + }) + expect(createGitHubPullRequestMock).not.toHaveBeenCalled() + }) + it('creates the pull request after fresh main-process validation passes', async () => { await expect( createHostedReview('/repo', { @@ -349,6 +403,29 @@ describe('createHostedReview', () => { ) }) + it('creates a pull request on a GitHub Enterprise Server remote (#8312)', async () => { + mockGitHubEnterpriseProvider() + + await expect( + createHostedReview('/repo', { + provider: 'github', + base: 'main', + head: 'feature', + title: 'Feature' + }) + ).resolves.toEqual({ + ok: true, + number: 12, + url: 'https://github.com/acme/orca/pull/12' + }) + + // Detection already confirmed gh is authed to the GHES host, so the auth + // gate must not fire a second (rate-limited) gh probe. + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + expect(createGitHubPullRequestMock).toHaveBeenCalled() + expect(createGiteaPullRequestMock).not.toHaveBeenCalled() + }) + it('creates a GitLab merge request after fresh main-process validation passes', async () => { mockGitLabProvider() @@ -455,6 +532,10 @@ describe('createHostedReview', () => { if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref' && args[2] === 'HEAD') { return { stdout: 'feature\n', stderr: '' } } + if (args[0] === 'for-each-ref') { + // Base-on-remote probe (Change 2) runs on the SSH host; base is pushed. + return { stdout: 'refs/remotes/origin/main\n', stderr: '' } + } if (args[0] === 'log' && args.includes('--pretty=%s')) { return { stdout: 'Feature title\n', stderr: '' } } @@ -542,223 +623,3 @@ describe('createHostedReview', () => { expect(createGitHubPullRequestMock).not.toHaveBeenCalled() }) }) - -describe('getHostedReviewCreationEligibility', () => { - beforeEach(() => { - resetMocks() - - mockGitHubProvider() - getHostedReviewForBranchMock.mockResolvedValue(null) - ghExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) - gitExecFileAsyncMock.mockResolvedValue({ stdout: 'Feature title\n', stderr: '' }) - isAzureDevOpsReviewCreationAuthenticatedMock.mockReturnValue(true) - isGiteaReviewCreationAuthenticatedMock.mockReturnValue(true) - }) - - it('treats short remote base refs as the default branch name', async () => { - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/repo', - branch: 'main', - base: 'origin/main', - hasUncommittedChanges: false, - hasUpstream: true, - ahead: 0, - behind: 0 - }) - ).resolves.toMatchObject({ - canCreate: false, - blockedReason: 'default_branch', - defaultBaseRef: 'origin/main' - }) - }) - - it('blocks dirty tracked GitHub branches before PR creation', async () => { - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/repo', - branch: 'feature/create-pr', - base: 'main', - hasUncommittedChanges: true, - hasUpstream: true, - ahead: 0, - behind: 0 - }) - ).resolves.toMatchObject({ - provider: 'github', - canCreate: false, - blockedReason: 'dirty', - nextAction: 'commit', - head: 'feature/create-pr' - }) - }) - - it('keeps dirty feature branches eligible for PR preparation when review lookup fails', async () => { - getHostedReviewForBranchMock.mockRejectedValueOnce(new Error('gh lookup failed')) - - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/repo', - branch: 'feature/create-pr', - base: 'main', - hasUncommittedChanges: true, - hasUpstream: false, - ahead: 0, - behind: 0 - }) - ).resolves.toMatchObject({ - provider: 'github', - canCreate: false, - blockedReason: 'dirty', - nextAction: 'commit', - head: 'feature/create-pr' - }) - }) - - it('enables creation for clean, in-sync, authenticated GitHub feature branches', async () => { - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/repo', - branch: 'refs/heads/feature/create-pr', - base: 'origin/main', - hasUncommittedChanges: false, - hasUpstream: true, - ahead: 0, - behind: 0 - }) - ).resolves.toMatchObject({ - provider: 'github', - canCreate: true, - blockedReason: null, - nextAction: null, - defaultBaseRef: 'origin/main', - head: 'feature/create-pr' - }) - }) - - it('resolves remote eligibility through SSH repo metadata without generating PR copy', async () => { - const remoteGit = { - exec: vi.fn(async () => ({ stdout: '', stderr: '' })) - } - getSshGitProviderMock.mockReturnValue(remoteGit) - - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/remote/repo', - connectionId: 'ssh-1', - branch: 'feature/create-pr', - base: 'origin/main', - hasUncommittedChanges: false, - hasUpstream: true, - ahead: 0, - behind: 0 - }) - ).resolves.toMatchObject({ - provider: 'github', - canCreate: true, - head: 'feature/create-pr' - }) - - expect(getProjectSlugMock).toHaveBeenCalledWith('/remote/repo', 'ssh-1') - expect(getRepoSlugMock).toHaveBeenCalledWith('/remote/repo', 'ssh-1') - expect(getHostedReviewForBranchMock).toHaveBeenCalledWith( - expect.objectContaining({ repoPath: '/remote/repo', connectionId: 'ssh-1' }) - ) - expect(remoteGit.exec).not.toHaveBeenCalled() - }) - - it('offers push as the next action for authenticated branches with local-only commits', async () => { - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/repo', - branch: 'feature/create-pr', - base: 'main', - hasUncommittedChanges: false, - hasUpstream: true, - ahead: 2, - behind: 0 - }) - ).resolves.toMatchObject({ - canCreate: false, - blockedReason: 'needs_push', - nextAction: 'push' - }) - }) - - it('enables creation for clean, in-sync, authenticated GitLab feature branches', async () => { - mockGitLabProvider() - - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/repo', - branch: 'feature/gitlab', - base: 'main', - hasUncommittedChanges: false, - hasUpstream: true, - ahead: 0, - behind: 0 - }) - ).resolves.toMatchObject({ - provider: 'gitlab', - canCreate: true, - blockedReason: null, - nextAction: null, - head: 'feature/gitlab' - }) - expect(ghExecFileAsyncMock).not.toHaveBeenCalled() - expect(glabExecFileAsyncMock).toHaveBeenCalledWith( - ['auth', 'status', '--hostname', 'gitlab.com'], - { cwd: '/repo' } - ) - }) - - it('enables creation for clean, in-sync, token-configured Azure DevOps feature branches', async () => { - mockAzureDevOpsProvider() - - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/repo', - branch: 'feature/azure', - base: 'main', - hasUncommittedChanges: false, - hasUpstream: true, - ahead: 0, - behind: 0 - }) - ).resolves.toMatchObject({ - provider: 'azure-devops', - canCreate: true, - blockedReason: null, - nextAction: null, - head: 'feature/azure' - }) - expect(isAzureDevOpsReviewCreationAuthenticatedMock).toHaveBeenCalledOnce() - expect(ghExecFileAsyncMock).not.toHaveBeenCalled() - expect(glabExecFileAsyncMock).not.toHaveBeenCalled() - }) - - it('enables creation for clean, in-sync, token-configured Gitea feature branches', async () => { - mockGiteaProvider() - - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/repo', - branch: 'feature/gitea', - base: 'main', - hasUncommittedChanges: false, - hasUpstream: true, - ahead: 0, - behind: 0 - }) - ).resolves.toMatchObject({ - provider: 'gitea', - canCreate: true, - blockedReason: null, - nextAction: null, - head: 'feature/gitea' - }) - expect(isGiteaReviewCreationAuthenticatedMock).toHaveBeenCalledOnce() - expect(ghExecFileAsyncMock).not.toHaveBeenCalled() - expect(glabExecFileAsyncMock).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/source-control/hosted-review-creation.ts b/src/main/source-control/hosted-review-creation.ts index 7b6709d2dd3..1e19f1d4fa8 100644 --- a/src/main/source-control/hosted-review-creation.ts +++ b/src/main/source-control/hosted-review-creation.ts @@ -18,6 +18,7 @@ import { } from '../../shared/hosted-review-creation-providers' import { isAzureDevOpsReviewCreationAuthenticated } from '../azure-devops/pull-request-creation' import { isGiteaReviewCreationAuthenticated } from '../gitea/pull-request-creation' +import { getEnterpriseGitHubRepoSlug } from '../github/github-enterprise-repository' import { acquire, ghExecFileAsync, gitExecFileAsync, release } from '../github/gh-utils' import { isNoUpstreamError, normalizeGitErrorMessage } from '../../shared/git-remote-error' import type { GitUpstreamStatus } from '../../shared/types' @@ -41,6 +42,10 @@ import { type HostedReviewCreationEligibilityInput = HostedReviewCreationEligibilityArgs & { connectionId?: string | null + // Why: only the create-time preflight enforces base-on-remote as a hard block; + // the renderer's eligibility probe passes the base as a candidate and relies on + // Change 1 to correct a local-only parent, so it must never set this. + enforceBaseOnRemote?: boolean } & HostedReviewExecutionOptions function stripRefPrefix(ref: string): string { @@ -59,6 +64,14 @@ async function isGitHubAuthenticated( connectionId?: string | null, options: HostedReviewExecutionOptions = {} ): Promise { + // Why: a GHES remote is only routed to the GitHub provider once detection has + // confirmed gh is authenticated to its enterprise host, so a non-null slug + // already means authenticated — skip a redundant, rate-limited gh probe. + // Reaching the github.com check below therefore means the remote is github.com + // (its own custom host would have resolved above) (#8312). + if (await getEnterpriseGitHubRepoSlug(repoPath, connectionId, options)) { + return true + } await acquire() try { await ghExecFileAsync( @@ -124,6 +137,52 @@ async function getDefaultBaseRef( ) } +/** + * Whether the candidate base resolves to a remote-tracking branch on the + * executing host. + * + * Why: a stacked worktree's `worktree.baseRef` is typically a bare parent + * branch name with no remote qualifier, so the probe must match the branch + * under *any* configured remote rather than assume `origin` — otherwise fork + * workflows (`upstream/main`) would be missed. Runs through + * `runGitForHostedReview` so it evaluates on the same host that will run the + * provider create (native/WSL/SSH/relay). This reads the local remote-tracking + * snapshot, not the live remote; see the design doc's Open Questions for the + * ls-remote/staleness tradeoff left as a follow-up. + */ +async function baseRefExistsOnRemote( + candidate: string, + repoPath: string, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + const base = normalizeHostedReviewBaseRef(candidate).trim() + if (!base) { + return false + } + const run = (argv: string[]): Promise<{ stdout: string }> => + runGitForHostedReview(repoPath, argv, connectionId, options) + + const patterns = [`refs/remotes/*/${base}`] + // Non-origin remote-qualified candidate (e.g. `fork/main`): the wildcard glob + // above only matches a branch literally named that because `*` does not cross `/`. + // Include the exact tracking ref directly. + if (base.includes('/')) { + patterns.push(`refs/remotes/${base}`) + } + + try { + // for-each-ref exits 0 whether or not the pattern matches, so a clean empty + // result is an authoritative "absent" while a thrown error is a transport + // failure. Never conflate the two: on an unreachable host, preserve the + // candidate rather than silently demoting a legitimately-pushed parent base. + const { stdout } = await run(['for-each-ref', '--count=1', '--format=%(refname)', ...patterns]) + return stdout.trim().length > 0 + } catch { + return true + } +} + async function getCurrentBranch( repoPath: string, connectionId?: string | null, @@ -246,9 +305,11 @@ async function isProviderAuthenticated( function blockedCreateResultForReason( reason: NonNullable, - provider: HostedReviewProvider + provider: HostedReviewProvider, + submittedBase?: string | null ): CreateHostedReviewResult | null { const copy = reviewCopy(provider) + const baseLabel = submittedBase?.trim() ? `"${submittedBase.trim()}" ` : '' const blockedCreateResultByReason = { auth_required: { ok: false, @@ -294,6 +355,11 @@ function blockedCreateResultForReason( ok: false, code: 'validation', error: `Create ${copy.shortLabel} failed: refresh source control status and try again.` + }, + base_not_on_remote: { + ok: false, + code: 'validation', + error: `Create ${copy.shortLabel} failed: the base branch ${baseLabel}hasn't been pushed to the remote. Choose a pushed base or push it first.` } } satisfies Partial< Record, CreateHostedReviewResult> @@ -302,7 +368,8 @@ function blockedCreateResultForReason( } function blockedEligibilityToCreateResult( - eligibility: HostedReviewCreationEligibility + eligibility: HostedReviewCreationEligibility, + submittedBase?: string | null ): CreateHostedReviewResult | null { if (eligibility.canCreate) { return null @@ -317,7 +384,11 @@ function blockedEligibilityToCreateResult( } } if (eligibility.blockedReason) { - return blockedCreateResultForReason(eligibility.blockedReason, eligibility.provider) + return blockedCreateResultForReason( + eligibility.blockedReason, + eligibility.provider, + submittedBase + ) } const copy = reviewCopy(eligibility.provider) return { @@ -349,20 +420,24 @@ async function validateCurrentBranchCanCreateReview( hasUncommittedChanges(repoPath, connectionId, options), getHostedReviewUpstreamStatus(repoPath, connectionId, options) ]) + const submittedBase = normalizeHostedReviewBaseRef(input.base) const eligibility = await getHostedReviewCreationEligibility({ repoPath, branch: requestedHead || currentBranch, - base: normalizeHostedReviewBaseRef(input.base), + base: submittedBase, hasUncommittedChanges: dirty, hasUpstream: upstreamStatus.hasUpstream, ahead: upstreamStatus.ahead, behind: upstreamStatus.behind, connectionId, + // Why: this is the last gate before the provider create, which targets the + // submitted base verbatim — enforce that the base exists on the remote here. + enforceBaseOnRemote: true, ...options }) // Why: renderer eligibility can be stale by submit time; the main process // is the last chance to avoid creating a PR from an out-of-date remote head. - return blockedEligibilityToCreateResult(eligibility) + return blockedEligibilityToCreateResult(eligibility, submittedBase) } catch (error) { console.warn('Hosted review creation preflight failed:', error) return { @@ -382,8 +457,22 @@ export async function getHostedReviewCreationEligibility( connectionId: args.connectionId, ...hostedReviewExecutionContext(args) }) - const defaultBaseRef = - args.base?.trim() || (await getDefaultBaseRef(args.repoPath, args.connectionId, args)) + // Why: an incoming base is only a *candidate* for the default merge target. A + // stacked worktree's parent base resolves on the remote only when it was + // actually pushed; a local-only parent must fall back to the repo default so + // the PR targets a ref the remote can resolve. Never regress to "no base" — + // keep the candidate if the repo default itself is unavailable. + const candidateBase = args.base?.trim() || null + const candidateBaseOnRemote = + candidateBase != null && + (await baseRefExistsOnRemote(candidateBase, args.repoPath, args.connectionId, args)) + let defaultBaseRef: string | null + if (candidateBase && candidateBaseOnRemote) { + defaultBaseRef = candidateBase + } else { + const repoDefaultBaseRef = await getDefaultBaseRef(args.repoPath, args.connectionId, args) + defaultBaseRef = repoDefaultBaseRef ?? candidateBase + } const baseBranch = defaultBaseRef ? normalizeHostedReviewBaseRef(defaultBaseRef) : null let review: Awaited> = null try { @@ -472,6 +561,20 @@ export async function getHostedReviewCreationEligibility( if ((args.ahead ?? 0) > 0) { return { ...baseResult, canCreate: false, blockedReason: 'needs_push', nextAction: 'push' } } + // Why: at create-time, `gh pr create` (and the other providers) target the + // submitted base verbatim — Change 1 only corrects the *default*, not a stale + // renderer's submitted value. Block a local-only submitted base here so it + // fails with actionable copy instead of the provider's opaque error. Only the + // create-time preflight enforces this; the renderer's eligibility probe leaves + // enforceBaseOnRemote unset so a local-only parent is silently auto-corrected. + if (args.enforceBaseOnRemote && candidateBase && !candidateBaseOnRemote) { + return { + ...baseResult, + canCreate: false, + blockedReason: 'base_not_on_remote', + nextAction: null + } + } return { ...baseResult, canCreate: Boolean(baseBranch), blockedReason: null, nextAction: null } } diff --git a/src/main/ssh/ssh-channel-multiplexer.test.ts b/src/main/ssh/ssh-channel-multiplexer.test.ts index 10123d56a25..16de2b94a46 100644 --- a/src/main/ssh/ssh-channel-multiplexer.test.ts +++ b/src/main/ssh/ssh-channel-multiplexer.test.ts @@ -305,6 +305,25 @@ describe('SshChannelMultiplexer', () => { expect(() => vi.advanceTimersByTime(5_000)).not.toThrow() expect(mux.isDisposed()).toBe(true) }) + + it('drives keepalive sends AND dead-link detection from a single interval', () => { + // The keepalive send and the liveness/timeout check were merged from two + // 5s intervals into one; there must be exactly one recurring timer. + expect(vi.getTimerCount()).toBe(1) + + // Send half: a keepalive is written on the tick. + const before = transport.written.length + vi.advanceTimersByTime(5_000) + expect(transport.written.length).toBeGreaterThan(before) + expect(transport.written.at(-1)![0]).toBe(MessageType.KeepAlive) + expect(vi.getTimerCount()).toBe(1) + + // Check half: with no inbound frames or acks, the same interval declares + // the link dead (no-data + oldest-unacked both exceed the 20s window). + expect(mux.isDisposed()).toBe(false) + vi.advanceTimersByTime(25_000) + expect(mux.isDisposed()).toBe(true) + }) }) describe('wake guard (timer pause across system sleep, #7773)', () => { diff --git a/src/main/ssh/ssh-channel-multiplexer.ts b/src/main/ssh/ssh-channel-multiplexer.ts index 8cb0a24f302..25e6e2c6f00 100644 --- a/src/main/ssh/ssh-channel-multiplexer.ts +++ b/src/main/ssh/ssh-channel-multiplexer.ts @@ -54,8 +54,7 @@ export class SshChannelMultiplexer { // generic notification listener that already serves fs.changed. private methodNotificationHandlers = new Map>() private disposeHandlers: ((reason: 'shutdown' | 'connection_lost') => void)[] = [] - private keepaliveTimer: ReturnType | null = null - private timeoutTimer: ReturnType | null = null + private connectionHealthTimer: ReturnType | null = null private disposed = false // Track the oldest unacked outgoing message timestamp @@ -88,8 +87,7 @@ export class SshChannelMultiplexer { if (this.disposed) { return } - this.startKeepalive() - this.startTimeoutCheck() + this.startConnectionHealthTimer() } onNotification(handler: NotificationHandler): () => void { @@ -276,13 +274,9 @@ export class SshChannelMultiplexer { } this.disposed = true - if (this.keepaliveTimer) { - clearInterval(this.keepaliveTimer) - this.keepaliveTimer = null - } - if (this.timeoutTimer) { - clearInterval(this.timeoutTimer) - this.timeoutTimer = null + if (this.connectionHealthTimer) { + clearInterval(this.connectionHealthTimer) + this.connectionHealthTimer = null } // Why: the renderer uses the error code to distinguish temporary disconnects @@ -488,15 +482,17 @@ export class SshChannelMultiplexer { } } - private startKeepalive(): void { - this.keepaliveTimer = setInterval(() => { - this.sendKeepAlive() - }, KEEPALIVE_SEND_MS) - } - - private startTimeoutCheck(): void { + // Why: one 5s interval owns BOTH the periodic keepalive send and the + // liveness/dead-link check. They used to be two separate 5s intervals that + // always fired back-to-back (keepalive created first); folding them into one + // tick — send first (as the keepalive timer did), then run the check (as the + // timeout timer did) — halves the per-connection timer count with identical + // behavior, including the #7773 wake-gap recovery below. + private startConnectionHealthTimer(): void { let lastTickAt = Date.now() - this.timeoutTimer = setInterval(() => { + this.connectionHealthTimer = setInterval(() => { + this.sendKeepAlive() + if (this.disposed) { return } diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts index 28bc9714db2..005d43c9ca8 100644 --- a/src/main/ssh/ssh-connection.test.ts +++ b/src/main/ssh/ssh-connection.test.ts @@ -133,7 +133,10 @@ vi.mock('./ssh-system-fallback', () => ({ getOrcaControlSocketPath: getOrcaControlSocketPathMock, spawnSystemSsh: spawnSystemSshMock, spawnSystemSshCommand: spawnSystemSshCommandMock, + downloadFileViaSystemSsh: vi.fn(), uploadDirectoryViaSystemSsh: vi.fn(), + uploadFileViaSystemSsh: vi.fn(), + writeBufferViaSystemSsh: vi.fn(), writeFileViaSystemSsh: vi.fn() })) @@ -152,7 +155,13 @@ import { type SshConnectionCallbacks } from './ssh-connection' import { resolveWithSshG, type SshResolvedConfig } from './ssh-config-parser' -import { uploadDirectoryViaSystemSsh, writeFileViaSystemSsh } from './ssh-system-fallback' +import { + downloadFileViaSystemSsh, + uploadDirectoryViaSystemSsh, + uploadFileViaSystemSsh, + writeBufferViaSystemSsh, + writeFileViaSystemSsh +} from './ssh-system-fallback' import { getRemoteHostPlatform } from './ssh-remote-platform' import type { SshTarget } from '../../shared/ssh-types' @@ -273,8 +282,14 @@ describe('SshConnection', () => { spawnSystemSshMock.mockImplementation(() => createSystemSshProcess()) spawnSystemSshCommandMock.mockReset() spawnSystemSshCommandMock.mockImplementation(() => createSystemCommandChannel()) + vi.mocked(downloadFileViaSystemSsh).mockReset() + vi.mocked(downloadFileViaSystemSsh).mockResolvedValue(undefined) vi.mocked(uploadDirectoryViaSystemSsh).mockReset() vi.mocked(uploadDirectoryViaSystemSsh).mockResolvedValue(undefined) + vi.mocked(uploadFileViaSystemSsh).mockReset() + vi.mocked(uploadFileViaSystemSsh).mockResolvedValue(undefined) + vi.mocked(writeBufferViaSystemSsh).mockReset() + vi.mocked(writeBufferViaSystemSsh).mockResolvedValue(undefined) vi.mocked(writeFileViaSystemSsh).mockReset() vi.mocked(writeFileViaSystemSsh).mockResolvedValue(undefined) vi.mocked(resolveWithSshG).mockReset() @@ -1057,6 +1072,18 @@ describe('SshConnection', () => { await conn.writeFile('C:/Users/me/.orca-remote/relay/.version', '0.1.0', { hostPlatform }) + await conn.writeBuffer('C:/Users/me/.orca-remote/relay/logo.png', Buffer.from('png'), { + hostPlatform, + exclusive: true + }) + await conn.downloadFile('C:/Users/me/.orca-remote/relay/logo.png', '/tmp/logo.png', { + hostPlatform + }) + const uploadSession = await conn.openFileUploadSession({ hostPlatform }) + await uploadSession.uploadFile('/tmp/logo.png', 'C:/Users/me/project/logo.png', { + exclusive: true + }) + uploadSession.close() expect(uploadDirectoryViaSystemSsh).toHaveBeenCalledWith( expect.objectContaining({ configHost: 'fdpass-host' }), @@ -1076,6 +1103,65 @@ describe('SshConnection', () => { resolvedConfig: expect.objectContaining({ proxyUseFdpass: true }) }) ) + expect(writeBufferViaSystemSsh).toHaveBeenCalledWith( + expect.objectContaining({ configHost: 'fdpass-host' }), + 'C:/Users/me/.orca-remote/relay/logo.png', + Buffer.from('png'), + expect.objectContaining({ + hostPlatform, + exclusive: true, + resolvedConfig: expect.objectContaining({ proxyUseFdpass: true }) + }) + ) + expect(downloadFileViaSystemSsh).toHaveBeenCalledWith( + expect.objectContaining({ configHost: 'fdpass-host' }), + 'C:/Users/me/.orca-remote/relay/logo.png', + '/tmp/logo.png', + expect.objectContaining({ + hostPlatform, + resolvedConfig: expect.objectContaining({ proxyUseFdpass: true }) + }) + ) + expect(uploadFileViaSystemSsh).toHaveBeenCalledWith( + expect.objectContaining({ configHost: 'fdpass-host' }), + '/tmp/logo.png', + 'C:/Users/me/project/logo.png', + expect.objectContaining({ + hostPlatform, + exclusive: true, + resolvedConfig: expect.objectContaining({ proxyUseFdpass: true }) + }) + ) + }) + + it('keeps an upload session cancelled after the connection disconnects', async () => { + const conn = new SshConnection( + createTarget({ proxyCommand: 'ssh -W %h:%p bastion.example.com' }), + createCallbacks() + ) + vi.mocked(uploadFileViaSystemSsh).mockImplementation( + async (_target, _localPath, _remotePath, options) => { + if (options?.signal?.aborted) { + const error = new Error('System SSH operation was cancelled') + error.name = 'AbortError' + throw error + } + } + ) + + await conn.connect() + const uploadSession = await conn.openFileUploadSession() + await conn.disconnect() + + await expect( + uploadSession.uploadFile('/tmp/late.txt', '/remote/late.txt') + ).rejects.toMatchObject({ name: 'AbortError' }) + expect(uploadFileViaSystemSsh).toHaveBeenCalledWith( + expect.anything(), + '/tmp/late.txt', + '/remote/late.txt', + expect.objectContaining({ signal: expect.objectContaining({ aborted: true }) }) + ) }) it('removes system SSH probe listeners after timeout', async () => { diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index eb2e54909c9..af74bdc712a 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -8,7 +8,10 @@ import { getOrcaControlSocketPath, spawnSystemSsh, spawnSystemSshCommand, + downloadFileViaSystemSsh, uploadDirectoryViaSystemSsh, + uploadFileViaSystemSsh, + writeBufferViaSystemSsh, writeFileViaSystemSsh, type SystemSshBuildArgsOptions, type SystemSshProcess @@ -35,6 +38,7 @@ import { type SshConnectionCallbacks } from './ssh-connection-utils' import type { RemoteHostPlatform } from './ssh-remote-platform' +import type { FileUploadSession } from '../providers/types' import { isSshSessionLimitError } from './ssh-session-limit-error' export type { SshConnectionCallbacks } from './ssh-connection-utils' @@ -347,6 +351,54 @@ export class SshConnection { }) } + async downloadFile( + remotePath: string, + localPath: string, + options?: SshRemoteFileOptions + ): Promise { + if (!this.useSystemSshTransport) { + const sftp = await this.sftp() + try { + const { fastGetViaSftp } = await import('../providers/ssh-filesystem-provider-sftp') + await fastGetViaSftp(sftp, remotePath, localPath) + } finally { + sftp.end() + } + return + } + await downloadFileViaSystemSsh(this.target, remotePath, localPath, { + signal: this.systemOperationAbortController.signal, + hostPlatform: options?.hostPlatform, + ...this.getSystemSshBuildArgsOptions() + }) + } + + async openFileUploadSession(options?: SshRemoteFileOptions): Promise { + if (!this.useSystemSshTransport) { + const sftp = await this.sftp() + const { uploadFile } = await import('./sftp-upload') + return { + uploadFile: (localPath, remotePath, uploadOptions) => + uploadFile(sftp, localPath, remotePath, uploadOptions), + close: () => sftp.end() + } + } + // Why: disconnect replaces the connection controller; an existing import + // session must stay bound to the signal and SSH config it opened with. + const signal = this.systemOperationAbortController.signal + const buildArgsOptions = this.getSystemSshBuildArgsOptions() + return { + uploadFile: (localPath, remotePath, uploadOptions) => + uploadFileViaSystemSsh(this.target, localPath, remotePath, { + signal, + hostPlatform: options?.hostPlatform, + exclusive: uploadOptions?.exclusive, + ...buildArgsOptions + }), + close: () => {} + } + } + async writeFile( remotePath: string, contents: string, @@ -401,6 +453,30 @@ export class SshConnection { }) } + async writeBuffer( + remotePath: string, + contents: Buffer, + options?: SshRemoteFileOptions & { append?: boolean; exclusive?: boolean } + ): Promise { + if (!this.useSystemSshTransport) { + const sftp = await this.sftp() + try { + const { uploadBuffer } = await import('./sftp-upload') + await uploadBuffer(sftp, contents, remotePath, options) + } finally { + sftp.end() + } + return + } + await writeBufferViaSystemSsh(this.target, remotePath, contents, { + signal: this.systemOperationAbortController.signal, + hostPlatform: options?.hostPlatform, + append: options?.append, + exclusive: options?.exclusive, + ...this.getSystemSshBuildArgsOptions() + }) + } + async connect(): Promise { if (this.disposed) { throw new Error('Connection disposed') diff --git a/src/main/ssh/ssh-relay-generation-reattach.test.ts b/src/main/ssh/ssh-relay-generation-reattach.test.ts new file mode 100644 index 00000000000..62d2caa0452 --- /dev/null +++ b/src/main/ssh/ssh-relay-generation-reattach.test.ts @@ -0,0 +1,364 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { SshRelaySession } from './ssh-relay-session' +import { Store } from '../persistence' +import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' + +vi.mock('electron', () => ({ + app: { getPath: () => process.cwd() }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length) + } +})) + +vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) + +vi.mock('./ssh-channel-multiplexer', () => ({ + SshChannelMultiplexer: class MockSshChannelMultiplexer { + notify = vi.fn() + request = vi.fn().mockResolvedValue([]) + onNotification = vi.fn().mockReturnValue(() => {}) + onRequest = vi.fn().mockReturnValue(() => {}) + onDispose = vi.fn().mockReturnValue(() => {}) + dispose = vi.fn() + isDisposed = vi.fn().mockReturnValue(false) + } +})) + +vi.mock('../providers/ssh-pty-provider', () => ({ + isSshPtyNotFoundError: (err: unknown) => + (err instanceof Error ? err.message : String(err)).includes('not found'), + isSshPtyIdentityMismatchError: (err: unknown) => + (err instanceof Error ? err.message : String(err)).includes('identity mismatch'), + SshPtyProvider: class MockSshPtyProvider { + onData = vi.fn().mockReturnValue(() => {}) + onReplay = vi.fn().mockReturnValue(() => {}) + onExit = vi.fn().mockReturnValue(() => {}) + dispose = vi.fn() + } +})) + +vi.mock('../providers/ssh-filesystem-provider', () => ({ + SshFilesystemProvider: class MockSshFilesystemProvider { + dispose = vi.fn() + } +})) + +vi.mock('../providers/ssh-git-provider', () => ({ + SshGitProvider: class MockSshGitProvider {} +})) + +vi.mock('../ipc/pty', () => ({ + registerSshPtyProvider: vi.fn(), + unregisterSshPtyProvider: vi.fn(), + getSshPtyProvider: vi.fn(), + getPtyIdsForConnection: vi.fn().mockReturnValue([]), + clearPtyOwnershipForConnection: vi.fn(), + clearProviderPtyState: vi.fn(), + deletePtyOwnership: vi.fn(), + hasPendingSshShutdown: vi.fn().mockReturnValue(false), + releasePendingSshShutdown: vi.fn(), + setPtyOwnership: vi.fn(), + answerStartupTerminalColorQueriesForPty: vi.fn((_id: string, data: string) => data) +})) + +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + registerSshFilesystemProvider: vi.fn(), + unregisterSshFilesystemProvider: vi.fn(), + getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }) +})) + +vi.mock('../providers/ssh-git-dispatch', () => ({ + registerSshGitProvider: vi.fn(), + unregisterSshGitProvider: vi.fn() +})) + +const { getSshPtyProvider, getPtyIdsForConnection, releasePendingSshShutdown, setPtyOwnership } = + await import('../ipc/pty') + +describe('SSH relay generation reattach', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDeploySuccess() + }) + + it('reattaches a durable lease only within the relay generation that minted it', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + const attachForReconnect = vi.fn().mockResolvedValue({}) + vi.mocked(getSshPtyProvider).mockReturnValue({ + attachForReconnect, + getRelayInstanceId: () => 'relay-generation', + dispose: vi.fn() + } as unknown as ReturnType) + vi.mocked(getPtyIdsForConnection).mockReturnValue([]) + vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([ + { + targetId: 'target-1', + ptyId: 'pty-live', + relayInstanceId: 'relay-generation', + state: 'detached' + }, + { targetId: 'target-1', ptyId: 'pty-expired', state: 'expired' } + ] as ReturnType) + + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + await session.establish(mockConn) + + expect(attachForReconnect).toHaveBeenCalledWith('pty-live') + expect(attachForReconnect).not.toHaveBeenCalledWith('pty-expired') + expect(setPtyOwnership).toHaveBeenCalledWith( + 'ssh:target-1@@relay-generation@@pty-live', + 'target-1' + ) + }) + + it('expires an older-generation lease before its recycled relay id can attach', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() + const attachForReconnect = vi.fn() + vi.mocked(getSshPtyProvider).mockReturnValue({ + attachForReconnect, + getRelayInstanceId: () => 'relay-current', + dispose: vi.fn() + } as unknown as ReturnType) + vi.mocked(getPtyIdsForConnection).mockReturnValue([]) + vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([ + { + targetId: 'target-1', + ptyId: 'pty-recycled', + relayInstanceId: 'relay-old', + state: 'detached' + } + ] as ReturnType) + + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + await session.establish(mockConn) + + expect(attachForReconnect).not.toHaveBeenCalled() + expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith( + 'target-1', + 'ssh:target-1@@relay-old@@pty-recycled', + 'expired' + ) + expect(releasePendingSshShutdown).toHaveBeenCalledWith('ssh:target-1@@relay-old@@pty-recycled') + expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:exit', { + id: 'ssh:target-1@@relay-old@@pty-recycled', + code: -1 + }) + }) + + it('migrates a legacy lease only after the current relay proves it can attach', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-legacy-ssh-lease-')) + const dataFile = join(dir, 'state.json') + const stores: Store[] = [] + try { + const seedStore = new Store({ dataFile }) + stores.push(seedStore) + seedStore.upsertSshRemotePtyLease({ + targetId: 'target-1', + ptyId: 'pty-legacy', + state: 'detached' + }) + seedStore.flush() + // Why: an upgraded profile predates the teardown journal entirely. The + // first migration must survive before the debounced full save runs. + rmSync(join(dir, 'orca-terminal-teardown-intents.json'), { force: true }) + const store = new Store({ dataFile }) + stores.push(store) + const synchronousFlush = vi.spyOn(store, 'flush') + const { mockConn, mockPortForward, getMainWindow } = createMockDeps() + const attachForReconnect = vi.fn().mockResolvedValue({}) + vi.mocked(getSshPtyProvider).mockReturnValue({ + attachForReconnect, + getRelayInstanceId: () => 'relay-current', + dispose: vi.fn() + } as unknown as ReturnType) + vi.mocked(getPtyIdsForConnection).mockReturnValue([]) + + const session = new SshRelaySession('target-1', getMainWindow, store, mockPortForward) + await session.establish(mockConn) + + expect(attachForReconnect).toHaveBeenCalledWith('pty-legacy') + expect(store.getSshRemotePtyLeases('target-1')).toEqual([ + expect.objectContaining({ + ptyId: 'pty-legacy', + relayInstanceId: 'relay-current', + state: 'attached', + lastAttachedAt: expect.any(Number) + }) + ]) + expect(synchronousFlush).not.toHaveBeenCalled() + const reloaded = new Store({ dataFile }) + stores.push(reloaded) + expect(reloaded.getSshRemotePtyLeases('target-1')[0]).toEqual( + expect.objectContaining({ relayInstanceId: 'relay-current', state: 'attached' }) + ) + } finally { + for (const store of stores) { + store.flush() + } + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('adopts many legacy leases with one linear index build and bounded records', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-ssh-lease-scale-')) + const dataFile = join(dir, 'state.json') + const store = new Store({ dataFile }) + const leaseCount = 50 + try { + for (let index = 0; index < leaseCount; index += 1) { + store.upsertSshRemotePtyLease({ + targetId: 'target-1', + ptyId: `pty-${index}`, + state: 'detached' + }) + } + const internals = store as unknown as { + state: { sshRemotePtyLeases: unknown[] } + } + const leases = internals.state.sshRemotePtyLeases + let indexedLeaseReads = 0 + internals.state.sshRemotePtyLeases = new Proxy(leases, { + get(target, property, receiver) { + if (typeof property === 'string' && /^\d+$/.test(property)) { + indexedLeaseReads += 1 + } + return Reflect.get(target, property, receiver) + } + }) + const synchronousFlush = vi.spyOn(store, 'flush') + + for (let index = 0; index < leaseCount; index += 1) { + expect( + store.migrateLegacySshRemotePtyLeaseGeneration( + 'target-1', + `pty-${index}`, + 'relay-current' + ) + ).toBe(true) + } + + expect(indexedLeaseReads).toBe(leaseCount) + expect(synchronousFlush).not.toHaveBeenCalled() + const records = readFileSync(join(dir, 'orca-terminal-teardown-intents.json'), 'utf-8') + .trim() + .split('\n') + .map((line) => JSON.parse(line) as { kind: string }) + const migrations = records.filter((record) => record.kind === 'ssh-migrate-generation') + expect(migrations).toHaveLength(leaseCount) + expect(Math.max(...migrations.map((record) => JSON.stringify(record).length))).toBeLessThan( + 1_024 + ) + } finally { + store.flush() + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('does not publish a stale attach over a replacement lease created in flight', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-ssh-lease-replacement-')) + const store = new Store({ dataFile: join(dir, 'state.json') }) + try { + store.upsertSshRemotePtyLease({ + targetId: 'target-1', + ptyId: 'pty-recycled', + tabId: 'tab-old', + state: 'detached' + }) + const { mockConn, mockPortForward, getMainWindow, mockWindow } = createMockDeps() + let resolveAttach!: (value: { replay: string }) => void + const attachForReconnect = vi.fn().mockReturnValue( + new Promise<{ replay: string }>((resolve) => { + resolveAttach = resolve + }) + ) + vi.mocked(getSshPtyProvider).mockReturnValue({ + attachForReconnect, + getRelayInstanceId: () => 'relay-current', + dispose: vi.fn() + } as unknown as ReturnType) + vi.mocked(getPtyIdsForConnection).mockReturnValue([]) + + const session = new SshRelaySession('target-1', getMainWindow, store, mockPortForward) + const establishing = session.establish(mockConn) + await vi.waitFor(() => + expect(attachForReconnect).toHaveBeenCalledWith('pty-recycled', { + tabId: 'tab-old' + }) + ) + store.upsertSshRemotePtyLease({ + targetId: 'target-1', + ptyId: 'ssh:target-1@@relay-current@@pty-recycled', + tabId: 'tab-new', + state: 'detached' + }) + resolveAttach({ replay: 'stale-output' }) + await establishing + + expect(store.getSshRemotePtyLeases('target-1')).toEqual([ + expect.objectContaining({ + ptyId: 'pty-recycled', + relayInstanceId: 'relay-current', + tabId: 'tab-new', + state: 'detached' + }) + ]) + expect(setPtyOwnership).not.toHaveBeenCalledWith( + 'ssh:target-1@@relay-current@@pty-recycled', + 'target-1' + ) + expect(mockWindow.webContents.send).not.toHaveBeenCalledWith('pty:replay', { + id: 'ssh:target-1@@relay-current@@pty-recycled', + data: 'stale-output' + }) + } finally { + store.flush() + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('expires a failed legacy identity without adopting the current relay generation', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-stale-ssh-lease-')) + const store = new Store({ dataFile: join(dir, 'state.json') }) + try { + store.upsertSshRemotePtyLease({ + targetId: 'target-1', + ptyId: 'pty-recycled', + state: 'detached' + }) + const { mockConn, mockPortForward, getMainWindow, mockWindow } = createMockDeps() + const attachForReconnect = vi + .fn() + .mockRejectedValue(new Error('PTY "pty-recycled" not found (identity mismatch)')) + vi.mocked(getSshPtyProvider).mockReturnValue({ + attachForReconnect, + getRelayInstanceId: () => 'relay-current', + dispose: vi.fn() + } as unknown as ReturnType) + vi.mocked(getPtyIdsForConnection).mockReturnValue([]) + + const session = new SshRelaySession('target-1', getMainWindow, store, mockPortForward) + await session.establish(mockConn) + + expect(store.getSshRemotePtyLeases('target-1')).toEqual([ + expect.objectContaining({ ptyId: 'pty-recycled', state: 'expired' }) + ]) + expect(store.getSshRemotePtyLeases('target-1')[0]?.relayInstanceId).toBeUndefined() + expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:exit', { + id: 'ssh:target-1@@pty-recycled', + code: -1 + }) + expect(setPtyOwnership).not.toHaveBeenCalledWith( + 'ssh:target-1@@relay-current@@pty-recycled', + 'target-1' + ) + } finally { + store.flush() + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/ssh/ssh-relay-session-terminal-error.test.ts b/src/main/ssh/ssh-relay-session-terminal-error.test.ts index 0fab723fbb0..7f0d0dee79c 100644 --- a/src/main/ssh/ssh-relay-session-terminal-error.test.ts +++ b/src/main/ssh/ssh-relay-session-terminal-error.test.ts @@ -58,6 +58,8 @@ vi.mock('../ipc/pty', () => ({ clearPtyOwnershipForConnection: vi.fn(), clearProviderPtyState: vi.fn(), deletePtyOwnership: vi.fn(), + hasPendingSshShutdown: vi.fn().mockReturnValue(false), + releasePendingSshShutdown: vi.fn(), setPtyOwnership: vi.fn(), answerStartupTerminalColorQueriesForPty: vi.fn((_id: string, data: string) => data) })) @@ -80,6 +82,7 @@ function createMockDeps(): { mockStore: Store mockPortForward: SshPortForwardManager getMainWindow: () => BrowserWindow | null + mockWindow: BrowserWindow } { const mockConn = {} as SshConnection const mockStore = { @@ -96,7 +99,7 @@ function createMockDeps(): { webContents: { send: vi.fn() } } as unknown as BrowserWindow const getMainWindow = vi.fn().mockReturnValue(mockWindow) as unknown as () => BrowserWindow | null - return { mockConn, mockStore, mockPortForward, getMainWindow } + return { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } } function mockDeploySuccess(): void { @@ -169,4 +172,55 @@ describe('SshRelaySession terminal relay error (RelayVersionMismatchError)', () expect(onTerminal).toHaveBeenCalledWith('target-1', mismatchErr) expect(session.getState()).toBe('idle') }) + + it('delivers PTY exit when durable lease persistence throws', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() + const runtime = { onPtyExit: vi.fn() } + vi.mocked(mockStore.markSshRemotePtyLease).mockImplementation(() => { + throw new Error('lease disk full') + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const session = new SshRelaySession( + 'target-1', + getMainWindow, + mockStore, + mockPortForward, + runtime as never + ) + await session.establish(mockConn) + const { registerSshPtyProvider } = await import('../ipc/pty') + const ptyProvider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as { + onExit: ReturnType + } + const onExit = ptyProvider.onExit.mock.calls[0]?.[0] as (payload: { + id: string + code: number + }) => void + + expect(() => onExit({ id: 'ssh-pty-1', code: 0 })).not.toThrow() + expect(runtime.onPtyExit).toHaveBeenCalledWith('ssh-pty-1', 0) + expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:exit', { + id: 'ssh-pty-1', + code: 0 + }) + }) + + it('leaves SSH exit persistence to an existing retry owner', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + const { hasPendingSshShutdown, registerSshPtyProvider } = await import('../ipc/pty') + vi.mocked(hasPendingSshShutdown).mockReturnValue(true) + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + await session.establish(mockConn) + const ptyProvider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as { + onExit: ReturnType + } + const onExit = ptyProvider.onExit.mock.calls[0]?.[0] as (payload: { + id: string + code: number + }) => void + + onExit({ id: 'ssh-pty-1', code: 0 }) + + expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index 82cbfaee42c..b74df517b87 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -73,6 +73,8 @@ vi.mock('../ipc/pty', () => ({ clearPtyOwnershipForConnection: vi.fn(), clearProviderPtyState: vi.fn(), deletePtyOwnership: vi.fn(), + hasPendingSshShutdown: vi.fn().mockReturnValue(false), + releasePendingSshShutdown: vi.fn(), setPtyOwnership: vi.fn(), answerStartupTerminalColorQueriesForPty: vi.fn((_id: string, data: string) => data) })) @@ -424,7 +426,7 @@ describe('SshRelaySession', () => { expect(registerSshPtyProvider).toHaveBeenCalledWith('target-1', expect.anything()) }) - it('installs a native Windows Orca CLI bridge without POSIX shell commands', async () => { + it('compiles a native Windows Orca CLI bridge without a cmd.exe shim', async () => { const { mockStore, mockPortForward, getMainWindow } = createMockDeps() const mockConn = { writeFile: vi.fn().mockResolvedValue(undefined) @@ -447,20 +449,20 @@ describe('SshRelaySession', () => { await session.establish(mockConn) - expect(execCommand).toHaveBeenCalledTimes(1) + expect(execCommand).toHaveBeenCalledTimes(2) expect(vi.mocked(execCommand).mock.calls[0]?.[1]).toContain('powershell.exe') expect(vi.mocked(execCommand).mock.calls[0]?.[2]).toEqual({ wrapCommand: false }) expect(mockConn.writeFile).toHaveBeenCalledWith( - 'C:/Users/me/.orca-relay/bin/orca.cmd', - expect.stringContaining('@echo off'), + 'C:/Users/me/.orca-relay/bin/orca-launcher.cs', + expect.stringContaining('ProcessStartInfo'), { hostPlatform: getRemoteHostPlatform('win32-x64') } ) - const shim = vi.mocked(mockConn.writeFile).mock.calls[0]?.[1] as string - expect(shim).toContain('C:/Users/me/.orca-remote/relay-v1') - expect(shim).toContain('\\\\.\\pipe\\orca-relay-123') - expect(shim).not.toContain('if not exist "%ORCA_RELAY_SOCKET_PATH%"') - expect(shim).not.toContain('Orca SSH CLI bridge cannot find the relay socket') - expect(shim).not.toContain('#!/usr/bin/env sh') + const launcherSource = vi.mocked(mockConn.writeFile).mock.calls[0]?.[1] as string + expect(launcherSource).toContain('ORCA_RELAY_SOCKET_PATH') + expect(launcherSource).not.toContain('cmd.exe') + expect(launcherSource).not.toContain('%*') + expect(vi.mocked(execCommand).mock.calls[1]?.[1]).toContain('powershell.exe') + expect(vi.mocked(execCommand).mock.calls[1]?.[2]).toEqual({ wrapCommand: false }) expect(vi.mocked(execCommand).mock.calls.some(([, command]) => command.includes('chmod'))).toBe( false ) @@ -553,31 +555,11 @@ describe('SshRelaySession', () => { expect(mockAttach).toHaveBeenCalledWith('pty-1') expect(setPtyOwnership).toHaveBeenCalledWith('ssh:target-1@@pty-1', 'target-1') - expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('target-1', 'pty-1', 'attached') - }) - - it('establish re-attaches durable leases after app restart', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const { getSshPtyProvider } = await import('../ipc/pty') - const mockAttach = vi.fn().mockResolvedValue(undefined) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: mockAttach, - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([ - { targetId: 'target-1', ptyId: 'pty-live', state: 'detached' }, - { targetId: 'target-1', ptyId: 'pty-expired', state: 'expired' } - ] as ReturnType) - - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await session.establish(mockConn) - - expect(mockAttach).toHaveBeenCalledWith('pty-live') - expect(mockAttach).not.toHaveBeenCalledWith('pty-expired') - expect(setPtyOwnership).toHaveBeenCalledWith('ssh:target-1@@pty-live', 'target-1') - expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('target-1', 'pty-live', 'attached') + expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith( + 'target-1', + 'ssh:target-1@@pty-1', + 'attached' + ) }) it('forwards a lease tab identity to reattach so a reset relay cannot cross-wire it', async () => { diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 5176d06411f..65fff208095 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -19,7 +19,7 @@ import { isSshPtyIdentityMismatchError, isSshPtyNotFoundError } from '../providers/ssh-pty-provider' -import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' +import { parseAppSshPtyId, toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider' import { SshGitProvider } from '../providers/ssh-git-provider' import { agentHookServer } from '../agent-hooks/server' @@ -41,6 +41,8 @@ import { clearPtyOwnershipForConnection, clearProviderPtyState, deletePtyOwnership, + hasPendingSshShutdown, + releasePendingSshShutdown, setPtyOwnership, answerStartupTerminalColorQueriesForPty } from '../ipc/pty' @@ -61,7 +63,8 @@ import { isMainWindowVisible, onMainWindowBecameVisible } from '../window/main-w import type { SshPortForwardManager } from './ssh-port-forward' import type { SshConnection } from './ssh-connection' import { joinRemotePath, isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' -import { makeRemoteDirectoryCommand, makeRemoteExecutableCommand } from './ssh-remote-commands' +import { makeRemoteDirectoryCommand } from './ssh-remote-commands' +import { createRemoteCliInstallPlan } from './ssh-remote-cli-launcher' import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, type DetectedPort, @@ -653,14 +656,14 @@ export class SshRelaySession { } try { - await this.installRemoteOrcaCliShim() + await this.installRemoteOrcaCliLauncher() } catch (error) { - // Why: the remote `orca` CLI shim is a convenience bridge. On session- + // Why: the remote `orca` CLI launcher is a convenience bridge. On session- // limited remotes (MaxSessions=1) the relay bridge holds the only slot, // so this raw-connection install can fail — that must not fail the // whole connection, matching the managed-hook install above. console.warn( - `[ssh-relay-session] remote orca CLI shim install failed for ${this.targetId}: ${ + `[ssh-relay-session] remote orca CLI launcher install failed for ${this.targetId}: ${ error instanceof Error ? error.message : String(error) }` ) @@ -671,11 +674,45 @@ export class SshRelaySession { this.wireUpRemoteOrcaCli(mux) - const ptyProvider = new SshPtyProvider(this.targetId, mux, this.remoteCliBridgeEnv ?? undefined) + let relayInstanceId: string | undefined + try { + const status = (await mux.request('relay.status', {})) as { instanceId?: unknown } + relayInstanceId = + typeof status.instanceId === 'string' && status.instanceId.length > 0 + ? status.instanceId + : undefined + } catch { + // Why: older relays lack boot identity. Attach/shutdown identity checks + // remain the compatibility guard until the versioned deploy upgrades it. + } + const ptyProvider = new SshPtyProvider( + this.targetId, + mux, + this.remoteCliBridgeEnv ?? undefined, + relayInstanceId + ) registerSshPtyProvider(this.targetId, ptyProvider) - const fsProvider = new SshFilesystemProvider(this.targetId, mux, () => - this.requireReadyConnection().sftp() + const fsProvider = new SshFilesystemProvider( + this.targetId, + mux, + () => this.requireReadyConnection().sftp(), + { + downloadFile: (sourcePath, destinationPath) => + this.requireReadyConnection().downloadFile(sourcePath, destinationPath, { + hostPlatform: this.remoteCliBridgeEnv?.hostPlatform + }), + openFileUploadSession: () => + this.requireReadyConnection().openFileUploadSession({ + hostPlatform: this.remoteCliBridgeEnv?.hostPlatform + }), + writeBuffer: (remotePath, contents, options) => + this.requireReadyConnection().writeBuffer(remotePath, contents, { + hostPlatform: this.remoteCliBridgeEnv?.hostPlatform, + append: options.append, + exclusive: options.exclusive + }) + } ) registerSshFilesystemProvider(this.targetId, fsProvider) @@ -757,34 +794,38 @@ export class SshRelaySession { } } - private async installRemoteOrcaCliShim(): Promise { + private async installRemoteOrcaCliLauncher(): Promise { if (!this.remoteCliBridgeEnv) { return } const { binDir, hostPlatform } = this.remoteCliBridgeEnv - const shim = buildRemoteCliShim(this.remoteCliBridgeEnv) + const plan = createRemoteCliInstallPlan(this.remoteCliBridgeEnv) const conn = this.requireReadyConnection() await execCommand(conn, makeRemoteDirectoryCommand(hostPlatform, binDir), { wrapCommand: !isWindowsRemoteHost(hostPlatform) }) if (typeof conn.writeFile === 'function') { - await conn.writeFile(shim.path, shim.contents, { hostPlatform }) + for (const file of plan.files) { + await conn.writeFile(file.path, file.contents, { hostPlatform }) + } } else { const sftp = await conn.sftp() try { - await new Promise((resolve, reject) => { - const ws = sftp.createWriteStream(shim.path) - sftp.once('error', reject) - ws.once('close', resolve) - ws.once('error', reject) - ws.end(shim.contents) - }) + for (const file of plan.files) { + await new Promise((resolve, reject) => { + const ws = sftp.createWriteStream(file.path) + sftp.once('error', reject) + ws.once('close', resolve) + ws.once('error', reject) + ws.end(file.contents) + }) + } } finally { sftp.end() } } - if (!isWindowsRemoteHost(hostPlatform)) { - await execCommand(conn, makeRemoteExecutableCommand(hostPlatform, shim.path)) + for (const command of plan.postWriteCommands) { + await execCommand(conn, command, { wrapCommand: !isWindowsRemoteHost(hostPlatform) }) } } @@ -1125,11 +1166,18 @@ export class SshRelaySession { } }) ptyProvider.onExit((payload) => { - const relayPtyId = toRelaySshPtyId(this.targetId, payload.id) clearProviderPtyState(payload.id) deletePtyOwnership(payload.id) this.forwardedReattachReplayByPty.delete(payload.id) - this.store.markSshRemotePtyLease(this.targetId, relayPtyId, 'terminated') + if (!hasPendingSshShutdown(payload.id)) { + try { + this.store.markSshRemotePtyLease(this.targetId, payload.id, 'terminated') + } catch (error) { + // Why: disk failure must not abort PTY exit delivery; a retained kill + // owner, when present, retries the durable lease transition separately. + console.warn('[ssh-relay-session] Failed to persist PTY exit:', error) + } + } this.runtime?.onPtyExit(payload.id, payload.code) const win = this.getMainWindow() if (win && !win.isDestroyed()) { @@ -1166,36 +1214,69 @@ export class SshRelaySession { } } + private expireReattachPty(_relayPtyId: string, appPtyId: string): void { + releasePendingSshShutdown(appPtyId) + clearProviderPtyState(appPtyId) + deletePtyOwnership(appPtyId) + this.forwardedReattachReplayByPty.delete(appPtyId) + this.store.markSshRemotePtyLease(this.targetId, appPtyId, 'expired') + const win = this.getMainWindow() + if (win && !win.isDestroyed()) { + win.webContents.send('pty:exit', { id: appPtyId, code: -1 }) + } + } + private async reattachKnownPtys(shouldContinue: () => boolean): Promise { + const ptyProvider = getSshPtyProvider(this.targetId) as SshPtyProvider | undefined + if (!ptyProvider) { + return + } + const relayInstanceId = ptyProvider.getRelayInstanceId?.() const activeLeases = this.store .getSshRemotePtyLeases(this.targetId) .filter((lease) => lease.state !== 'terminated' && lease.state !== 'expired') - const leasedPtyIds = activeLeases.map((lease) => lease.ptyId) + const attachableLeases = activeLeases.filter((lease) => { + if (!lease.relayInstanceId || !relayInstanceId || lease.relayInstanceId === relayInstanceId) { + return true + } + const staleAppPtyId = toAppSshPtyId(this.targetId, lease.ptyId, lease.relayInstanceId) + console.warn( + `[ssh-relay-session] Expiring stale PTY ${lease.ptyId} for ${this.targetId} after relay generation changed` + ) + this.expireReattachPty(lease.ptyId, staleAppPtyId) + return false + }) + const leasedPtyIds = attachableLeases.map((lease) => lease.ptyId) // Why: carry each lease's pane identity into the attach so the relay can // reject cross-generation id collisions even between split panes in one tab. // Older leases may lack leafId, so tabId remains the compatibility fallback. const expectedIdentityByPtyId = new Map( - activeLeases + attachableLeases .map((lease): [string, ExpectedPtyIdentity] | null => { const expected = expectedIdentityForLease(lease) return expected ? [lease.ptyId, expected] : null }) .filter((entry): entry is [string, ExpectedPtyIdentity] => entry !== null) ) + const leaseByPtyId = new Map(attachableLeases.map((lease) => [lease.ptyId, lease])) // Why: after app restart, ptyOwnership is empty but durable SSH leases // still describe remote PTYs that survived in the relay grace window. - const ptyIds = Array.from( - new Set([ - ...getPtyIdsForConnection(this.targetId).map((ptyId) => - toRelaySshPtyId(this.targetId, ptyId) - ), - ...leasedPtyIds - ]) - ) - const ptyProvider = getSshPtyProvider(this.targetId) as SshPtyProvider | undefined - if (!ptyProvider) { - return - } + const ownedPtyIds = getPtyIdsForConnection(this.targetId).flatMap((appPtyId) => { + const parsed = parseAppSshPtyId(appPtyId) + if ( + parsed?.relayInstanceId && + relayInstanceId && + parsed.relayInstanceId !== relayInstanceId + ) { + console.warn( + `[ssh-relay-session] Expiring stale PTY ${parsed.relayPtyId} for ${this.targetId} after relay generation changed` + ) + this.expireReattachPty(parsed.relayPtyId, appPtyId) + return [] + } + return [toRelaySshPtyId(this.targetId, appPtyId)] + }) + const ptyIds = Array.from(new Set([...ownedPtyIds, ...leasedPtyIds])) for (const ptyId of ptyIds) { if (!shouldContinue()) { return @@ -1209,16 +1290,36 @@ export class SshRelaySession { if (!shouldContinue()) { return } - const appPtyId = toAppSshPtyId(this.targetId, ptyId) + const appPtyId = toAppSshPtyId(this.targetId, ptyId, relayInstanceId) + const lease = leaseByPtyId.get(ptyId) + if (lease && !lease.relayInstanceId && relayInstanceId) { + // Why: only a proven attach can bind a pre-generation lease to the + // relay boot that actually owns it; strict normal writes stay fenced. + const migrated = this.store.migrateLegacySshRemotePtyLeaseGeneration( + this.targetId, + ptyId, + relayInstanceId + ) + if (!migrated) { + continue + } + } else { + this.store.markSshRemotePtyLease(this.targetId, appPtyId, 'attached') + } setPtyOwnership(appPtyId, this.targetId) - this.store.markSshRemotePtyLease(this.targetId, ptyId, 'attached') this.forwardReattachReplay(appPtyId, attachResult.replay ?? '') } catch (err) { if (!isSshPtyNotFoundError(err)) { throw err } - const appPtyId = toAppSshPtyId(this.targetId, ptyId) - if (isSshPtyIdentityMismatchError(err)) { + const lease = leaseByPtyId.get(ptyId) + const isLegacyLease = Boolean(lease && !lease.relayInstanceId && relayInstanceId) + const appPtyId = toAppSshPtyId( + this.targetId, + ptyId, + isLegacyLease ? undefined : relayInstanceId + ) + if (isSshPtyIdentityMismatchError(err) && !isLegacyLease) { console.warn( `[ssh-relay-session] Ignoring stale PTY ${ptyId} for ${this.targetId} after relay identity mismatch: ${ err instanceof Error ? err.message : String(err) @@ -1231,62 +1332,10 @@ export class SshRelaySession { err instanceof Error ? err.message : String(err) }` ) - clearProviderPtyState(appPtyId) - deletePtyOwnership(appPtyId) - this.forwardedReattachReplayByPty.delete(appPtyId) - this.store.markSshRemotePtyLease(this.targetId, ptyId, 'expired') - // Why: if the new relay cannot reattach this id, the remote backing - // process is gone. Tell the renderer so it clears stale pane bindings - // instead of keeping a cursor-only terminal. - const win = this.getMainWindow() - if (win && !win.isDestroyed()) { - win.webContents.send('pty:exit', { id: appPtyId, code: -1 }) - } + // Why: a failed legacy attach has not earned the new generation. Retire + // and notify its original id so a recycled current relay id stays intact. + this.expireReattachPty(ptyId, appPtyId) } } } } - -function quoteSh(value: string): string { - return `'${value.replaceAll("'", `'\\''`)}'` -} - -function buildRemoteCliShim(env: RemoteCliBridgeEnv): { - path: string - contents: string -} { - if (isWindowsRemoteHost(env.hostPlatform)) { - const shimPath = joinRemotePath(env.hostPlatform, env.binDir, 'orca.cmd') - return { - path: shimPath, - contents: [ - '@echo off', - 'setlocal', - `if not defined ORCA_RELAY_NODE_PATH set "ORCA_RELAY_NODE_PATH=${env.nodePath}"`, - `if not defined ORCA_RELAY_DIR set "ORCA_RELAY_DIR=${env.relayDir}"`, - `if not defined ORCA_RELAY_SOCKET_PATH set "ORCA_RELAY_SOCKET_PATH=${env.sockPath}"`, - '"%ORCA_RELAY_NODE_PATH%" "%ORCA_RELAY_DIR%/relay.js" --sock-path "%ORCA_RELAY_SOCKET_PATH%" --orca-cli %*', - 'exit /b %ERRORLEVEL%', - '' - ].join('\r\n') - } - } - - const shimPath = joinRemotePath(env.hostPlatform, env.binDir, 'orca') - return { - path: shimPath, - contents: [ - '#!/usr/bin/env sh', - 'set -eu', - `ORCA_RELAY_NODE_PATH=\${ORCA_RELAY_NODE_PATH:-${quoteSh(env.nodePath)}}`, - `ORCA_RELAY_DIR=\${ORCA_RELAY_DIR:-${quoteSh(env.relayDir)}}`, - `ORCA_RELAY_SOCKET_PATH=\${ORCA_RELAY_SOCKET_PATH:-${quoteSh(env.sockPath)}}`, - 'if [ ! -S "$ORCA_RELAY_SOCKET_PATH" ]; then', - ' echo "Orca SSH CLI bridge cannot find the relay socket: $ORCA_RELAY_SOCKET_PATH" >&2', - ' exit 1', - 'fi', - 'exec "$ORCA_RELAY_NODE_PATH" "$ORCA_RELAY_DIR/relay.js" --sock-path "$ORCA_RELAY_SOCKET_PATH" --orca-cli "$@"', - '' - ].join('\n') - } -} diff --git a/src/main/ssh/ssh-relay-versioned-install.test.ts b/src/main/ssh/ssh-relay-versioned-install.test.ts index 8cc46e8b151..244ae80a48c 100644 --- a/src/main/ssh/ssh-relay-versioned-install.test.ts +++ b/src/main/ssh/ssh-relay-versioned-install.test.ts @@ -108,11 +108,12 @@ describe('isRelayAlreadyInstalled', () => { ).rejects.toBe(sessionLimitError) }) - it('checks for relay.js AND .install-complete in addition to the dir', async () => { + it('checks for both relay process artifacts and .install-complete', async () => { mockExec.mockResolvedValueOnce('OK') await isRelayAlreadyInstalled(conn, '/r') const cmd = mockExec.mock.calls.at(-1)?.[1] ?? '' expect(cmd).toContain('relay.js') + expect(cmd).toContain('relay-watcher.js') expect(cmd).toContain('.install-complete') }) }) diff --git a/src/main/ssh/ssh-relay-versioned-install.ts b/src/main/ssh/ssh-relay-versioned-install.ts index 5ebac3a07fc..a669f0b7875 100644 --- a/src/main/ssh/ssh-relay-versioned-install.ts +++ b/src/main/ssh/ssh-relay-versioned-install.ts @@ -126,10 +126,9 @@ export function computeRemoteRelayDir( /** * Probe whether a fully-installed relay already exists at remoteRelayDir. * - * "Fully installed" means: the directory exists, contains relay.js, AND - * contains the .install-complete sentinel written at the end of a successful - * install. A directory missing .install-complete is either mid-install (lock - * held) or a crashed-install partial — either way we re-run the deploy. + * "Fully installed" means: the directory contains relay.js, its isolated + * relay-watcher.js child, and the .install-complete sentinel written at the + * end of a successful install. Missing artifacts force a complete re-deploy. */ export async function isRelayAlreadyInstalled( conn: SshConnection, diff --git a/src/main/ssh/ssh-remote-linear-argument-error.ts b/src/main/ssh/ssh-remote-cli-argument-error.ts similarity index 100% rename from src/main/ssh/ssh-remote-linear-argument-error.ts rename to src/main/ssh/ssh-remote-cli-argument-error.ts diff --git a/src/main/ssh/ssh-remote-cli-format.ts b/src/main/ssh/ssh-remote-cli-format.ts index c654af79b74..744c9f8e6cf 100644 --- a/src/main/ssh/ssh-remote-cli-format.ts +++ b/src/main/ssh/ssh-remote-cli-format.ts @@ -34,6 +34,7 @@ function formatStatusResult(status: CliStatusResult): { stdout: string; stderr: stdout: `${[ `appRunning: ${status.app.running}`, `pid: ${status.app.pid ?? 'none'}`, + `desktopWindowStatus: ${status.app.desktopWindowStatus ?? 'unknown'}`, `runtimeState: ${status.runtime.state}`, `runtimeReachable: ${status.runtime.reachable}`, `runtimeId: ${status.runtime.runtimeId ?? 'none'}`, diff --git a/src/main/ssh/ssh-remote-cli-launcher.test.ts b/src/main/ssh/ssh-remote-cli-launcher.test.ts new file mode 100644 index 00000000000..ae0a2b18619 --- /dev/null +++ b/src/main/ssh/ssh-remote-cli-launcher.test.ts @@ -0,0 +1,143 @@ +import { Buffer } from 'node:buffer' +import { spawnSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { createRemoteCliInstallPlan } from './ssh-remote-cli-launcher' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +const itWindows = process.platform === 'win32' ? it : it.skip + +function decodePowerShellCommand(command: string): string { + const encoded = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/)?.[1] + if (!encoded) { + throw new Error(`Expected an encoded PowerShell command: ${command}`) + } + return Buffer.from(encoded, 'base64').toString('utf16le') +} + +describe('SSH remote Orca CLI launcher', () => { + it('compiles a native Windows launcher without a cmd.exe argument bridge', () => { + const hostPlatform = getRemoteHostPlatform('win32-x64') + const plan = createRemoteCliInstallPlan({ + binDir: 'C:/Users/me user/.orca-relay/bin', + relayDir: 'C:/Users/me user/.orca-remote/relay-v1', + nodePath: 'C:/Program Files/nodejs/node.exe', + sockPath: '\\\\.\\pipe\\orca-relay-123', + hostPlatform + }) + + expect(plan.launcherPath).toBe('C:/Users/me user/.orca-relay/bin/orca.exe') + expect(plan.files).toHaveLength(1) + expect(plan.files[0]?.path).toBe('C:/Users/me user/.orca-relay/bin/orca-launcher.cs') + expect(plan.files[0]?.contents).toContain('ProcessStartInfo') + expect(plan.files[0]?.contents).toContain('"--orca-cli"') + expect(plan.files[0]?.contents).toContain("value[index] == '\"'") + expect(plan.files[0]?.contents).toContain("character == '\\\\'") + expect(plan.files[0]?.contents).not.toContain('cmd.exe') + expect(plan.files[0]?.contents).not.toContain('%*') + + expect(plan.postWriteCommands).toHaveLength(1) + const compileScript = decodePowerShellCommand(plan.postWriteCommands[0] ?? '') + expect(compileScript).toContain('v4.0.30319\\csc.exe') + // Why: legacy csc.exe is invoked from the bin directory with bare, space-free + // file names so PowerShell 5.1 never mangles a space-bearing absolute path. + expect(compileScript).toContain( + "Set-Location -ErrorAction Stop -LiteralPath 'C:/Users/me user/.orca-relay/bin'" + ) + expect(compileScript).toContain('/out:orca.exe') + expect(compileScript).toContain('C:/Users/me user/.orca-relay/bin/orca-launcher.cs') + expect(compileScript).toContain('C:/Users/me user/.orca-relay/bin/orca.cmd') + expect(compileScript.indexOf('orca.cmd')).toBeLessThan(compileScript.indexOf('csc.exe')) + }) + + itWindows('preserves a multiline argument through the compiled remote launcher', () => { + const root = mkdtempSync(join(tmpdir(), 'orca remote cli ')) + try { + const binDir = join(root, 'bin').replaceAll('\\', '/') + const relayDir = join(root, 'relay').replaceAll('\\', '/') + const sockPath = '\\\\.\\pipe\\orca-relay-test' + const plan = createRemoteCliInstallPlan({ + binDir, + relayDir, + nodePath: process.execPath, + sockPath, + hostPlatform: getRemoteHostPlatform('win32-x64') + }) + for (const file of plan.files) { + mkdirSync(dirname(file.path), { recursive: true }) + writeFileSync(file.path, file.contents, 'utf8') + } + + const encoded = plan.postWriteCommands[0]?.match(/-EncodedCommand\s+(\S+)/)?.[1] + expect(encoded).toBeTruthy() + const compile = spawnSync( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-EncodedCommand', + encoded! + ], + { encoding: 'utf8' } + ) + expect(compile.status, `${compile.stdout}\n${compile.stderr}`).toBe(0) + + mkdirSync(relayDir, { recursive: true }) + writeFileSync( + join(relayDir, 'relay.js'), + 'process.stdout.write(JSON.stringify(process.argv.slice(2)))\n', + 'utf8' + ) + const body = 'line one\nline two & whoami\n"quoted" C:\\tail\\' + const launched = spawnSync( + plan.launcherPath, + ['orchestration', 'send', '--body', body, '--json'], + { + encoding: 'utf8', + env: { + ...process.env, + ORCA_RELAY_NODE_PATH: process.execPath, + ORCA_RELAY_DIR: relayDir, + ORCA_RELAY_SOCKET_PATH: sockPath + } + } + ) + + expect(launched.status, launched.stderr).toBe(0) + expect(JSON.parse(launched.stdout)).toEqual([ + '--sock-path', + sockPath, + '--orca-cli', + 'orchestration', + 'send', + '--body', + body, + '--json' + ]) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('keeps the POSIX launcher as an argv-preserving shell exec', () => { + const plan = createRemoteCliInstallPlan({ + binDir: '/home/me/.orca-relay/bin', + relayDir: '/home/me/.orca-remote/relay-v1', + nodePath: '/usr/bin/node', + sockPath: '/home/me/.orca-remote/relay-v1/relay.sock', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(plan.launcherPath).toBe('/home/me/.orca-relay/bin/orca') + expect(plan.files).toEqual([ + expect.objectContaining({ + path: '/home/me/.orca-relay/bin/orca', + contents: expect.stringContaining('--orca-cli "$@"') + }) + ]) + }) +}) diff --git a/src/main/ssh/ssh-remote-cli-launcher.ts b/src/main/ssh/ssh-remote-cli-launcher.ts new file mode 100644 index 00000000000..a246ce9c9b8 --- /dev/null +++ b/src/main/ssh/ssh-remote-cli-launcher.ts @@ -0,0 +1,238 @@ +import type { RemoteHostPlatform } from './ssh-remote-platform' +import { isWindowsRemoteHost, joinRemotePath } from './ssh-remote-platform' +import { powerShellCommand, powerShellLiteral, powerShellNativeArg } from './ssh-remote-powershell' + +type RemoteCliInstallEnv = { + binDir: string + relayDir: string + nodePath: string + sockPath: string + hostPlatform: RemoteHostPlatform +} + +type RemoteCliInstallFile = { + path: string + contents: string +} + +export type RemoteCliInstallPlan = { + launcherPath: string + files: RemoteCliInstallFile[] + postWriteCommands: string[] +} + +const WINDOWS_REMOTE_CLI_LAUNCHER_SOURCE = String.raw`using System; +using System.Diagnostics; +using System.IO; +using System.Text; + +internal static class OrcaRemoteCliLauncher +{ + private static int Main(string[] args) + { + try + { + string nodePath = RequireEnvironmentVariable("ORCA_RELAY_NODE_PATH"); + string relayDirectory = RequireEnvironmentVariable("ORCA_RELAY_DIR"); + string socketPath = RequireEnvironmentVariable("ORCA_RELAY_SOCKET_PATH"); + string relayPath = Path.Combine(relayDirectory, "relay.js"); + + if (!File.Exists(nodePath)) + { + Console.Error.WriteLine("Orca SSH CLI bridge cannot find Node.js at \"{0}\"", nodePath); + return 1; + } + if (!File.Exists(relayPath)) + { + Console.Error.WriteLine("Orca SSH CLI bridge cannot find the relay at \"{0}\"", relayPath); + return 1; + } + + ProcessStartInfo startInfo = new ProcessStartInfo + { + FileName = nodePath, + Arguments = BuildArguments(relayPath, socketPath, args), + UseShellExecute = false + }; + + using (Process child = Process.Start(startInfo)) + { + child.WaitForExit(); + return child.ExitCode; + } + } + catch (Exception error) + { + Console.Error.WriteLine("Unable to start the Orca SSH CLI bridge: {0}", error.Message); + return 1; + } + } + + private static string RequireEnvironmentVariable(string name) + { + string value = Environment.GetEnvironmentVariable(name); + if (String.IsNullOrEmpty(value)) + { + throw new InvalidOperationException(name + " is not set."); + } + return value; + } + + private static string BuildArguments(string relayPath, string socketPath, string[] args) + { + StringBuilder commandLine = new StringBuilder(); + AppendArgument(commandLine, relayPath); + AppendArgument(commandLine, "--sock-path"); + AppendArgument(commandLine, socketPath); + AppendArgument(commandLine, "--orca-cli"); + foreach (string arg in args) + { + AppendArgument(commandLine, arg); + } + return commandLine.ToString(); + } + + private static void AppendArgument(StringBuilder commandLine, string value) + { + if (commandLine.Length > 0) + { + commandLine.Append(' '); + } + commandLine.Append(QuoteArgument(value)); + } + + private static string QuoteArgument(string value) + { + bool requiresQuotes = value.Length == 0; + for (int index = 0; index < value.Length && !requiresQuotes; index += 1) + { + requiresQuotes = value[index] == '"' || Char.IsWhiteSpace(value[index]); + } + if (!requiresQuotes) + { + return value; + } + + StringBuilder quoted = new StringBuilder("\""); + int backslashCount = 0; + foreach (char character in value) + { + if (character == '\\') + { + backslashCount += 1; + continue; + } + if (character == '"') + { + quoted.Append('\\', backslashCount * 2 + 1); + quoted.Append('"'); + } + else + { + quoted.Append('\\', backslashCount); + quoted.Append(character); + } + backslashCount = 0; + } + + quoted.Append('\\', backslashCount * 2); + quoted.Append('"'); + return quoted.ToString(); + } +} +` + +function quoteSh(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'` +} + +function createWindowsLauncherCompileCommand( + binDir: string, + sourceFileName: string, + launcherFileName: string, + launcherPath: string, + sourcePath: string, + legacyShimPath: string +): string { + // Why: legacy csc.exe mis-parses space-bearing absolute paths handed to it by + // Windows PowerShell 5.1's native-argument quoting, so compile from the bin + // directory and pass only the bare, space-free launcher file names. + const compilerArgs = [ + '/nologo', + '/target:exe', + '/optimize+', + '/warnaserror+', + `/out:${launcherFileName}`, + sourceFileName + ] + .map(powerShellNativeArg) + .join(' ') + return powerShellCommand( + [ + // Why: an upgrade must not leave the old %* batch bridge callable when + // compiler discovery fails; losing the convenience CLI is safer. + `Remove-Item -LiteralPath ${powerShellLiteral(legacyShimPath)} -Force -ErrorAction SilentlyContinue`, + `Set-Location -ErrorAction Stop -LiteralPath ${powerShellLiteral(binDir)}`, + '$windowsDirectory = if ($env:WINDIR) { $env:WINDIR } else { $env:SystemRoot }', + `$compilerCandidates = @((Join-Path $windowsDirectory 'Microsoft.NET\\Framework64\\v4.0.30319\\csc.exe'), (Join-Path $windowsDirectory 'Microsoft.NET\\Framework\\v4.0.30319\\csc.exe'))`, + '$compiler = $compilerCandidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1', + "if (-not $compiler) { Write-Error 'Unable to find the .NET Framework C# compiler required for the Orca SSH CLI launcher.'; exit 1 }", + `& $compiler ${compilerArgs}`, + 'if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }', + `if (-not (Test-Path -LiteralPath ${powerShellLiteral(launcherPath)} -PathType Leaf)) { Write-Error 'The Orca SSH CLI launcher compiler produced no executable.'; exit 1 }`, + `Remove-Item -LiteralPath ${powerShellLiteral(sourcePath)} -Force` + ].join('; ') + ) +} + +export function createRemoteCliInstallPlan(env: RemoteCliInstallEnv): RemoteCliInstallPlan { + if (isWindowsRemoteHost(env.hostPlatform)) { + const launcherFileName = 'orca.exe' + const sourceFileName = 'orca-launcher.cs' + const launcherPath = joinRemotePath(env.hostPlatform, env.binDir, launcherFileName) + const sourcePath = joinRemotePath(env.hostPlatform, env.binDir, sourceFileName) + const legacyShimPath = joinRemotePath(env.hostPlatform, env.binDir, 'orca.cmd') + const binDir = joinRemotePath(env.hostPlatform, env.binDir) + return { + launcherPath, + files: [{ path: sourcePath, contents: WINDOWS_REMOTE_CLI_LAUNCHER_SOURCE }], + // Why: compiling on the Windows target avoids shipping an unsigned + // cross-host binary while ensuring argv never crosses cmd.exe's parser. + postWriteCommands: [ + createWindowsLauncherCompileCommand( + binDir, + sourceFileName, + launcherFileName, + launcherPath, + sourcePath, + legacyShimPath + ) + ] + } + } + + const launcherPath = joinRemotePath(env.hostPlatform, env.binDir, 'orca') + return { + launcherPath, + files: [ + { + path: launcherPath, + contents: [ + '#!/usr/bin/env sh', + 'set -eu', + `ORCA_RELAY_NODE_PATH=\${ORCA_RELAY_NODE_PATH:-${quoteSh(env.nodePath)}}`, + `ORCA_RELAY_DIR=\${ORCA_RELAY_DIR:-${quoteSh(env.relayDir)}}`, + `ORCA_RELAY_SOCKET_PATH=\${ORCA_RELAY_SOCKET_PATH:-${quoteSh(env.sockPath)}}`, + 'if [ ! -S "$ORCA_RELAY_SOCKET_PATH" ]; then', + ' echo "Orca SSH CLI bridge cannot find the relay socket: $ORCA_RELAY_SOCKET_PATH" >&2', + ' exit 1', + 'fi', + 'exec "$ORCA_RELAY_NODE_PATH" "$ORCA_RELAY_DIR/relay.js" --sock-path "$ORCA_RELAY_SOCKET_PATH" --orca-cli "$@"', + '' + ].join('\n') + } + ], + // Surface chmod failures: a non-executable launcher must fail install loudly, not silently. + postWriteCommands: [`chmod +x ${quoteSh(launcherPath)}`] + } +} diff --git a/src/main/ssh/ssh-remote-commands.ts b/src/main/ssh/ssh-remote-commands.ts index 01ab8b266a7..339f4aef594 100644 --- a/src/main/ssh/ssh-remote-commands.ts +++ b/src/main/ssh/ssh-remote-commands.ts @@ -59,11 +59,13 @@ export function probeRelayInstalledCommand( remoteRelayDir: string ): string { const relayJs = joinRemotePath(host, remoteRelayDir, 'relay.js') + const relayWatcherJs = joinRemotePath(host, remoteRelayDir, 'relay-watcher.js') const installComplete = joinRemotePath(host, remoteRelayDir, '.install-complete') if (!isWindowsRemoteHost(host)) { return ( `test -d ${shellEscape(remoteRelayDir)} ` + `&& test -f ${shellEscape(relayJs)} ` + + `&& test -f ${shellEscape(relayWatcherJs)} ` + `&& test -f ${shellEscape(installComplete)} ` + `&& echo OK || echo MISSING` ) @@ -72,8 +74,9 @@ export function probeRelayInstalledCommand( [ `$dir = ${powerShellLiteral(remoteRelayDir)}`, `$relay = ${powerShellLiteral(relayJs)}`, + `$watcher = ${powerShellLiteral(relayWatcherJs)}`, `$complete = ${powerShellLiteral(installComplete)}`, - "if ((Test-Path -LiteralPath $dir -PathType Container) -and (Test-Path -LiteralPath $relay -PathType Leaf) -and (Test-Path -LiteralPath $complete -PathType Leaf)) { 'OK' } else { 'MISSING' }" + "if ((Test-Path -LiteralPath $dir -PathType Container) -and (Test-Path -LiteralPath $relay -PathType Leaf) -and (Test-Path -LiteralPath $watcher -PathType Leaf) -and (Test-Path -LiteralPath $complete -PathType Leaf)) { 'OK' } else { 'MISSING' }" ].join('; ') ) } diff --git a/src/main/ssh/ssh-remote-linear-cli.ts b/src/main/ssh/ssh-remote-linear-cli.ts index 3367c217256..4a5c5c4b416 100644 --- a/src/main/ssh/ssh-remote-linear-cli.ts +++ b/src/main/ssh/ssh-remote-linear-cli.ts @@ -2,14 +2,12 @@ import type { RpcDispatcher } from '../runtime/rpc/dispatcher' import type { RpcResponse } from '../runtime/rpc/core' import { getRemoteLinearReadHelp } from './ssh-remote-linear-read-help' import { tryDispatchRemoteLinearReadCli } from './ssh-remote-linear-read-cli' -import { RemoteCliArgumentError, type ParsedRemoteCli } from './ssh-remote-linear-argument-error' +import type { ParsedRemoteCli } from './ssh-remote-cli-argument-error' import { getRemoteLinearWriteHelp, tryDispatchRemoteLinearWriteCli } from './ssh-remote-linear-write-cli' -export { RemoteCliArgumentError } - export function getRemoteLinearHelp(parsed: ParsedRemoteCli): string | null { const helpPath = remoteLinearHelpPath(parsed) if (!helpPath) { diff --git a/src/main/ssh/ssh-remote-linear-read-cli.ts b/src/main/ssh/ssh-remote-linear-read-cli.ts index 82a6fedb2ad..7e64081c5c0 100644 --- a/src/main/ssh/ssh-remote-linear-read-cli.ts +++ b/src/main/ssh/ssh-remote-linear-read-cli.ts @@ -6,7 +6,7 @@ import { } from '../../shared/linear-agent-access' import type { RpcDispatcher } from '../runtime/rpc/dispatcher' import type { RpcResponse } from '../runtime/rpc/core' -import { RemoteCliArgumentError, type ParsedRemoteCli } from './ssh-remote-linear-argument-error' +import { RemoteCliArgumentError, type ParsedRemoteCli } from './ssh-remote-cli-argument-error' import { LINEAR_ISSUE_FLAGS, diff --git a/src/main/ssh/ssh-remote-orca-cli.test.ts b/src/main/ssh/ssh-remote-orca-cli.test.ts index f54c2157c55..1741772d789 100644 --- a/src/main/ssh/ssh-remote-orca-cli.test.ts +++ b/src/main/ssh/ssh-remote-orca-cli.test.ts @@ -11,7 +11,8 @@ vi.mock('../persistence', () => ({ getCanonicalUserDataPath: () => '/host/user-data' })) -import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import { OrchestrationDb } from '../runtime/orchestration/db' +import { OrcaRuntimeService } from '../runtime/orca-runtime' import type { HostCliPassthroughOptions } from './ssh-remote-cli-host-passthrough' import { runRemoteOrcaCli } from './ssh-remote-orca-cli' @@ -91,6 +92,7 @@ describe('runRemoteOrcaCli', () => { liveLeafCount: 1 }), getOrchestrationDb: () => db, + getTerminalPaneKey: () => null, deliverPendingMessagesForHandle: vi.fn(), notifyMessageArrived: vi.fn(), linearIssueContext: vi.fn(async (request: unknown) => ({ @@ -147,6 +149,191 @@ describe('runRemoteOrcaCli', () => { expect(db.getUnreadMessages('term_windows')[0]?.from_handle).toBe('term_ssh') }) + it('forwards remote pane identity through the legacy orchestration fallback', async () => { + const { runtime, db } = createRuntime() + + const result = await runRemoteOrcaCli( + runtime, + { + argv: ['orchestration', 'send', '--to', 'term_windows', '--subject', 'ping', '--json'], + cwd: '/home/alice/repo', + env: { + ORCA_TERMINAL_HANDLE: 'term_ssh', + ORCA_PANE_KEY: 'tab_ssh:leaf_ssh' + } + }, + LEGACY_FALLBACK_OPTIONS + ) + + expect(result.exitCode).toBe(0) + expect(db.insertMessage).toHaveBeenCalledWith( + expect.objectContaining({ senderPaneKey: 'tab_ssh:leaf_ssh' }) + ) + }) + + it('returns a non-zero status for lifecycle rejection through the legacy fallback', async () => { + const db = new OrchestrationDb(':memory:') + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + const task = db.createTask({ spec: 'remote work' }) + const dispatch = db.createDispatchContext(task.id, 'term_ssh', 'tab_owner:leaf_owner') + + try { + const result = await runRemoteOrcaCli( + runtime, + { + argv: [ + 'orchestration', + 'send', + '--from', + 'term_ssh', + '--to', + 'term_coord', + '--subject', + 'Done', + '--type', + 'worker_done', + '--payload', + JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }), + '--json' + ], + cwd: '/home/alice/repo', + env: { ORCA_PANE_KEY: 'tab_foreign:leaf_foreign' } + }, + LEGACY_FALLBACK_OPTIONS + ) + + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: true, + result: { + message: { type: 'worker_done', subject: 'Rejected worker_done: Done' }, + lifecycle: { action: 'rejected', code: 'sender_not_assignee' } + } + }) + expect(db.getTask(task.id)?.status).toBe('dispatched') + } finally { + db.close() + } + }) + + it('preserves structured lifecycle payload flags through the legacy fallback', async () => { + const db = new OrchestrationDb(':memory:') + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + const task = db.createTask({ spec: 'remote work' }) + const dispatch = db.createDispatchContext(task.id, 'term_ssh', 'tab_owner:leaf_owner') + + try { + const result = await runRemoteOrcaCli( + runtime, + { + argv: [ + 'orchestration', + 'send', + '--to', + 'term_coord', + '--subject', + 'Done', + '--type', + 'worker_done', + '--task-id', + task.id, + '--dispatch-id', + dispatch.id, + '--files-modified', + 'src/a.ts, src/b.ts', + '--json' + ], + cwd: '/home/alice/repo', + env: { + ORCA_TERMINAL_HANDLE: 'term_ssh', + ORCA_PANE_KEY: 'tab_owner:leaf_owner' + } + }, + LEGACY_FALLBACK_OPTIONS + ) + + expect(result.exitCode).toBe(0) + expect(db.getTask(task.id)).toMatchObject({ + status: 'completed', + result: expect.stringContaining('src/a.ts') + }) + } finally { + db.close() + } + }) + + it('rejects identity-less lifecycle sends in the legacy fallback', async () => { + const { runtime, db } = createRuntime() + + const result = await runRemoteOrcaCli( + runtime, + { + argv: [ + 'orchestration', + 'send', + '--to', + 'term_coord', + '--subject', + 'Done', + '--type', + 'worker_done', + '--json' + ], + cwd: '/home/alice/repo', + env: {} + }, + LEGACY_FALLBACK_OPTIONS + ) + + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: false, + error: { code: 'no_active_sender_terminal' } + }) + expect(db.insertMessage).not.toHaveBeenCalled() + }) + + it('rejects mixed raw and structured payload flags in the legacy fallback', async () => { + const { runtime, db } = createRuntime() + + const result = await runRemoteOrcaCli( + runtime, + { + argv: [ + 'orchestration', + 'send', + '--from', + 'term_ssh', + '--to', + 'term_coord', + '--subject', + 'Done', + '--payload', + '{"taskId":"task_1"}', + '--task-id', + 'task_1', + '--json' + ], + cwd: '/home/alice/repo', + env: {} + }, + LEGACY_FALLBACK_OPTIONS + ) + + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: false, + error: { code: 'invalid_argument', message: expect.stringContaining('structured payload') } + }) + expect(db.insertMessage).not.toHaveBeenCalled() + }) + it('accepts equals-style orchestration flags in the remote shim', async () => { const { runtime, db } = createRuntime() diff --git a/src/main/ssh/ssh-remote-orca-cli.ts b/src/main/ssh/ssh-remote-orca-cli.ts index d555b5c2707..e43956dfc1c 100644 --- a/src/main/ssh/ssh-remote-orca-cli.ts +++ b/src/main/ssh/ssh-remote-orca-cli.ts @@ -10,19 +10,16 @@ import { type RemoteOrcaCliRequest, type RemoteOrcaCliResult } from './ssh-remote-cli-host-passthrough' +import { RemoteCliArgumentError, type ParsedRemoteCli } from './ssh-remote-cli-argument-error' +import { getRemoteLinearHelp, tryDispatchRemoteLinearCli } from './ssh-remote-linear-cli' import { - RemoteCliArgumentError, - getRemoteLinearHelp, - tryDispatchRemoteLinearCli -} from './ssh-remote-linear-cli' + getRemoteOrchestrationPayload, + hasRemoteLifecycleRejection, + resolveRemoteOrchestrationSender +} from './ssh-remote-orchestration-send' export type { RemoteOrcaCliRequest, RemoteOrcaCliResult } from './ssh-remote-cli-host-passthrough' -type ParsedRemoteCli = { - commandPath: string[] - flags: Map -} - // Why: these commands run a foreground/interactive process attached to the // caller's TTY (or a local tmux pane), which a buffered one-shot relay bridge // cannot host. Everything else routes through the full host CLI. @@ -116,7 +113,9 @@ async function runLegacyRemoteOrcaCli( return { stdout: formatted.stdout, stderr: formatted.stderr, - exitCode: response.ok ? 0 : 1 + // Why: the legacy SSH bridge bypasses the local CLI handler that turns + // a persisted lifecycle rejection into an unsuccessful command. + exitCode: response.ok && !hasRemoteLifecycleRejection(response.result) ? 0 : 1 } } catch (err) { const message = err instanceof Error ? err.message : String(err) @@ -159,7 +158,11 @@ async function dispatchRemoteCli( } const status = response.result as RuntimeStatus const cliStatus: CliStatusResult = { - app: { running: true, pid: null }, + app: { + running: true, + pid: null, + ...(status.desktopWindowStatus ? { desktopWindowStatus: status.desktopWindowStatus } : {}) + }, runtime: { state: status.graphStatus === 'ready' ? 'ready' : 'graph_not_ready', reachable: true, @@ -174,17 +177,22 @@ async function dispatchRemoteCli( worktree: optionalString(parsed.flags, 'worktree'), limit: optionalNumber(parsed.flags, 'limit') }) - case 'orchestration send': + case 'orchestration send': { + const type = optionalString(parsed.flags, 'type') return await call(dispatcher, 'orchestration.send', { - from: resolveHandle(parsed.flags, env, 'from'), + from: resolveRemoteOrchestrationSender(parsed.flags, env, type), to: requiredString(parsed.flags, 'to'), subject: requiredString(parsed.flags, 'subject'), body: optionalString(parsed.flags, 'body'), - type: optionalString(parsed.flags, 'type'), + type, priority: optionalString(parsed.flags, 'priority'), threadId: optionalString(parsed.flags, 'thread-id'), - payload: optionalString(parsed.flags, 'payload') + payload: getRemoteOrchestrationPayload(parsed.flags), + // Why: the legacy in-process bridge must preserve the same pane + // authority as the full host CLI passthrough. + senderPaneKey: env.ORCA_PANE_KEY || undefined }) + } case 'orchestration check': return await call(dispatcher, 'orchestration.check', { terminal: resolveHandle(parsed.flags, env, 'terminal'), diff --git a/src/main/ssh/ssh-remote-orchestration-send.test.ts b/src/main/ssh/ssh-remote-orchestration-send.test.ts new file mode 100644 index 00000000000..6efd9a18891 --- /dev/null +++ b/src/main/ssh/ssh-remote-orchestration-send.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { + getRemoteOrchestrationPayload, + hasRemoteLifecycleRejection, + resolveRemoteOrchestrationSender +} from './ssh-remote-orchestration-send' + +describe('remote orchestration send compatibility', () => { + it('recognizes only structured lifecycle rejections', () => { + expect(hasRemoteLifecycleRejection({ lifecycle: { action: 'rejected' } })).toBe(true) + expect(hasRemoteLifecycleRejection({ lifecycle: null })).toBe(false) + expect(hasRemoteLifecycleRejection({ lifecycle: { action: 'completed' } })).toBe(false) + expect(hasRemoteLifecycleRejection(null)).toBe(false) + }) + + it('prefers an explicit sender over the remote terminal environment', () => { + expect( + resolveRemoteOrchestrationSender( + new Map([['from', 'term_explicit']]), + { ORCA_TERMINAL_HANDLE: 'term_env' }, + 'worker_done' + ) + ).toBe('term_explicit') + }) + + it.each(['worker_done', 'heartbeat'])('fails closed for an identity-less %s', (type) => { + expect(() => resolveRemoteOrchestrationSender(new Map(), {}, type)).toThrowError( + expect.objectContaining({ code: 'no_active_sender_terminal' }) + ) + }) + + it('preserves the legacy unknown sender for non-lifecycle messages', () => { + expect(resolveRemoteOrchestrationSender(new Map(), {}, 'status')).toBe('unknown') + }) + + it('serializes every structured payload field with local CLI semantics', () => { + const payload = getRemoteOrchestrationPayload( + new Map([ + ['task-id', 'task_1'], + ['dispatch-id', 'ctx_1'], + ['files-modified', 'src/a.ts, src/b.ts,'], + ['report-path', 'report.md'], + ['phase', 'reviewing'] + ]) + ) + + expect(JSON.parse(payload ?? '{}')).toEqual({ + taskId: 'task_1', + dispatchId: 'ctx_1', + filesModified: ['src/a.ts', 'src/b.ts'], + reportPath: 'report.md', + phase: 'reviewing' + }) + }) + + it('passes through a raw payload when no structured fields are present', () => { + expect(getRemoteOrchestrationPayload(new Map([['payload', '{"ok":true}']]))).toBe('{"ok":true}') + }) + + it('rejects mixed raw and structured payloads', () => { + expect(() => + getRemoteOrchestrationPayload( + new Map([ + ['payload', '{"taskId":"task_1"}'], + ['task-id', 'task_1'] + ]) + ) + ).toThrowError(expect.objectContaining({ code: 'invalid_argument' })) + }) +}) diff --git a/src/main/ssh/ssh-remote-orchestration-send.ts b/src/main/ssh/ssh-remote-orchestration-send.ts new file mode 100644 index 00000000000..5e844a9c586 --- /dev/null +++ b/src/main/ssh/ssh-remote-orchestration-send.ts @@ -0,0 +1,83 @@ +import { RemoteCliArgumentError } from './ssh-remote-cli-argument-error' + +type RemoteFlags = Map + +export function hasRemoteLifecycleRejection(result: unknown): boolean { + if (!result || typeof result !== 'object') { + return false + } + const lifecycle = (result as { lifecycle?: unknown }).lifecycle + return ( + lifecycle !== null && + typeof lifecycle === 'object' && + (lifecycle as { action?: unknown }).action === 'rejected' + ) +} + +export function resolveRemoteOrchestrationSender( + flags: RemoteFlags, + env: Record, + type: string | undefined +): string { + const explicit = optionalString(flags, 'from') + const envHandle = env.ORCA_TERMINAL_HANDLE || undefined + if ((type === 'worker_done' || type === 'heartbeat') && !explicit && !envHandle) { + // Why: the fallback must not turn missing remote identity into the + // synthetic "unknown" handle for lifecycle authority decisions. + throw new RemoteCliArgumentError( + '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.' + ) + } + return explicit ?? envHandle ?? 'unknown' +} + +export function getRemoteOrchestrationPayload(flags: RemoteFlags): string | undefined { + const rawPayload = optionalString(flags, 'payload') + const taskId = optionalString(flags, 'task-id') + const dispatchId = optionalString(flags, 'dispatch-id') + const filesModified = optionalString(flags, 'files-modified') + const reportPath = optionalString(flags, 'report-path') + const phase = optionalString(flags, 'phase') + const hasStructuredPayload = [taskId, dispatchId, filesModified, reportPath, phase].some( + (value) => value !== undefined + ) + if (!hasStructuredPayload) { + return rawPayload + } + if (rawPayload !== undefined) { + throw new RemoteCliArgumentError( + 'invalid_argument', + 'Use either --payload or structured payload flags, not both.' + ) + } + + // Why: the fallback receives the same preamble commands as the full CLI; + // preserving these flags keeps lifecycle payloads valid over broken installs. + const payload: Record = {} + if (taskId) { + payload.taskId = taskId + } + if (dispatchId) { + payload.dispatchId = dispatchId + } + 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) +} + +function optionalString(flags: RemoteFlags, name: string): string | undefined { + const value = flags.get(name) + return typeof value === 'string' && value.length > 0 ? value : undefined +} diff --git a/src/main/ssh/ssh-remote-platform.test.ts b/src/main/ssh/ssh-remote-platform.test.ts index fc89d7382ce..69c922dc64f 100644 --- a/src/main/ssh/ssh-remote-platform.test.ts +++ b/src/main/ssh/ssh-remote-platform.test.ts @@ -1,5 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { getRemoteHostPlatform, joinRemotePath } from './ssh-remote-platform' +import { + assertSafeRemotePathSegment, + getRemoteHostPlatform, + joinRemotePath +} from './ssh-remote-platform' import { detectRemoteHostPlatform } from './ssh-remote-platform-detection' import { execCommand } from './ssh-relay-deploy-helpers' import type { SshConnection } from './ssh-connection' @@ -33,6 +37,47 @@ describe('joinRemotePath', () => { }) }) +describe('assertSafeRemotePathSegment', () => { + it('accepts ordinary names under both path flavors', () => { + expect(() => assertSafeRemotePathSegment('report copy.txt', 'posix')).not.toThrow() + expect(() => assertSafeRemotePathSegment('report copy.txt', 'windows')).not.toThrow() + }) + + it.each(['.', '..', '../secret', 'child/name', 'nul\0byte'])( + 'rejects invalid segment %j under both path flavors', + (segment) => { + expect(() => assertSafeRemotePathSegment(segment, 'posix')).toThrow( + 'Unsafe remote path segment' + ) + expect(() => assertSafeRemotePathSegment(segment, 'windows')).toThrow( + 'Unsafe remote path segment' + ) + } + ) + + it('preserves valid POSIX names that Windows would reinterpret', () => { + expect(() => assertSafeRemotePathSegment('notes\\2026\nfinal.txt', 'posix')).not.toThrow() + }) + + it.each([ + '..\\..\\.ssh\\orca_drop', + 'report.txt:orca', + 'question?.txt', + 'trailing.', + 'trailing ', + 'NUL', + 'con.txt', + 'CONIN$', + 'CLOCK$.log', + 'COM1.log', + 'LPT¹' + ])('rejects Win32-special segment %j', (segment) => { + expect(() => assertSafeRemotePathSegment(segment, 'windows')).toThrow( + 'Unsafe remote path segment' + ) + }) +}) + describe('detectRemoteHostPlatform', () => { it('uses uname when the remote is POSIX', async () => { vi.mocked(execCommand).mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Darwin arm64') diff --git a/src/main/ssh/ssh-remote-platform.ts b/src/main/ssh/ssh-remote-platform.ts index f1e9b56af96..eb5f094cafc 100644 --- a/src/main/ssh/ssh-remote-platform.ts +++ b/src/main/ssh/ssh-remote-platform.ts @@ -84,6 +84,44 @@ export function normalizeWindowsRemotePath(path: string): string { return path.replace(/\\/g, '/') } +export function assertSafeRemotePathSegment(segment: string, pathFlavor: RemotePathFlavor): void { + if ( + !segment || + segment === '.' || + segment === '..' || + segment.includes('/') || + segment.includes('\0') + ) { + throw new Error(`Unsafe remote path segment: ${JSON.stringify(segment)}`) + } + if (pathFlavor === 'posix') { + return + } + + // Why: Win32 canonicalizes names and exposes devices/NTFS streams through + // otherwise ordinary-looking path segments, bypassing no-clobber checks. + const normalized = normalizeWindowsRemotePath(segment) + const deviceStem = normalized.split('.')[0]?.toUpperCase() + if ( + normalized.includes('/') || + hasUnsafeWindowsPathChar(normalized) || + /[ .]$/u.test(normalized) || + /^(?:CON|PRN|AUX|NUL|CONIN\$|CONOUT\$|CLOCK\$|COM[1-9¹²³]|LPT[1-9¹²³])$/u.test(deviceStem ?? '') + ) { + throw new Error(`Unsafe remote path segment: ${JSON.stringify(segment)}`) + } +} + +function hasUnsafeWindowsPathChar(value: string): boolean { + for (let i = 0; i < value.length; i += 1) { + const char = value[i] + if (value.charCodeAt(i) <= 31 || (char !== undefined && '<>:"|?*'.includes(char))) { + return true + } + } + return false +} + export function normalizeRemoteHome(rawHome: string, host: RemoteHostPlatform): string { const home = rawHome.trim() return isWindowsRemoteHost(host) ? normalizeWindowsRemotePath(home).replace(/\/+$/, '') : home diff --git a/src/main/ssh/ssh-remote-powershell.ts b/src/main/ssh/ssh-remote-powershell.ts index d20f0e2969f..cbc3b4faddc 100644 --- a/src/main/ssh/ssh-remote-powershell.ts +++ b/src/main/ssh/ssh-remote-powershell.ts @@ -1,14 +1,8 @@ import { encodePowerShellCommand } from '../../shared/powershell-command-encoding' - -export function powerShellLiteral(value: string): string { - return `'${value.replace(/'/g, "''")}'` -} - -// Why: Windows PowerShell 5.1 does not preserve embedded double quotes when -// passing args to native executables, so pre-escape them for Win32 argv parsing. -export function powerShellNativeArg(value: string): string { - return powerShellLiteral(value.replace(/(\\*)"/g, '$1$1\\"')) -} +export { + quotePowerShellLiteral as powerShellLiteral, + quotePowerShellNativeArgument as powerShellNativeArg +} from '../../shared/powershell-native-argument' export function powerShellCommand(script: string): string { return `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encodePowerShellCommand(script)}` diff --git a/src/main/ssh/ssh-remote-pty-lease-index.ts b/src/main/ssh/ssh-remote-pty-lease-index.ts new file mode 100644 index 00000000000..4cedeaef4db --- /dev/null +++ b/src/main/ssh/ssh-remote-pty-lease-index.ts @@ -0,0 +1,20 @@ +import type { SshRemotePtyLease } from '../../shared/ssh-types' + +export function sshRemotePtyLeaseIdentityKey( + targetId: string, + ptyId: string, + relayInstanceId?: string +): string { + return JSON.stringify([targetId, ptyId, relayInstanceId ?? null]) +} + +export function indexSshRemotePtyLeases( + leases: SshRemotePtyLease[] +): Map { + return new Map( + leases.map((lease) => [ + sshRemotePtyLeaseIdentityKey(lease.targetId, lease.ptyId, lease.relayInstanceId), + lease + ]) + ) +} diff --git a/src/main/ssh/ssh-system-fallback.test.ts b/src/main/ssh/ssh-system-fallback.test.ts index 5bec0bb00ad..324a0ed6e48 100644 --- a/src/main/ssh/ssh-system-fallback.test.ts +++ b/src/main/ssh/ssh-system-fallback.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { EventEmitter } from 'node:events' @@ -25,9 +25,12 @@ vi.mock('child_process', () => ({ import { buildSshArgs, findSystemSsh, + downloadFileViaSystemSsh, spawnSystemSsh, spawnSystemSshCommand, uploadDirectoryViaSystemSsh, + uploadFileViaSystemSsh, + writeBufferViaSystemSsh, writeFileViaSystemSsh } from './ssh-system-fallback' import { spawnSystemSshPortForward } from './system-ssh-forward-process' @@ -38,6 +41,11 @@ import type { SystemSshResolvedConfig } from './ssh-control-socket' const SYSTEM_SSH_PATH = process.platform === 'win32' ? 'C:\\Windows\\System32\\OpenSSH\\ssh.exe' : '/usr/bin/ssh' +function decodePowerShellCommand(command: string): string { + const encoded = command.match(/-EncodedCommand\s+(\S+)/)?.[1] + return encoded ? Buffer.from(encoded, 'base64').toString('utf16le') : command +} + function mockSystemSshExists(): void { existsSyncMock.mockImplementation((p: string) => p === SYSTEM_SSH_PATH) } @@ -462,6 +470,23 @@ describe('spawnSystemSsh', () => { expect(proc.listenerCount('error')).toBe(0) }) + it('pauses command stdout under backpressure and resumes when the channel reads', async () => { + const proc = createMockChildProcess() + const pause = vi.spyOn(proc.stdout, 'pause') + const resume = vi.spyOn(proc.stdout, 'resume') + spawnMock.mockReturnValue(proc) + + const channel = spawnSystemSshCommand(createTarget(), 'cat /tmp/large-file') + resume.mockClear() + proc.stdout.write(Buffer.alloc(128 * 1024)) + + expect(pause).toHaveBeenCalled() + resume.mockClear() + channel.read() + await new Promise((resolve) => setImmediate(resolve)) + expect(resume).toHaveBeenCalled() + }) + it('removes write command wait listeners after close', async () => { const proc = createEventedProcess() spawnMock.mockReturnValue(proc) @@ -470,10 +495,90 @@ describe('spawnSystemSsh', () => { proc.emit('close', 0, null) await expect(promise).resolves.toBeUndefined() - expect(proc.stdin.end).toHaveBeenCalledWith('contents') + expect(proc.stdin.end).toHaveBeenCalledWith(Buffer.from('contents')) expect(proc.stderr.listenerCount('data')).toBe(0) }) + it('writes binary buffers to POSIX system SSH targets with exclusive create', async () => { + const proc = createEventedProcess() + spawnMock.mockReturnValue(proc) + + const promise = writeBufferViaSystemSsh(createTarget(), '/tmp/file', Buffer.from('png'), { + exclusive: true + }) + proc.emit('close', 0, null) + + await expect(promise).resolves.toBeUndefined() + const args = spawnMock.mock.calls[0][1] as string[] + expect(args.at(-1)).toContain('set -C; cat >') + expect(args.at(-1)).toContain('/tmp/file') + expect(proc.stdin.end).toHaveBeenCalledWith(Buffer.from('png')) + }) + + it('streams a local file through one POSIX system SSH command', async () => { + const proc = createMockChildProcess() + const received: Buffer[] = [] + proc.stdin.on('data', (chunk: Buffer) => received.push(chunk)) + spawnMock.mockReturnValue(proc) + const dir = mkdtempSync(join(tmpdir(), 'orca-system-ssh-upload-')) + const source = join(dir, 'payload.bin') + writeFileSync(source, Buffer.from('payload')) + + try { + const promise = uploadFileViaSystemSsh(createTarget(), source, '/remote/payload.bin', { + exclusive: true + }) + await new Promise((resolve) => proc.stdin.once('finish', resolve)) + proc.emit('close', 0, null) + + await expect(promise).resolves.toBeUndefined() + expect(Buffer.concat(received)).toEqual(Buffer.from('payload')) + expect(spawnMock).toHaveBeenCalledTimes(1) + const args = spawnMock.mock.calls[0][1] as string[] + expect(args.at(-1)).toContain('set -C; cat >') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('appends binary buffers to POSIX system SSH targets', async () => { + const proc = createEventedProcess() + spawnMock.mockReturnValue(proc) + + const promise = writeBufferViaSystemSsh(createTarget(), '/tmp/file', Buffer.from('more'), { + append: true + }) + proc.emit('close', 0, null) + + await expect(promise).resolves.toBeUndefined() + const args = spawnMock.mock.calls[0][1] as string[] + expect(args.at(-1)).toContain('cat >>') + expect(args.at(-1)).toContain('/tmp/file') + expect(args.at(-1)).not.toContain('set -C') + }) + + it('downloads files from POSIX system SSH targets', async () => { + const proc = createEventedProcess() + spawnMock.mockReturnValue(proc) + const dir = mkdtempSync(join(tmpdir(), 'orca-system-ssh-download-')) + const dest = join(dir, 'payload.bin') + + try { + const promise = downloadFileViaSystemSsh(createTarget(), '/remote/payload.bin', dest) + proc.stdout.emit('data', Buffer.from('payload')) + proc.stdout.emit('end') + proc.emit('close', 0, null) + + await expect(promise).resolves.toBeUndefined() + expect(readFileSync(dest)).toEqual(Buffer.from('payload')) + const args = spawnMock.mock.calls[0][1] as string[] + expect(args.at(-1)).toContain('cat') + expect(args.at(-1)).toContain('/remote/payload.bin') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + it('forces standalone SSH for POSIX file writes when requested', async () => { const proc = createEventedProcess() spawnMock.mockReturnValue(proc) @@ -511,6 +616,56 @@ describe('spawnSystemSsh', () => { expect(proc.stdin.end).toHaveBeenCalledWith(Buffer.from('0.1.0', 'utf-8')) }) + it('writes binary buffers to Windows system SSH targets with CreateNew mode', async () => { + const proc = createEventedProcess() + spawnMock.mockReturnValue(proc) + const hostPlatform = getRemoteHostPlatform('win32-x64') + + const promise = writeBufferViaSystemSsh( + createTarget(), + 'C:/Users/me/logo.png', + Buffer.from('png'), + { hostPlatform, exclusive: true } + ) + proc.emit('close', 0, null) + + await expect(promise).resolves.toBeUndefined() + const args = spawnMock.mock.calls[0][1] as string[] + const remoteCommand = args.at(-1) ?? '' + expect(remoteCommand).toContain('powershell.exe') + expect(decodePowerShellCommand(remoteCommand)).toContain('CreateNew') + expect(remoteCommand).not.toContain('/bin/sh') + expect(proc.stdin.end).toHaveBeenCalledWith(Buffer.from('png')) + }) + + it('downloads files from Windows system SSH targets with PowerShell stdout bytes', async () => { + const proc = createEventedProcess() + spawnMock.mockReturnValue(proc) + const hostPlatform = getRemoteHostPlatform('win32-x64') + const dir = mkdtempSync(join(tmpdir(), 'orca-system-ssh-download-')) + const dest = join(dir, 'payload.bin') + + try { + const promise = downloadFileViaSystemSsh(createTarget(), 'C:/Users/me/payload.bin', dest, { + hostPlatform + }) + proc.stdout.emit('data', Buffer.from('payload')) + proc.stdout.emit('end') + proc.emit('close', 0, null) + + await expect(promise).resolves.toBeUndefined() + expect(readFileSync(dest)).toEqual(Buffer.from('payload')) + const args = spawnMock.mock.calls[0][1] as string[] + const remoteCommand = args.at(-1) ?? '' + expect(remoteCommand).toContain('powershell.exe') + expect(decodePowerShellCommand(remoteCommand)).toContain('OpenRead') + expect(decodePowerShellCommand(remoteCommand)).toContain('CopyTo') + expect(remoteCommand).not.toContain('/bin/sh') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + it('forces standalone SSH for Windows file writes when requested', async () => { const proc = createEventedProcess() spawnMock.mockReturnValue(proc) diff --git a/src/main/ssh/ssh-system-fallback.ts b/src/main/ssh/ssh-system-fallback.ts index 01c3d978c72..b34a7579a1f 100644 --- a/src/main/ssh/ssh-system-fallback.ts +++ b/src/main/ssh/ssh-system-fallback.ts @@ -5,4 +5,9 @@ export { type SystemSshBuildArgsOptions } from './system-ssh-args' export { spawnSystemSsh, spawnSystemSshCommand, type SystemSshProcess } from './system-ssh-command' +export { + downloadFileViaSystemSsh, + uploadFileViaSystemSsh, + writeBufferViaSystemSsh +} from './system-ssh-file-binary-transfer' export { uploadDirectoryViaSystemSsh, writeFileViaSystemSsh } from './system-ssh-file-transfer' diff --git a/src/main/ssh/ssh-target-id-migration.test.ts b/src/main/ssh/ssh-target-id-migration.test.ts index fa6507d6260..09f1fe9b6df 100644 --- a/src/main/ssh/ssh-target-id-migration.test.ts +++ b/src/main/ssh/ssh-target-id-migration.test.ts @@ -34,7 +34,7 @@ describe('migrateWorkspaceSessionSshTargetId', () => { const session = makeSession({ tabsByWorktree: { 'r1::/wt': [ - makeTab({ id: 'tab1', ptyId: `ssh:${OLD_ID}@@pty-3` }), + makeTab({ id: 'tab1', ptyId: `ssh:${OLD_ID}@@relay-generation@@pty-3` }), makeTab({ id: 'tab2', ptyId: 'local-pty-7' }), makeTab({ id: 'tab3', ptyId: `ssh:other-target@@pty-1` }) ] @@ -44,11 +44,11 @@ describe('migrateWorkspaceSessionSshTargetId', () => { root: { type: 'leaf', leafId: 'leaf-1' }, activeLeafId: 'leaf-1', expandedLeafId: null, - ptyIdsByLeafId: { 'leaf-1': `ssh:${OLD_ID}@@pty-3` } + ptyIdsByLeafId: { 'leaf-1': `ssh:${OLD_ID}@@relay-generation@@pty-3` } } }, remoteSessionIdsByTabId: { - tab1: `ssh:${OLD_ID}@@pty-3`, + tab1: `ssh:${OLD_ID}@@relay-generation@@pty-3`, tab3: `ssh:other-target@@pty-1` } }) @@ -56,15 +56,15 @@ describe('migrateWorkspaceSessionSshTargetId', () => { expect(migrateWorkspaceSessionSshTargetId(session, OLD_ID, NEW_ID)).toBe(true) const tabs = session.tabsByWorktree['r1::/wt'] - expect(tabs[0].ptyId).toBe(`ssh:${NEW_ID}@@pty-3`) + expect(tabs[0].ptyId).toBe(`ssh:${NEW_ID}@@relay-generation@@pty-3`) // Local and other-target pty ids must be untouched. expect(tabs[1].ptyId).toBe('local-pty-7') expect(tabs[2].ptyId).toBe(`ssh:other-target@@pty-1`) expect(session.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({ - 'leaf-1': `ssh:${NEW_ID}@@pty-3` + 'leaf-1': `ssh:${NEW_ID}@@relay-generation@@pty-3` }) expect(session.remoteSessionIdsByTabId).toEqual({ - tab1: `ssh:${NEW_ID}@@pty-3`, + tab1: `ssh:${NEW_ID}@@relay-generation@@pty-3`, tab3: `ssh:other-target@@pty-1` }) }) diff --git a/src/main/ssh/ssh-target-id-migration.ts b/src/main/ssh/ssh-target-id-migration.ts index a917c4393e9..75a8127a30e 100644 --- a/src/main/ssh/ssh-target-id-migration.ts +++ b/src/main/ssh/ssh-target-id-migration.ts @@ -21,7 +21,7 @@ function rewriteSshPtyId(ptyId: string, oldTargetId: string, newTargetId: string if (!parsed || parsed.connectionId !== oldTargetId) { return null } - return toAppSshPtyId(newTargetId, parsed.relayPtyId) + return toAppSshPtyId(newTargetId, parsed.relayPtyId, parsed.relayInstanceId) } function rewriteSshPtyIdRecordValues( diff --git a/src/main/ssh/system-ssh-command.ts b/src/main/ssh/system-ssh-command.ts index e3051201ac4..1f0ce5ed6df 100644 --- a/src/main/ssh/system-ssh-command.ts +++ b/src/main/ssh/system-ssh-command.ts @@ -90,7 +90,9 @@ function wrapChildProcess(proc: ChildProcess): SystemSshProcess { function wrapCommandProcess(proc: ChildProcess): SystemSshCommandChannel { const duplex = new Duplex({ - read() {}, + read() { + proc.stdout?.resume() + }, write(chunk, encoding, cb) { proc.stdin!.write(chunk, encoding, cb) } @@ -128,7 +130,11 @@ function wrapCommandProcess(proc: ChildProcess): SystemSshCommandChannel { duplex.destroy(err) } const onStdoutData = (data: Buffer): void => { - duplex.push(data) + // Why: file downloads can outpace the local destination; pause OpenSSH + // instead of buffering the producer-consumer lag in the main process. + if (!duplex.push(data)) { + proc.stdout!.pause() + } } const onStdoutEnd = (): void => { duplex.push(null) diff --git a/src/main/ssh/system-ssh-file-binary-transfer.ts b/src/main/ssh/system-ssh-file-binary-transfer.ts new file mode 100644 index 00000000000..e86aa3c9ae0 --- /dev/null +++ b/src/main/ssh/system-ssh-file-binary-transfer.ts @@ -0,0 +1,215 @@ +import { constants, createWriteStream } from 'node:fs' +import { lstat, open } from 'node:fs/promises' +import type { Writable } from 'node:stream' +import { pipeline } from 'node:stream/promises' +import type { SshTarget } from '../../shared/ssh-types' +import { shellEscape } from './ssh-connection-utils' +import { + getSystemSshBuildArgsFromOperationOptions, + type SystemSshBuildArgsOptions +} from './system-ssh-args' +import { spawnSystemSshCommand } from './system-ssh-command' +import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' +import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' +import { + awaitWithSystemSshAbort, + throwIfAborted, + waitForChannelClose +} from './system-ssh-operation-lifecycle' + +type SystemSshOperationOptions = SystemSshBuildArgsOptions & { + signal?: AbortSignal + hostPlatform?: RemoteHostPlatform +} + +type SystemSshWriteBufferOptions = SystemSshOperationOptions & { + append?: boolean + exclusive?: boolean +} + +type SystemSshUploadFileOptions = SystemSshOperationOptions & { + exclusive?: boolean +} + +export async function downloadFileViaSystemSsh( + target: SshTarget, + remotePath: string, + localPath: string, + options?: SystemSshOperationOptions +): Promise { + throwIfAborted(options?.signal) + const isWindows = options?.hostPlatform && isWindowsRemoteHost(options.hostPlatform) + const command = isWindows + ? makeWindowsReadFileCommand(remotePath) + : `cat ${shellEscape(remotePath)}` + const channel = spawnSystemSshCommand(target, command, { + wrapCommand: !isWindows, + ...getSystemSshBuildArgsFromOperationOptions(options) + }) + const output = createWriteStream(localPath, { flags: 'wx' }) + try { + await awaitWithSystemSshAbort( + options?.signal, + () => { + channel.close() + output.destroy() + }, + Promise.all([ + waitForChannelClose(channel, `download ${remotePath}`), + pipeline(channel, output) + ]) + ) + } catch (error) { + channel.close() + output.destroy() + throw error + } +} + +export async function writeBufferViaSystemSsh( + target: SshTarget, + remotePath: string, + contents: Buffer, + options?: SystemSshWriteBufferOptions +): Promise { + throwIfAborted(options?.signal) + if (options?.hostPlatform && isWindowsRemoteHost(options.hostPlatform)) { + await writeBufferViaSystemSshWindows(target, remotePath, contents, options) + return + } + + const channel = spawnSystemSshCommand( + target, + makePosixWriteFileCommand(remotePath, options), + getSystemSshBuildArgsFromOperationOptions(options) + ) + const closePromise = awaitWithSystemSshAbort( + options?.signal, + () => channel.close(), + waitForChannelClose(channel, `write ${remotePath}`) + ) + if (!options?.signal?.aborted) { + channel.stdin.end(contents) + } + await closePromise +} + +export async function uploadFileViaSystemSsh( + target: SshTarget, + localPath: string, + remotePath: string, + options?: SystemSshUploadFileOptions +): Promise { + throwIfAborted(options?.signal) + const sourceStat = await lstat(localPath) + if (sourceStat.isSymbolicLink() || !sourceStat.isFile()) { + throw new Error(`Unsupported upload source: ${localPath}`) + } + + const handle = await open(localPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)) + try { + const openedStat = await handle.stat() + if ( + !openedStat.isFile() || + openedStat.size !== sourceStat.size || + (sourceStat.ino !== 0 && openedStat.ino !== 0 && openedStat.ino !== sourceStat.ino) || + (sourceStat.dev !== 0 && openedStat.dev !== 0 && openedStat.dev !== sourceStat.dev) + ) { + throw new Error(`File changed during upload: ${localPath}`) + } + throwIfAborted(options?.signal) + + const isWindows = options?.hostPlatform && isWindowsRemoteHost(options.hostPlatform) + const channel = spawnSystemSshCommand( + target, + isWindows + ? makeWindowsWriteFileCommand(remotePath, options) + : makePosixWriteFileCommand(remotePath, options), + { + wrapCommand: !isWindows, + ...getSystemSshBuildArgsFromOperationOptions(options) + } + ) + const input = handle.createReadStream({ autoClose: false }) + try { + await awaitWithSystemSshAbort( + options?.signal, + () => { + input.destroy() + channel.close() + }, + Promise.all([ + waitForChannelClose(channel, `upload ${remotePath}`), + pipeline(input, channel.stdin as Writable) + ]) + ) + } catch (error) { + input.destroy() + channel.close() + throw error + } + } finally { + await handle.close() + } +} + +async function writeBufferViaSystemSshWindows( + target: SshTarget, + remotePath: string, + contents: Buffer, + options: SystemSshWriteBufferOptions +): Promise { + throwIfAborted(options.signal) + const channel = spawnSystemSshCommand(target, makeWindowsWriteFileCommand(remotePath, options), { + wrapCommand: false, + ...getSystemSshBuildArgsFromOperationOptions(options) + }) + const closePromise = awaitWithSystemSshAbort( + options.signal, + () => channel.close(), + waitForChannelClose(channel, `write ${remotePath}`) + ) + if (!options.signal?.aborted) { + channel.stdin.end(contents) + } + await closePromise +} + +function makeWindowsWriteFileCommand( + remotePath: string, + options?: { append?: boolean; exclusive?: boolean } +): string { + const fileMode = options?.append ? 'Append' : options?.exclusive ? 'CreateNew' : 'Create' + return powerShellCommand( + [ + '$ErrorActionPreference = "Stop"', + `$path = ${powerShellLiteral(remotePath)}`, + '$parent = [System.IO.Path]::GetDirectoryName($path)', + 'if ($parent) { $null = [System.IO.Directory]::CreateDirectory($parent) }', + '$inputStream = [Console]::OpenStandardInput()', + `$outputStream = [System.IO.File]::Open($path, [System.IO.FileMode]::${fileMode}, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)`, + 'try { $inputStream.CopyTo($outputStream) } finally { $outputStream.Dispose() }' + ].join('; ') + ) +} + +function makePosixWriteFileCommand( + remotePath: string, + options?: { append?: boolean; exclusive?: boolean } +): string { + const redirection = options?.append ? '>>' : '>' + const noclobber = !options?.append && options?.exclusive ? 'set -C; ' : '' + return `${noclobber}cat ${redirection} ${shellEscape(remotePath)}` +} + +function makeWindowsReadFileCommand(remotePath: string): string { + return powerShellCommand( + [ + '$ErrorActionPreference = "Stop"', + `$path = ${powerShellLiteral(remotePath)}`, + '$src = [System.IO.File]::OpenRead($path)', + '$dst = [Console]::OpenStandardOutput()', + 'try { $src.CopyTo($dst) } finally { $src.Dispose() }' + ].join('; ') + ) +} diff --git a/src/main/ssh/system-ssh-file-transfer.ts b/src/main/ssh/system-ssh-file-transfer.ts index 345ab690920..72cb4425d11 100644 --- a/src/main/ssh/system-ssh-file-transfer.ts +++ b/src/main/ssh/system-ssh-file-transfer.ts @@ -13,7 +13,7 @@ import { } from './system-ssh-args' import { spawnSystemSshCommand } from './system-ssh-command' import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' -import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' +import { powerShellCommand } from './ssh-remote-powershell' import { awaitWithSystemSshAbort, killProcess, @@ -22,6 +22,7 @@ import { waitForProcess, type ProcessResult } from './system-ssh-operation-lifecycle' +import { writeBufferViaSystemSsh } from './system-ssh-file-binary-transfer' type SystemSshOperationOptions = SystemSshBuildArgsOptions & { signal?: AbortSignal @@ -95,30 +96,7 @@ export async function writeFileViaSystemSsh( options?: SystemSshOperationOptions ): Promise { throwIfAborted(options?.signal) - if (options?.hostPlatform && isWindowsRemoteHost(options.hostPlatform)) { - await writeBufferViaSystemSshWindows( - target, - remotePath, - Buffer.from(contents, 'utf-8'), - options - ) - return - } - - const channel = spawnSystemSshCommand( - target, - `cat > ${shellEscape(remotePath)}`, - getSystemSshBuildArgsFromOperationOptions(options) - ) - const closePromise = awaitWithSystemSshAbort( - options?.signal, - () => channel.close(), - waitForChannelClose(channel, `write ${remotePath}`) - ) - if (!options?.signal?.aborted) { - channel.stdin.end(contents) - } - await closePromise + await writeBufferViaSystemSsh(target, remotePath, Buffer.from(contents, 'utf-8'), options) } async function uploadDirectoryViaSystemSshWindows( @@ -221,42 +199,6 @@ async function readLocalUploadFile( } } -async function writeBufferViaSystemSshWindows( - target: SshTarget, - remotePath: string, - contents: Buffer, - options: SystemSshOperationOptions -): Promise { - throwIfAborted(options.signal) - const channel = spawnSystemSshCommand(target, makeWindowsWriteFileCommand(remotePath), { - wrapCommand: false, - ...getSystemSshBuildArgsFromOperationOptions(options) - }) - const closePromise = awaitWithSystemSshAbort( - options.signal, - () => channel.close(), - waitForChannelClose(channel, `write ${remotePath}`) - ) - if (!options.signal?.aborted) { - channel.stdin.end(contents) - } - await closePromise -} - -function makeWindowsWriteFileCommand(remotePath: string): string { - return powerShellCommand( - [ - '$ErrorActionPreference = "Stop"', - `$path = ${powerShellLiteral(remotePath)}`, - '$parent = [System.IO.Path]::GetDirectoryName($path)', - 'if ($parent) { $null = [System.IO.Directory]::CreateDirectory($parent) }', - '$inputStream = [Console]::OpenStandardInput()', - '$outputStream = [System.IO.File]::Open($path, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)', - 'try { $inputStream.CopyTo($outputStream) } finally { $outputStream.Dispose() }' - ].join('; ') - ) -} - function makeWindowsUploadPackageCommand(): string { return powerShellCommand( [ diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index 7939af31aae..b9adfc6b8f2 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -describe('desktop startup ordering', () => { +describe('startup ordering', () => { it('passes the startup barrier into PTY handlers without blocking window creation', () => { const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') const attachStart = source.indexOf('attachMainWindowServices(') @@ -24,6 +24,51 @@ describe('desktop startup ordering', () => { expect(Math.max(rpcStartIndex, legacyRpcStartIndex)).toBeGreaterThanOrEqual(0) }) + it('bounds WSL reconciliation before serve RPC while leaving desktop startup independent', () => { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const barrierStart = source.indexOf("ipcMain.handle('app:awaitFirstWindowStartupServices'") + const barrierEnd = source.indexOf("ipcMain.handle(\n 'app:startupDiagnostic'", barrierStart) + const barrier = source.slice(barrierStart, barrierEnd) + const reconciliationStart = source.indexOf( + 'managedWslCliReconciliationReady = reconcileManagedWslCliRegistrations(' + ) + const serveStart = source.indexOf('if (serveOptions) {', reconciliationStart) + const serveReady = source.indexOf('await printServeReady(serveOptions)', serveStart) + const serveEnd = source.indexOf('return', serveReady) + const desktopWindowStart = source.indexOf('Promise.resolve(openMainWindow())') + const serveStartup = source.slice(serveStart, serveEnd) + const desktopStartup = source.slice(serveEnd, desktopWindowStart) + + expect(reconciliationStart).toBeGreaterThanOrEqual(0) + expect(serveStart).toBeGreaterThan(reconciliationStart) + expect(serveEnd).toBeGreaterThan(serveStart) + expect(desktopWindowStart).toBeGreaterThan(reconciliationStart) + expect(serveStartup).toContain('await managedWslCliStartupBarrierReady') + expect(serveStartup).not.toContain('await managedWslCliReconciliationReady') + expect(serveStartup.indexOf('await managedWslCliStartupBarrierReady')).toBeLessThan( + serveStartup.indexOf('await runtimeRpc.start()') + ) + expect(desktopStartup).not.toContain('await managedWslCliReconciliationReady') + expect(barrier).toContain('managedWslCliStartupBarrierReady') + expect(barrier).not.toContain('managedWslCliReconciliationReady') + }) + + it('exposes managed WSL reconciliation status to headless serve clients and diagnostics', () => { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + + // Why: the barrier fails open, so the serve-ready payload must carry the + // reconciliation state and the bounded wait must be traceable via a milestone. + const readyStart = source.indexOf("type: 'orca_server_ready'") + const readyEnd = source.indexOf('pairing: pairing.available', readyStart) + const readyPayload = source.slice(readyStart, readyEnd) + expect(readyPayload).toContain('managedWslCliReconciliation: managedWslCliReconciliationStatus') + + expect(source).toContain("managedWslCliReconciliationStatus = 'pending'") + expect(source).toContain("managedWslCliReconciliationStatus = 'settled'") + expect(source).toContain("managedWslCliReconciliationStatus = 'failed'") + expect(source).toContain("logStartupMilestone('wsl-cli-barrier-resolved'") + }) + it('does not run the rate-limit quota fetch before the first window can show results', () => { const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') const attachIndex = source.indexOf('rateLimits.attach(window)') diff --git a/src/main/startup/packaged-cli-entry-redirect.ts b/src/main/startup/packaged-cli-entry-redirect.ts index 85445c6a6cd..333da2fcdd8 100644 --- a/src/main/startup/packaged-cli-entry-redirect.ts +++ b/src/main/startup/packaged-cli-entry-redirect.ts @@ -27,7 +27,7 @@ type RedirectOptions = { const REDIRECT_ATTEMPT_ENV = 'ORCA_PACKAGED_CLI_ENTRY_REDIRECTED' /** - * Why: on Windows the bundled `orca.cmd` runs `Orca.exe ` + * Why: on Windows the bundled native launcher runs `Orca.exe ` * with ELECTRON_RUN_AS_NODE=1. When that env var is dropped (e.g. a wrapper or * shell that resets it), Orca boots as a GUI, loses the single-instance lock to * an already-running window, and exits silently with no stdout. This detects the diff --git a/src/main/startup/serve-desktop-activation-wiring.test.ts b/src/main/startup/serve-desktop-activation-wiring.test.ts new file mode 100644 index 00000000000..9008ef4014e --- /dev/null +++ b/src/main/startup/serve-desktop-activation-wiring.test.ts @@ -0,0 +1,49 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +describe('serve desktop activation wiring', () => { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + + it('routes second-instance and app activation through one safety gate', () => { + expect(source).toContain('createServeDesktopActivationGate({') + expect(source).toContain('acquireSingleInstanceLock(app, requestDesktopActivation)') + expect(source).toContain("app.on('activate', requestDesktopActivation)") + expect(source).toContain('getDesktopWindowStatus: getDesktopWindowStatus') + }) + + it('settles the persistent provider before headless PTY registration', () => { + const appReadyIndex = source.indexOf('app.whenReady().then(async () => {') + const startupIndex = source.indexOf( + '\n startTerminalRuntimeStartupServices()\n', + appReadyIndex + ) + const serveIndex = source.indexOf('if (serveOptions) {', appReadyIndex) + const ptyReadyIndex = source.indexOf('await localPtyStartupReady', serveIndex) + const headlessRegistrationIndex = source.indexOf('registerHeadlessPtyRuntime(', serveIndex) + + expect(startupIndex).toBeGreaterThanOrEqual(0) + expect(startupIndex).toBeLessThan(serveIndex) + expect(ptyReadyIndex).toBeGreaterThan(serveIndex) + expect(headlessRegistrationIndex).toBeGreaterThan(ptyReadyIndex) + expect(source).not.toContain( + 'if (!isServeMode) {\n startDesktopFirstWindowStartupServices()' + ) + }) + + it('publishes the named headless sentinel and only enables promotion after RPC is ready', () => { + const serveIndex = source.indexOf('if (serveOptions) {') + const sentinelIndex = source.indexOf( + 'runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID', + serveIndex + ) + const rpcIndex = source.indexOf('await runtimeRpc.start()', serveIndex) + const settleIndex = source.indexOf('settleServeDesktopActivation()', rpcIndex) + + expect(serveIndex).toBeGreaterThanOrEqual(0) + expect(sentinelIndex).toBeGreaterThan(serveIndex) + expect(rpcIndex).toBeGreaterThan(sentinelIndex) + expect(settleIndex).toBeGreaterThan(rpcIndex) + expect(source).not.toContain('runtime.syncWindowGraph(0,') + }) +}) diff --git a/src/main/startup/serve-desktop-activation.test.ts b/src/main/startup/serve-desktop-activation.test.ts new file mode 100644 index 00000000000..659e7b382d4 --- /dev/null +++ b/src/main/startup/serve-desktop-activation.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest' +import { createServeDesktopActivationGate } from './serve-desktop-activation' + +describe('createServeDesktopActivationGate', () => { + it('coalesces activation requests while serve is initializing and drains once when ready', () => { + const activateWindow = vi.fn() + const gate = createServeDesktopActivationGate({ + initialState: 'initializing', + activateWindow + }) + + gate.requestActivation() + gate.requestActivation() + + expect(activateWindow).not.toHaveBeenCalled() + expect(gate.getState()).toBe('initializing') + + gate.markReady() + + expect(activateWindow).toHaveBeenCalledOnce() + expect(gate.getState()).toBe('ready') + }) + + it('activates immediately after the persistent provider is ready', () => { + const activateWindow = vi.fn() + const gate = createServeDesktopActivationGate({ + initialState: 'ready', + activateWindow + }) + + gate.requestActivation() + gate.requestActivation() + + expect(activateWindow).toHaveBeenCalledTimes(2) + }) + + it('drops pending activation and fails closed when promotion is blocked', () => { + const activateWindow = vi.fn() + const onBlocked = vi.fn() + const gate = createServeDesktopActivationGate({ + initialState: 'initializing', + activateWindow, + onBlocked + }) + + gate.requestActivation() + gate.markBlocked('persistent PTY provider unavailable') + gate.requestActivation() + + expect(activateWindow).not.toHaveBeenCalled() + expect(onBlocked).toHaveBeenCalledTimes(2) + expect(gate.getState()).toBe('blocked') + }) +}) diff --git a/src/main/startup/serve-desktop-activation.ts b/src/main/startup/serve-desktop-activation.ts new file mode 100644 index 00000000000..8ee6a83a0e8 --- /dev/null +++ b/src/main/startup/serve-desktop-activation.ts @@ -0,0 +1,56 @@ +import type { RuntimeDesktopWindowStatus } from '../../shared/runtime-types' + +type ActivationGateState = Exclude | 'ready' + +export type ServeDesktopActivationGate = { + getState: () => ActivationGateState + requestActivation: () => void + markReady: () => void + markBlocked: (reason: string) => void +} + +export function createServeDesktopActivationGate(options: { + initialState: 'initializing' | 'ready' + activateWindow: () => void + onBlocked?: (reason: string) => void +}): ServeDesktopActivationGate { + let state: ActivationGateState = options.initialState + let pendingActivation = false + let blockedReason = 'desktop activation is unavailable' + + return { + getState: () => state, + requestActivation: () => { + if (state === 'ready') { + options.activateWindow() + return + } + if (state === 'initializing') { + pendingActivation = true + return + } + options.onBlocked?.(blockedReason) + }, + markReady: () => { + if (state !== 'initializing') { + return + } + state = 'ready' + if (pendingActivation) { + pendingActivation = false + options.activateWindow() + } + }, + markBlocked: (reason) => { + if (state !== 'initializing') { + return + } + state = 'blocked' + blockedReason = reason + if (pendingActivation) { + pendingActivation = false + options.onBlocked?.(blockedReason) + } + } + } +} diff --git a/src/main/startup/single-instance-lock.test.ts b/src/main/startup/single-instance-lock.test.ts index f5318b7b54b..5ccd70e6a4a 100644 --- a/src/main/startup/single-instance-lock.test.ts +++ b/src/main/startup/single-instance-lock.test.ts @@ -5,6 +5,7 @@ import { logSingleInstanceLockBypass, logSingleInstanceLockFailure, shouldBypassSingleInstanceLock, + shouldSkipSingleInstanceLock, SINGLE_INSTANCE_LOCK_BYPASS_MESSAGE, SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE } from './single-instance-lock' @@ -73,6 +74,24 @@ describe('acquireSingleInstanceLock', () => { }) }) +describe('shouldSkipSingleInstanceLock', () => { + it('keeps ordinary dev multi-instance behavior but never skips for serve', () => { + expect(shouldSkipSingleInstanceLock({ isDev: true, isServeMode: false, env: {} })).toBe(true) + expect(shouldSkipSingleInstanceLock({ isDev: true, isServeMode: true, env: {} })).toBe(false) + expect(shouldSkipSingleInstanceLock({ isDev: false, isServeMode: false, env: {} })).toBe(false) + }) + + it('lets isolated E2E exercise the production single-instance path', () => { + expect( + shouldSkipSingleInstanceLock({ + isDev: true, + isServeMode: false, + env: { ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK: '1' } + }) + ).toBe(false) + }) +}) + describe('logSingleInstanceLockFailure', () => { it('emits a production-visible synchronous diagnostic for the early quit path', () => { const write = vi.fn() diff --git a/src/main/startup/single-instance-lock.ts b/src/main/startup/single-instance-lock.ts index 5fd9cc27610..078500284a2 100644 --- a/src/main/startup/single-instance-lock.ts +++ b/src/main/startup/single-instance-lock.ts @@ -4,6 +4,7 @@ import { writeStartupDiagnosticLine, type StartupDiagnosticSink } from './startu export const SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE = '[single-instance] Another Orca instance is already running for this userData profile; exiting this launch after requesting the existing window. If no Orca process is running, this may be an Electron/macOS single-instance lock failure.' export const SINGLE_INSTANCE_LOCK_BYPASS_ENV = 'ORCA_BYPASS_SINGLE_INSTANCE_LOCK' +export const SINGLE_INSTANCE_LOCK_E2E_ENFORCE_ENV = 'ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK' export const SINGLE_INSTANCE_LOCK_BYPASS_MESSAGE = '[single-instance] ORCA_BYPASS_SINGLE_INSTANCE_LOCK=1 is set; bypassing the packaged macOS single-instance lock for diagnostics. Do not use this with another Orca instance running for the same profile.' @@ -49,6 +50,15 @@ export function shouldBypassSingleInstanceLock(options: { ) } +export function shouldSkipSingleInstanceLock(options: { + env?: NodeJS.ProcessEnv + isDev: boolean + isServeMode: boolean +}): boolean { + const env = options.env ?? process.env + return options.isDev && !options.isServeMode && env[SINGLE_INSTANCE_LOCK_E2E_ENFORCE_ENV] !== '1' +} + export function logSingleInstanceLockFailure(write?: StartupDiagnosticSink): void { writeStartupDiagnosticLine(SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE, write) } diff --git a/src/main/startup/window-all-closed-quit-policy.test.ts b/src/main/startup/window-all-closed-quit-policy.test.ts index b7aab9e13ae..6c451f7e4ad 100644 --- a/src/main/startup/window-all-closed-quit-policy.test.ts +++ b/src/main/startup/window-all-closed-quit-policy.test.ts @@ -41,4 +41,14 @@ describe('shouldQuitWhenAllWindowsClosed', () => { }) ).toBe(true) }) + + it('continues a committed quit after a serve owner was promoted to desktop', () => { + expect( + shouldQuitWhenAllWindowsClosed({ + platform: 'darwin', + isQuitting: true, + isServeMode: true + }) + ).toBe(true) + }) }) diff --git a/src/main/startup/window-all-closed-quit-policy.ts b/src/main/startup/window-all-closed-quit-policy.ts index 8b23f436140..f17881ea989 100644 --- a/src/main/startup/window-all-closed-quit-policy.ts +++ b/src/main/startup/window-all-closed-quit-policy.ts @@ -3,7 +3,7 @@ export function shouldQuitWhenAllWindowsClosed(options: { isQuitting: boolean isServeMode: boolean }): boolean { - if (options.isServeMode) { + if (options.isServeMode && !options.isQuitting) { return false } return options.platform !== 'darwin' || options.isQuitting diff --git a/src/main/startup/wsl-cli-reconciliation-startup-barrier.test.ts b/src/main/startup/wsl-cli-reconciliation-startup-barrier.test.ts new file mode 100644 index 00000000000..b2a56a415e8 --- /dev/null +++ b/src/main/startup/wsl-cli-reconciliation-startup-barrier.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from 'vitest' +import { + WSL_CLI_RECONCILIATION_STARTUP_BUDGET_MS, + createWslCliReconciliationStartupBarrier +} from './wsl-cli-reconciliation-startup-barrier' + +describe('createWslCliReconciliationStartupBarrier', () => { + it('resolves as soon as reconciliation finishes', async () => { + vi.useFakeTimers() + let resolveReconciliation!: () => void + + try { + const reconciliation = new Promise((resolve) => { + resolveReconciliation = resolve + }) + const barrier = createWslCliReconciliationStartupBarrier(reconciliation) + let barrierSettled = false + void barrier.then(() => { + barrierSettled = true + }) + + await vi.advanceTimersByTimeAsync(1) + expect(barrierSettled).toBe(false) + + resolveReconciliation() + await expect(barrier).resolves.toBeUndefined() + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('fails open immediately when reconciliation rejects before the budget expires', async () => { + vi.useFakeTimers() + let rejectReconciliation!: (error: Error) => void + + try { + const reconciliation = new Promise((_resolve, reject) => { + rejectReconciliation = reject + }) + const barrier = createWslCliReconciliationStartupBarrier(reconciliation) + let barrierSettled = false + void barrier.then(() => { + barrierSettled = true + }) + + await vi.advanceTimersByTimeAsync(1) + expect(barrierSettled).toBe(false) + + // Why: a fast WSL discovery failure should release the barrier via its catch + // branch without waiting out the budget, and must clear the pending timer. + rejectReconciliation(new Error('WSL discovery failed')) + await expect(barrier).resolves.toBeUndefined() + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('lets serve reach RPC readiness at budget while reconciliation remains pending', async () => { + vi.useFakeTimers() + let resolveReconciliation!: () => void + let reconciliationCompleted = false + + try { + const reconciliation = new Promise((resolve) => { + resolveReconciliation = resolve + }).then(() => { + reconciliationCompleted = true + }) + const barrier = createWslCliReconciliationStartupBarrier(reconciliation) + let rpcReady = false + const serveRpcReadiness = barrier.then(() => { + rpcReady = true + }) + + await vi.advanceTimersByTimeAsync(WSL_CLI_RECONCILIATION_STARTUP_BUDGET_MS - 1) + expect(rpcReady).toBe(false) + expect(reconciliationCompleted).toBe(false) + + await vi.advanceTimersByTimeAsync(1) + await expect(serveRpcReadiness).resolves.toBeUndefined() + expect(rpcReady).toBe(true) + expect(reconciliationCompleted).toBe(false) + + resolveReconciliation() + await reconciliation + expect(reconciliationCompleted).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('preserves eventual reconciliation error reporting after the budget expires', async () => { + vi.useFakeTimers() + let rejectReconciliation!: (error: Error) => void + const reportedErrors: string[] = [] + + try { + const reconciliation = new Promise((_resolve, reject) => { + rejectReconciliation = reject + }).catch((error) => { + reportedErrors.push(error instanceof Error ? error.message : String(error)) + }) + const barrier = createWslCliReconciliationStartupBarrier(reconciliation, { timeoutMs: 10 }) + + await vi.advanceTimersByTimeAsync(10) + await expect(barrier).resolves.toBeUndefined() + expect(reportedErrors).toEqual([]) + + rejectReconciliation(new Error('WSL discovery failed')) + await reconciliation + expect(reportedErrors).toEqual(['WSL discovery failed']) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/startup/wsl-cli-reconciliation-startup-barrier.ts b/src/main/startup/wsl-cli-reconciliation-startup-barrier.ts new file mode 100644 index 00000000000..056f225b8de --- /dev/null +++ b/src/main/startup/wsl-cli-reconciliation-startup-barrier.ts @@ -0,0 +1,33 @@ +export const WSL_CLI_RECONCILIATION_STARTUP_BUDGET_MS = 2_000 + +type WslCliReconciliationStartupBarrierOptions = { + timeoutMs?: number +} + +/** + * Briefly gates terminal startup while managed WSL registrations reconcile. + */ +export function createWslCliReconciliationStartupBarrier( + reconciliation: Promise, + options: WslCliReconciliationStartupBarrierOptions = {} +): Promise { + const timeoutMs = options.timeoutMs ?? WSL_CLI_RECONCILIATION_STARTUP_BUDGET_MS + let timeout: ReturnType | null = null + const settled = reconciliation + .then(() => undefined) + .catch(() => undefined) + .finally(() => { + if (timeout) { + clearTimeout(timeout) + } + }) + + // Why: reconciliation may outlive a slow or unavailable WSL distro; terminal + // startup should wait briefly without turning WSL discovery into an app hang. + return Promise.race([ + settled, + new Promise((resolve) => { + timeout = setTimeout(resolve, timeoutMs) + }) + ]) +} diff --git a/src/main/terminal-teardown-intent-records.ts b/src/main/terminal-teardown-intent-records.ts new file mode 100644 index 00000000000..f5ba04497b9 --- /dev/null +++ b/src/main/terminal-teardown-intent-records.ts @@ -0,0 +1,119 @@ +import { z } from 'zod' +import type { PersistedLocalPtyShutdown, PersistedRuntimeTerminalClose } from '../shared/types' + +const nonEmptyString = z.string().min(1) +const timestamp = z.number().finite().nonnegative() +const localShutdownSchema = z + .object({ + ptyId: nonEmptyString, + expectedPaneKey: nonEmptyString.optional(), + expectedTabId: nonEmptyString.optional(), + requestedAt: timestamp + }) + .strict() +const runtimeCloseSchema = z + .object({ + environmentId: nonEmptyString, + handle: nonEmptyString, + runtimeId: nonEmptyString.optional(), + requestedAt: timestamp + }) + .strict() +const sshShutdownSchema = z + .object({ + targetId: nonEmptyString, + ptyId: nonEmptyString, + relayInstanceId: nonEmptyString.optional(), + shutdownRequestedAt: timestamp + }) + .strict() + +export const terminalTeardownIntentSnapshotSchema = z + .object({ + version: z.literal(1), + revision: z.number().int().nonnegative(), + pendingLocalPtyShutdowns: z.array(localShutdownSchema), + pendingRuntimeTerminalCloses: z.array(runtimeCloseSchema), + pendingSshPtyShutdowns: z.array(sshShutdownSchema) + }) + .strict() + +const journalBaseSchema = z.object({ + version: z.literal(2), + revision: z.number().int().nonnegative() +}) + +export const terminalTeardownIntentJournalRecordSchema = z.discriminatedUnion('kind', [ + journalBaseSchema + .extend({ + kind: z.literal('checkpoint'), + pendingLocalPtyShutdowns: z.array(localShutdownSchema), + pendingRuntimeTerminalCloses: z.array(runtimeCloseSchema), + pendingSshPtyShutdowns: z.array(sshShutdownSchema) + }) + .strict(), + journalBaseSchema + .extend({ kind: z.literal('local-upsert'), request: localShutdownSchema }) + .strict(), + journalBaseSchema.extend({ kind: z.literal('local-remove'), ptyId: nonEmptyString }).strict(), + journalBaseSchema + .extend({ kind: z.literal('runtime-upsert'), request: runtimeCloseSchema }) + .strict(), + journalBaseSchema + .extend({ + kind: z.literal('runtime-remove'), + environmentId: nonEmptyString, + handle: nonEmptyString + }) + .strict(), + journalBaseSchema + .extend({ kind: z.literal('runtime-remove-environment'), environmentId: nonEmptyString }) + .strict(), + journalBaseSchema + .extend({ + kind: z.literal('ssh-migrate-generation'), + targetId: nonEmptyString, + ptyId: nonEmptyString, + relayInstanceId: nonEmptyString, + attachedAt: timestamp + }) + .strict(), + journalBaseSchema + .extend({ + kind: z.literal('ssh-set'), + targetId: nonEmptyString, + ptyId: nonEmptyString, + relayInstanceId: nonEmptyString.optional(), + shutdownRequestedAt: timestamp.optional() + }) + .strict(), + journalBaseSchema + .extend({ kind: z.literal('ssh-remove-target'), targetId: nonEmptyString }) + .strict() +]) + +export type TerminalTeardownIntentSnapshot = z.infer +export type TerminalTeardownIntentJournalRecord = z.infer< + typeof terminalTeardownIntentJournalRecordSchema +> +export type TerminalTeardownIntentMutation = + | { kind: 'local-upsert'; request: PersistedLocalPtyShutdown } + | { kind: 'local-remove'; ptyId: string } + | { kind: 'runtime-upsert'; request: PersistedRuntimeTerminalClose } + | { kind: 'runtime-remove'; environmentId: string; handle: string } + | { kind: 'runtime-remove-environment'; environmentId: string } + | { + kind: 'ssh-migrate-generation' + targetId: string + ptyId: string + relayInstanceId: string + attachedAt: number + } + | { + kind: 'ssh-set' + targetId: string + ptyId: string + relayInstanceId?: string + shutdownRequestedAt?: number + } + | { kind: 'ssh-remove-target'; targetId: string } diff --git a/src/main/terminal-teardown-intent-snapshot.ts b/src/main/terminal-teardown-intent-snapshot.ts new file mode 100644 index 00000000000..ebce871fb51 --- /dev/null +++ b/src/main/terminal-teardown-intent-snapshot.ts @@ -0,0 +1,316 @@ +import { + appendFileSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + unlinkSync, + writeFileSync +} from 'node:fs' +import { dirname, join } from 'node:path' +import type { PersistedState } from '../shared/types' +import { + terminalTeardownIntentJournalRecordSchema, + terminalTeardownIntentSnapshotSchema +} from './terminal-teardown-intent-records' +import type { + TerminalTeardownIntentJournalRecord, + TerminalTeardownIntentMutation, + TerminalTeardownIntentSnapshot +} from './terminal-teardown-intent-records' +import { + indexSshRemotePtyLeases, + sshRemotePtyLeaseIdentityKey +} from './ssh/ssh-remote-pty-lease-index' +import type { SshRemotePtyLease } from '../shared/ssh-types' + +export type { TerminalTeardownIntentMutation } from './terminal-teardown-intent-records' + +function getSnapshotFile(dataFile: string): string { + return join(dirname(dataFile), 'orca-terminal-teardown-intents.json') +} + +function pendingSshShutdowns( + state: PersistedState +): TerminalTeardownIntentSnapshot['pendingSshPtyShutdowns'] { + return (state.sshRemotePtyLeases ?? []).flatMap((lease) => + lease.shutdownRequestedAt === undefined + ? [] + : [ + { + targetId: lease.targetId, + ptyId: lease.ptyId, + ...(lease.relayInstanceId ? { relayInstanceId: lease.relayInstanceId } : {}), + shutdownRequestedAt: lease.shutdownRequestedAt + } + ] + ) +} + +function checkpointForState( + state: PersistedState +): Omit, 'version'> { + return { + kind: 'checkpoint', + revision: state.terminalTeardownIntentRevision ?? 0, + pendingLocalPtyShutdowns: state.pendingLocalPtyShutdowns ?? [], + pendingRuntimeTerminalCloses: state.pendingRuntimeTerminalCloses ?? [], + pendingSshPtyShutdowns: pendingSshShutdowns(state) + } +} + +function readRecords(dataFile: string): { + records: (TerminalTeardownIntentSnapshot | TerminalTeardownIntentJournalRecord)[] + journalReady: boolean +} { + const snapshotFile = getSnapshotFile(dataFile) + if (!existsSync(snapshotFile)) { + return { records: [], journalReady: false } + } + try { + const raw = readFileSync(snapshotFile, 'utf-8') + try { + const parsed = JSON.parse(raw) as unknown + const legacy = terminalTeardownIntentSnapshotSchema.safeParse(parsed) + if (legacy.success) { + return { records: [legacy.data], journalReady: false } + } + const journal = terminalTeardownIntentJournalRecordSchema.safeParse(parsed) + if (journal.success) { + // Why: appendFileSync needs a record delimiter. A crash after the JSON + // bytes but before the newline must recover through an atomic checkpoint. + return { records: [journal.data], journalReady: raw.endsWith('\n') } + } + } catch { + // Multi-record journals are newline-delimited rather than one JSON value. + } + const records: TerminalTeardownIntentJournalRecord[] = [] + let appendable = raw.endsWith('\n') + for (const line of raw.split('\n')) { + if (!line.trim()) { + continue + } + let value: unknown + try { + value = JSON.parse(line) + } catch { + console.warn('[persistence] Ignoring truncated terminal teardown intent journal tail') + appendable = false + break + } + const parsed = terminalTeardownIntentJournalRecordSchema.safeParse(value) + if (!parsed.success) { + // Why: a crash can tear only the final append. Earlier complete records + // remain durable, while replaying past corruption could invent state. + console.warn('[persistence] Ignoring truncated terminal teardown intent journal tail') + appendable = false + break + } + records.push(parsed.data) + } + return { records, journalReady: appendable && records.length > 0 } + } catch (error) { + console.warn('[persistence] Ignoring invalid terminal teardown intent snapshot:', error) + return { records: [], journalReady: false } + } +} + +function applySshShutdowns( + state: PersistedState, + pendingSshPtyShutdowns: TerminalTeardownIntentSnapshot['pendingSshPtyShutdowns'], + leasesByIdentity: Map +): void { + for (const lease of state.sshRemotePtyLeases ?? []) { + delete lease.shutdownRequestedAt + } + for (const pending of pendingSshPtyShutdowns) { + const lease = leasesByIdentity.get( + sshRemotePtyLeaseIdentityKey(pending.targetId, pending.ptyId, pending.relayInstanceId) + ) + if (lease) { + lease.shutdownRequestedAt = pending.shutdownRequestedAt + lease.updatedAt = Math.max(lease.updatedAt, pending.shutdownRequestedAt) + } + } +} + +function applyJournalRecord( + state: PersistedState, + record: TerminalTeardownIntentJournalRecord, + leasesByIdentity: Map +): void { + switch (record.kind) { + case 'checkpoint': + state.pendingLocalPtyShutdowns = record.pendingLocalPtyShutdowns + state.pendingRuntimeTerminalCloses = record.pendingRuntimeTerminalCloses + applySshShutdowns(state, record.pendingSshPtyShutdowns, leasesByIdentity) + return + case 'local-upsert': { + state.pendingLocalPtyShutdowns ??= [] + const index = state.pendingLocalPtyShutdowns.findIndex( + (entry) => entry.ptyId === record.request.ptyId + ) + if (index >= 0) { + state.pendingLocalPtyShutdowns[index] = record.request + } else { + state.pendingLocalPtyShutdowns.push(record.request) + } + return + } + case 'local-remove': + state.pendingLocalPtyShutdowns = (state.pendingLocalPtyShutdowns ?? []).filter( + (entry) => entry.ptyId !== record.ptyId + ) + return + case 'runtime-upsert': { + state.pendingRuntimeTerminalCloses ??= [] + const index = state.pendingRuntimeTerminalCloses.findIndex( + (entry) => + entry.environmentId === record.request.environmentId && + entry.handle === record.request.handle + ) + if (index >= 0) { + state.pendingRuntimeTerminalCloses[index] = record.request + } else { + state.pendingRuntimeTerminalCloses.push(record.request) + } + return + } + case 'runtime-remove': + state.pendingRuntimeTerminalCloses = (state.pendingRuntimeTerminalCloses ?? []).filter( + (entry) => entry.environmentId !== record.environmentId || entry.handle !== record.handle + ) + return + case 'runtime-remove-environment': + state.pendingRuntimeTerminalCloses = (state.pendingRuntimeTerminalCloses ?? []).filter( + (entry) => entry.environmentId !== record.environmentId + ) + return + case 'ssh-migrate-generation': { + const legacyKey = sshRemotePtyLeaseIdentityKey(record.targetId, record.ptyId) + const lease = leasesByIdentity.get(legacyKey) + if (!lease) { + return + } + leasesByIdentity.delete(legacyKey) + lease.relayInstanceId = record.relayInstanceId + leasesByIdentity.set( + sshRemotePtyLeaseIdentityKey(record.targetId, record.ptyId, record.relayInstanceId), + lease + ) + lease.state = 'attached' + lease.updatedAt = Math.max(lease.updatedAt, record.attachedAt) + lease.lastAttachedAt = Math.max(lease.lastAttachedAt ?? 0, record.attachedAt) + return + } + case 'ssh-set': { + const lease = leasesByIdentity.get( + sshRemotePtyLeaseIdentityKey(record.targetId, record.ptyId, record.relayInstanceId) + ) + if (!lease) { + return + } + if (record.shutdownRequestedAt === undefined) { + delete lease.shutdownRequestedAt + } else { + lease.shutdownRequestedAt = record.shutdownRequestedAt + lease.updatedAt = Math.max(lease.updatedAt, record.shutdownRequestedAt) + } + return + } + case 'ssh-remove-target': + for (const lease of state.sshRemotePtyLeases ?? []) { + if (lease.targetId === record.targetId) { + delete lease.shutdownRequestedAt + } + } + } +} + +export function applyTerminalTeardownIntentSnapshot( + state: PersistedState, + dataFile: string +): boolean { + const { records, journalReady } = readRecords(dataFile) + const leasesByIdentity = indexSshRemotePtyLeases(state.sshRemotePtyLeases ?? []) + for (const record of records) { + // Why: a failed journal append falls back to a synchronous full-store + // write. Older valid records must not override that newer durable state. + const durableRevision = state.terminalTeardownIntentRevision ?? 0 + if ( + record.revision < durableRevision || + (record.version === 2 && record.revision === durableRevision) + ) { + continue + } + if (record.version === 1) { + state.pendingLocalPtyShutdowns = record.pendingLocalPtyShutdowns + state.pendingRuntimeTerminalCloses = record.pendingRuntimeTerminalCloses + applySshShutdowns(state, record.pendingSshPtyShutdowns, leasesByIdentity) + } else { + applyJournalRecord(state, record, leasesByIdentity) + } + state.terminalTeardownIntentRevision = record.revision + } + return journalReady +} + +function writeRecordsAtomically( + records: TerminalTeardownIntentJournalRecord[], + dataFile: string +): void { + const snapshotFile = getSnapshotFile(dataFile) + const tmpFile = `${snapshotFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` + mkdirSync(dirname(snapshotFile), { recursive: true }) + let renamed = false + try { + const serialized = records.map((record) => JSON.stringify(record)).join('\n') + writeFileSync(tmpFile, `${serialized}\n`, 'utf-8') + renameSync(tmpFile, snapshotFile) + renamed = true + } finally { + if (!renamed) { + try { + unlinkSync(tmpFile) + } catch { + // The primary write error is more useful than best-effort tmp cleanup. + } + } + } +} + +export function writeTerminalTeardownIntentSnapshot(state: PersistedState, dataFile: string): void { + writeRecordsAtomically([{ version: 2, ...checkpointForState(state) }], dataFile) +} + +export function appendTerminalTeardownIntentMutation( + state: PersistedState, + dataFile: string, + mutation: TerminalTeardownIntentMutation, + journalReady: boolean +): void { + if (!journalReady) { + if (mutation.kind === 'ssh-migrate-generation') { + const revision = state.terminalTeardownIntentRevision ?? 0 + // Why: the checkpoint schema contains teardown owners, not lease + // generations. The first legacy migration needs its own atomic record. + writeRecordsAtomically( + [ + { + version: 2, + ...checkpointForState(state), + revision: Math.max(0, revision - 1) + }, + { version: 2, revision, ...mutation } + ], + dataFile + ) + return + } + writeTerminalTeardownIntentSnapshot(state, dataFile) + return + } + const revision = state.terminalTeardownIntentRevision ?? 0 + const record: TerminalTeardownIntentJournalRecord = { version: 2, revision, ...mutation } + appendFileSync(getSnapshotFile(dataFile), `${JSON.stringify(record)}\n`, 'utf-8') +} diff --git a/src/main/text-generation/agent-failure-output.test.ts b/src/main/text-generation/agent-failure-output.test.ts new file mode 100644 index 00000000000..b4c24617d9e --- /dev/null +++ b/src/main/text-generation/agent-failure-output.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { + captureAgentGenerationFailureOutput, + formatAgentGenerationFailureOutputForDisplay +} from './agent-failure-output' + +const ESC = String.fromCharCode(27) +const BEL = String.fromCharCode(7) + +describe('captureAgentGenerationFailureOutput', () => { + it('returns null when both streams are blank', () => { + expect(captureAgentGenerationFailureOutput('Pi', 1, ' \n', '\t')).toBeNull() + }) + + it('keeps short streams verbatim', () => { + const capture = captureAgentGenerationFailureOutput('Pi', 1, 'out', 'err') + expect(capture).toEqual({ label: 'Pi', exitCode: 1, stdout: 'out', stderr: 'err' }) + }) + + it('bounds an oversized stream to its head and tail with an omission marker', () => { + const oversized = `${'h'.repeat(20_000)}${'m'.repeat(40_000)}${'t'.repeat(50_000)}` + const capture = captureAgentGenerationFailureOutput('Pi', 1, '', oversized) + const stderr = capture?.stderr ?? '' + expect(stderr.startsWith('h'.repeat(16 * 1024))).toBe(true) + expect(stderr.endsWith('t'.repeat(48 * 1024))).toBe(true) + expect(stderr).toContain('characters omitted') + expect(stderr.length).toBeLessThan(oversized.length) + }) +}) + +describe('formatAgentGenerationFailureOutputForDisplay', () => { + it('renders a header plus the streams that had content, stderr first', () => { + const text = formatAgentGenerationFailureOutputForDisplay({ + label: 'Pi', + exitCode: 1, + stdout: 'partial result', + stderr: 'No API key found for github-copilot.' + }) + expect(text).toBe( + 'Pi exited with code 1.\n\n[stderr]\nNo API key found for github-copilot.\n\n[stdout]\npartial result' + ) + }) + + it('omits a blank stream section and reports an unknown exit code', () => { + const text = formatAgentGenerationFailureOutputForDisplay({ + label: 'Claude', + exitCode: null, + stdout: 'Not logged in · Please run /login', + stderr: ' ' + }) + expect(text).toBe( + 'Claude exited with code unknown.\n\n[stdout]\nNot logged in · Please run /login' + ) + }) + + it('strips ANSI, OSC, and bidi-override characters while keeping line structure', () => { + const text = formatAgentGenerationFailureOutputForDisplay({ + label: 'Pi', + exitCode: 1, + stdout: '', + stderr: `${ESC}]0;title${BEL}${ESC}[91mline one${ESC}[0m\r\nsafe ${String.fromCharCode(0x202e)}evil${String.fromCharCode(0x202c)} tail\rline three` + }) + expect(text).toBe('Pi exited with code 1.\n\n[stderr]\nline one\nsafe evil tail\nline three') + }) + + it('keeps later diagnostics when an OSC sequence is unterminated', () => { + const text = formatAgentGenerationFailureOutputForDisplay({ + label: 'Pi', + exitCode: 1, + stdout: '', + stderr: `${ESC}]0;unfinished title\nNo API key found.\nUse /login to log in.` + }) + expect(text).toContain('No API key found.\nUse /login to log in.') + }) +}) diff --git a/src/main/text-generation/agent-failure-output.ts b/src/main/text-generation/agent-failure-output.ts new file mode 100644 index 00000000000..00c60cb1f97 --- /dev/null +++ b/src/main/text-generation/agent-failure-output.ts @@ -0,0 +1,74 @@ +import { stripAnsiControlSequences } from '../../shared/commit-message-agent-output' + +/** Full (bounded) output of a failed agent-CLI generation run, kept for + * on-demand display only — never persisted or synced. */ +export type AgentGenerationFailureOutput = { + label: string + exitCode: number | null + stdout: string + stderr: string +} + +// Why: bounding both ends keeps a runaway 4 MiB capture out of memory and IPC +// while preserving where CLIs put their errors (some front-load them, some +// print them last). +const STREAM_HEAD_LIMIT = 16 * 1024 +const STREAM_TAIL_LIMIT = 48 * 1024 + +export function captureAgentGenerationFailureOutput( + label: string, + exitCode: number | null, + stdout: string, + stderr: string +): AgentGenerationFailureOutput | null { + if (!/\S/.test(stdout) && !/\S/.test(stderr)) { + return null + } + return { + label, + exitCode, + stdout: boundStream(stdout), + stderr: boundStream(stderr) + } +} + +function boundStream(value: string): string { + if (value.length <= STREAM_HEAD_LIMIT + STREAM_TAIL_LIMIT) { + return value + } + const omitted = value.length - STREAM_HEAD_LIMIT - STREAM_TAIL_LIMIT + const bounded = `${value.slice(0, STREAM_HEAD_LIMIT)}\n… (${omitted} characters omitted) …\n${value.slice( + value.length - STREAM_TAIL_LIMIT + )}` + // Why: V8 otherwise retains the multi-megabyte parent through sliced strings + // stored in the capture map; this no-op replacement materializes a flat copy. + return bounded.replace(/$/u, '') +} + +/** Renders the capture as one copyable text block: a header line, then the + * streams that had content. */ +export function formatAgentGenerationFailureOutputForDisplay( + output: AgentGenerationFailureOutput +): string { + const sections = [`${output.label} exited with code ${output.exitCode ?? 'unknown'}.`] + const stderr = sanitizeStreamForDisplay(output.stderr) + if (stderr) { + sections.push(`[stderr]\n${stderr}`) + } + const stdout = sanitizeStreamForDisplay(output.stdout) + if (stdout) { + sections.push(`[stdout]\n${stdout}`) + } + return sections.join('\n\n') +} + +// Why: the block renders in a
 and gets pasted into bug reports; ANSI and
+// non-printing control/format characters (incl. bidi overrides) must not
+// survive, but line structure must.
+function sanitizeStreamForDisplay(value: string): string {
+  return stripAnsiControlSequences(value.replace(/\r\n?/g, '\n'))
+    .replace(/[\p{Cc}\p{Cf}]/gu, (character) =>
+      character === '\n' || character === '\t' ? character : ''
+    )
+    .trim()
+}
diff --git a/src/main/text-generation/commit-message-text-generation.test.ts b/src/main/text-generation/commit-message-text-generation.test.ts
index 8217d193bb9..4464bb8f4ed 100644
--- a/src/main/text-generation/commit-message-text-generation.test.ts
+++ b/src/main/text-generation/commit-message-text-generation.test.ts
@@ -615,7 +615,7 @@ describe('generateCommitMessageFromContext', () => {
     })
   })
 
-  it('does not expose unstructured raw CLI failure output', async () => {
+  it('exposes raw CLI failure output only after path sanitization', async () => {
     const result = await generateCommitMessageFromContext(
       {
         branch: 'main',
@@ -642,7 +642,7 @@ describe('generateCommitMessageFromContext', () => {
 
     expect(result).toEqual({
       success: false,
-      error: 'agent CLI command failed with code 1.'
+      error: 'agent CLI command failed with code 1: raw failure output with [path]'
     })
   })
 
@@ -673,7 +673,7 @@ describe('generateCommitMessageFromContext', () => {
 
     expect(result).toEqual({
       success: false,
-      error: 'agent CLI command failed: fatal: [path] failed'
+      error: 'agent CLI command failed with code 1: ERROR: fatal: [path] failed'
     })
   })
 
@@ -704,11 +704,11 @@ describe('generateCommitMessageFromContext', () => {
 
     expect(result).toEqual({
       success: false,
-      error: 'agent CLI command failed: failed at [path]'
+      error: 'agent CLI command failed with code 1: ERROR: failed at [path]'
     })
   })
 
-  it('treats empty stdout plus stderr on exit 0 as an agent CLI failure', async () => {
+  it('reports an empty result with the stderr excerpt when exit 0 produces no stdout', async () => {
     const result = await generateCommitMessageFromContext(
       {
         branch: 'main',
@@ -735,10 +735,275 @@ describe('generateCommitMessageFromContext', () => {
 
     expect(result).toEqual({
       success: false,
-      error: 'agent CLI command failed: No payment method'
+      error: 'agent returned an empty message. CLI output: Error: No payment method'
     })
   })
 
+  it('surfaces pi auth failure detail end-to-end through the adjusted path sanitizer', async () => {
+    const result = await generateCommitMessageFromContext(
+      {
+        branch: 'main',
+        stagedSummary: 'M\tREADME.md',
+        stagedPatch: '+hello'
+      },
+      {
+        agentId: 'pi',
+        model: 'github-copilot/gpt-5.5'
+      },
+      {
+        kind: 'remote',
+        cwd: '/repo',
+        missingBinaryLocation: 'remote PATH',
+        execute: async () => ({
+          stdout: '',
+          stderr: [
+            'No API key found for github-copilot.',
+            '',
+            'Use /login to log into a provider via OAuth or API key. See:',
+            '  /private/tmp/pi-exit1-repro/node_modules/@earendil-works/pi-coding-agent/docs/providers.md',
+            '  /private/tmp/pi-exit1-repro/node_modules/@earendil-works/pi-coding-agent/docs/models.md'
+          ].join('\n'),
+          exitCode: 1,
+          timedOut: false
+        })
+      }
+    )
+
+    expect(result).toEqual({
+      success: false,
+      error:
+        'Pi CLI command failed with code 1: No API key found for github-copilot. Use /login to log into a provider via OAuth or API key. See: … [path]'
+    })
+  })
+
+  it('preserves slash-commands while redacting multi-segment paths in failure detail', async () => {
+    const result = await generateCommitMessageFromContext(
+      {
+        branch: 'main',
+        stagedSummary: 'M\tREADME.md',
+        stagedPatch: '+hello'
+      },
+      {
+        agentId: 'custom',
+        model: '',
+        customAgentCommand: 'agent'
+      },
+      {
+        kind: 'remote',
+        cwd: '/repo',
+        missingBinaryLocation: 'remote PATH',
+        execute: async () => ({
+          stdout: '',
+          stderr: 'ERROR: run /login then check /Users/name/repo',
+          exitCode: 1,
+          timedOut: false
+        })
+      }
+    )
+
+    expect(result).toEqual({
+      success: false,
+      error: 'agent CLI command failed with code 1: ERROR: run /login then check [path]'
+    })
+  })
+
+  it('redacts a filesystem path embedded in a pi HTTP 401 payload', async () => {
+    const result = await generateCommitMessageFromContext(
+      {
+        branch: 'main',
+        stagedSummary: 'M\tREADME.md',
+        stagedPatch: '+hello'
+      },
+      {
+        agentId: 'pi',
+        model: 'github-copilot/gpt-5.5'
+      },
+      {
+        kind: 'remote',
+        cwd: '/repo',
+        missingBinaryLocation: 'remote PATH',
+        execute: async () => ({
+          stdout: '',
+          stderr: '401: {"message":"Invalid key loaded from /Users/name/.config/pi/auth.json"}',
+          exitCode: 1,
+          timedOut: false
+        })
+      }
+    )
+
+    expect(result).toEqual({
+      success: false,
+      error: 'Pi CLI command failed with code 1: 401: {"message":"Invalid key loaded from [path]"}'
+    })
+  })
+
+  it('redacts a Windows drive path with JSON-escaped backslashes in a payload', async () => {
+    const result = await generateCommitMessageFromContext(
+      {
+        branch: 'main',
+        stagedSummary: 'M\tREADME.md',
+        stagedPatch: '+hello'
+      },
+      {
+        agentId: 'pi',
+        model: 'github-copilot/gpt-5.5'
+      },
+      {
+        kind: 'remote',
+        cwd: '/repo',
+        missingBinaryLocation: 'remote PATH',
+        execute: async () => ({
+          stdout: '',
+          stderr: '401: {"message":"Invalid key loaded from C:\\\\Users\\\\name\\\\auth.json"}',
+          exitCode: 1,
+          timedOut: false
+        })
+      }
+    )
+
+    expect(result).toEqual({
+      success: false,
+      error: 'Pi CLI command failed with code 1: 401: {"message":"Invalid key loaded from [path]"}'
+    })
+  })
+
+  it('keeps a scheme:// remedy URL intact while still redacting paths', async () => {
+    const result = await generateCommitMessageFromContext(
+      {
+        branch: 'main',
+        stagedSummary: 'M\tREADME.md',
+        stagedPatch: '+hello'
+      },
+      {
+        agentId: 'pi',
+        model: 'github-copilot/gpt-5.5'
+      },
+      {
+        kind: 'remote',
+        cwd: '/repo',
+        missingBinaryLocation: 'remote PATH',
+        execute: async () => ({
+          stdout: '',
+          stderr:
+            '401: Visit https://console.anthropic.com/settings/keys then check /Users/name/.config/pi/auth.json',
+          exitCode: 1,
+          timedOut: false
+        })
+      }
+    )
+
+    expect(result).toEqual({
+      success: false,
+      error:
+        'Pi CLI command failed with code 1: 401: Visit https://console.anthropic.com/settings/keys then check [path]'
+    })
+  })
+
+  it('redacts key=/path shapes in provider bodies', async () => {
+    const result = await generateCommitMessageFromContext(
+      {
+        branch: 'main',
+        stagedSummary: 'M\tREADME.md',
+        stagedPatch: '+hello'
+      },
+      {
+        agentId: 'pi',
+        model: 'github-copilot/gpt-5.5'
+      },
+      {
+        kind: 'remote',
+        cwd: '/repo',
+        missingBinaryLocation: 'remote PATH',
+        execute: async () => ({
+          stdout: '',
+          stderr: '401: {"message":"rejected credential_path=/Users/name/.config/pi/auth.json"}',
+          exitCode: 1,
+          timedOut: false
+        })
+      }
+    )
+
+    expect(result).toEqual({
+      success: false,
+      error: 'Pi CLI command failed with code 1: 401: {"message":"rejected credential_path=[path]"}'
+    })
+  })
+
+  it.each([
+    [
+      'comma-prefixed list path',
+      '401: {"message":"candidate list a,/Users/name/creds rejected"}',
+      'Pi CLI command failed with code 1: 401: {"message":"candidate list a,[path] rejected"}'
+    ],
+    [
+      'non-drive colon-prefixed path',
+      '401: {"message":"slot 1:/Users/name/alt failed"}',
+      'Pi CLI command failed with code 1: 401: {"message":"slot 1:[path] failed"}'
+    ]
+  ])('redacts a %s in provider bodies', async (_shape, stderr, expected) => {
+    const result = await generateCommitMessageFromContext(
+      {
+        branch: 'main',
+        stagedSummary: 'M\tREADME.md',
+        stagedPatch: '+hello'
+      },
+      {
+        agentId: 'pi',
+        model: 'github-copilot/gpt-5.5'
+      },
+      {
+        kind: 'remote',
+        cwd: '/repo',
+        missingBinaryLocation: 'remote PATH',
+        execute: async () => ({
+          stdout: '',
+          stderr,
+          exitCode: 1,
+          timedOut: false
+        })
+      }
+    )
+
+    expect(result).toEqual({ success: false, error: expected })
+  })
+
+  it('passes the live colonless 400 gateway payload through the sanitizer unmangled', async () => {
+    const result = await generateCommitMessageFromContext(
+      {
+        branch: 'main',
+        stagedSummary: 'M\tREADME.md',
+        stagedPatch: '+hello'
+      },
+      {
+        agentId: 'pi',
+        model: 'github-copilot/gpt-5.5'
+      },
+      {
+        kind: 'remote',
+        cwd: '/repo',
+        missingBinaryLocation: 'remote PATH',
+        execute: async () => ({
+          stdout: '',
+          stderr:
+            '400 {"type":"error","error":{"type":"invalid_request_error","message":"Third-party apps now draw from your extra usage, not your plan limits. Add more at claude.ai/settings/usage and keep going."},"request_id":"req_011CcsZLJ5ZiLLNvpxcxDuU4"}',
+          exitCode: 1,
+          timedOut: false
+        })
+      }
+    )
+
+    expect(result.success).toBe(false)
+    if (result.success) {
+      throw new Error('expected a failure result')
+    }
+    expect(result.error.startsWith('Pi CLI command failed with code 1: 400 {"type":"error"')).toBe(
+      true
+    )
+    // The bare domain link survives path redaction so the remedy stays usable.
+    expect(result.error).toContain('claude.ai/settings/usage')
+    expect(result.error.length).toBeLessThanOrEqual(300)
+  })
+
   it('preserves the structured subject and body when formatting the final response', async () => {
     const result = await generateCommitMessageFromContext(
       {
@@ -1492,7 +1757,99 @@ describe('generateBranchNameFromContext', () => {
 
     expect(result).toEqual({
       success: false,
-      error: 'Generated branch name was empty after sanitization.'
+      error: 'Generated branch name was empty after sanitization.',
+      failureOutput: { label: 'agent', exitCode: 0, stdout: '!!! ___', stderr: '' }
+    })
+  })
+
+  it('carries the full CLI output on failures for the local on-demand view', async () => {
+    const result = await generateBranchNameFromContext(
+      { firstPrompt: 'Fix login flow' },
+      {
+        agentId: 'pi',
+        model: 'github-copilot/gpt-5.5'
+      },
+      {
+        kind: 'remote',
+        cwd: '/repo',
+        missingBinaryLocation: 'remote PATH',
+        execute: async () => ({
+          stdout: 'partial',
+          stderr: 'No API key found for github-copilot.',
+          exitCode: 1,
+          timedOut: false
+        })
+      }
+    )
+
+    expect(result.success).toBe(false)
+    if (result.success) {
+      throw new Error('expected a failure result')
+    }
+    expect(result.failureOutput).toEqual({
+      label: 'Pi',
+      exitCode: 1,
+      stdout: 'partial',
+      stderr: 'No API key found for github-copilot.'
+    })
+  })
+
+  it('does not persist stdout-only branch failure detail that may echo the prompt', async () => {
+    const result = await generateBranchNameFromContext(
+      { firstPrompt: 'Customer secret in the first prompt' },
+      {
+        agentId: 'custom',
+        model: '',
+        customAgentCommand: 'agent'
+      },
+      {
+        kind: 'remote',
+        cwd: '/repo',
+        missingBinaryLocation: 'remote PATH',
+        execute: async () => ({
+          stdout: 'Customer secret in the first prompt',
+          stderr: '',
+          exitCode: 1,
+          timedOut: false
+        })
+      }
+    )
+
+    expect(result).toEqual({
+      success: false,
+      error: 'agent CLI command failed with code 1.',
+      failureOutput: {
+        label: 'agent',
+        exitCode: 1,
+        stdout: 'Customer secret in the first prompt',
+        stderr: ''
+      }
+    })
+  })
+
+  it('describes a signal-terminated generator without a null exit code', async () => {
+    const result = await generateBranchNameFromContext(
+      { firstPrompt: 'Fix login flow' },
+      {
+        agentId: 'pi',
+        model: 'github-copilot/gpt-5.5'
+      },
+      {
+        kind: 'remote',
+        cwd: '/repo',
+        missingBinaryLocation: 'remote PATH',
+        execute: async () => ({
+          stdout: '',
+          stderr: 'Process killed by host',
+          exitCode: null,
+          timedOut: false
+        })
+      }
+    )
+
+    expect(result).toMatchObject({
+      success: false,
+      error: 'Pi CLI command was terminated before exiting: Process killed by host'
     })
   })
 
diff --git a/src/main/text-generation/commit-message-text-generation.ts b/src/main/text-generation/commit-message-text-generation.ts
index 28fce4a9b4e..ff0c9e5fc24 100644
--- a/src/main/text-generation/commit-message-text-generation.ts
+++ b/src/main/text-generation/commit-message-text-generation.ts
@@ -17,8 +17,12 @@ import {
 } from '../../shared/pull-request-generation'
 import {
   cleanGeneratedCommitMessage,
-  extractAgentErrorMessage
+  excerptAgentFailureOutput
 } from '../../shared/commit-message-prompt'
+import {
+  captureAgentGenerationFailureOutput,
+  type AgentGenerationFailureOutput
+} from './agent-failure-output'
 import {
   buildBranchNamePrompt,
   sanitizeBranchSlug,
@@ -108,7 +112,14 @@ type ResolveCommitMessageSettingsResult =
 
 type InternalTextGenerationResult =
   | { success: true; rawOutput: string; agentLabel?: string }
-  | { success: false; error: string; canceled?: boolean }
+  | {
+      success: false
+      error: string
+      canceled?: boolean
+      /** Bounded full CLI output for on-demand local display. Stripped from
+       *  every renderer-bound result so it never crosses IPC wholesale. */
+      failureOutput?: AgentGenerationFailureOutput
+    }
 
 export type CommitMessageModelDiscoveryLocalOptions = {
   cwd?: string
@@ -148,20 +159,29 @@ function formatAgentCliFailureMessage(
   stdout: string,
   stderr: string,
   exitCode: number | null,
-  options?: { includeLocalMacDnsHint?: boolean }
+  options?: { includeLocalMacDnsHint?: boolean; includeStdoutDetail?: boolean }
 ): string {
-  const detail = sanitizeAgentFailureDetail(extractAgentErrorMessage(stdout, stderr))
-  const message = detail
-    ? `${label} CLI command failed: ${detail}`
-    : `${label} CLI command failed with code ${exitCode}.`
+  const detail = sanitizeAgentFailureDetail(
+    excerptAgentFailureOutput(options?.includeStdoutDetail === false ? '' : stdout, stderr)
+  )
+  const message =
+    exitCode === null
+      ? detail
+        ? `${label} CLI command was terminated before exiting: ${detail}`
+        : `${label} CLI command was terminated before exiting.`
+      : detail
+        ? `${label} CLI command failed with code ${exitCode}: ${detail}`
+        : `${label} CLI command failed with code ${exitCode}.`
   return options?.includeLocalMacDnsHint === false
     ? message
     : 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}+/gu, ' ')
+    ?.replace(/[\p{Cc}\p{Cf}]+/gu, ' ')
     .replace(/\s+/g, ' ')
     .trim()
   if (!trimmed) {
@@ -174,11 +194,20 @@ function sanitizeAgentFailureDetail(detail: string | null): string | null {
       /\\\\[^\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,
+      /[A-Za-z]:(?:\\+|\/)(?:[^\s"'`<>\\/|:*?]+(?:\s+[^\s"'`<>\\/|:*?]+)*(?=[\\/])(?:\\+|\/))*[^\s"'`<>\\/|:*?]+/g,
       '[path]'
     )
-    .replace(/(^|[\s"'`(])\/(?:[^\s"'`<>/]+(?:\s+[^\s"'`<>/]+)*(?=\/)\/)*[^\s"'`<>/]+/g, '$1[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
 }
 
@@ -661,7 +690,15 @@ async function runLocalPlan(
         })
         return
       }
-      finalizeFromAgentOutput({ code, stdout, stderr, label, emptyResultName, finalize })
+      finalizeFromAgentOutput({
+        code,
+        stdout,
+        stderr,
+        label,
+        emptyResultName,
+        finalize,
+        includeStdoutDetail: operation !== 'branch-name'
+      })
     }
     child.stdout?.on('data', onStdoutData)
     child.stderr?.on('data', onStderrData)
@@ -686,8 +723,18 @@ function finalizeFromAgentOutput(args: {
   emptyResultName: string
   finalize: (result: InternalTextGenerationResult) => void
   includeLocalMacDnsHint?: boolean
+  includeStdoutDetail?: boolean
 }): void {
-  const { code, stdout, stderr, label, emptyResultName, finalize, includeLocalMacDnsHint } = args
+  const {
+    code,
+    stdout,
+    stderr,
+    label,
+    emptyResultName,
+    finalize,
+    includeLocalMacDnsHint,
+    includeStdoutDetail
+  } = args
   if (code !== 0) {
     console.error('[commit-message] Generator failed:', {
       label,
@@ -698,30 +745,34 @@ function finalizeFromAgentOutput(args: {
     finalize({
       success: false,
       error: formatAgentCliFailureMessage(label, stdout, stderr, code, {
-        includeLocalMacDnsHint
-      })
+        includeLocalMacDnsHint,
+        includeStdoutDetail
+      }),
+      failureOutput: captureAgentGenerationFailureOutput(label, code, stdout, stderr) ?? undefined
     })
     return
   }
   const cleaned = cleanGeneratedCommitMessage(stdout)
   if (!cleaned) {
-    const detail = sanitizeAgentFailureDetail(extractAgentErrorMessage(stdout, stderr))
+    // stdout is the (empty) result here, not diagnostics, so only stderr is
+    // excerpted. The run exited 0, so this stays "returned an empty result"
+    // rather than misreporting a command failure.
+    const detail = sanitizeAgentFailureDetail(excerptAgentFailureOutput('', stderr))
     if (detail) {
-      console.error('[commit-message] Generator returned no stdout but reported an error:', {
+      console.error('[commit-message] Generator returned no stdout but wrote to stderr:', {
         label,
         exitCode: code,
         stdout,
         stderr
       })
-      finalize({
-        success: false,
-        error: formatAgentCliFailureMessage(label, stdout, stderr, code, {
-          includeLocalMacDnsHint
-        })
-      })
-      return
     }
-    finalize({ success: false, error: `${label} returned an empty ${emptyResultName}.` })
+    finalize({
+      success: false,
+      error: detail
+        ? `${label} returned an empty ${emptyResultName}. CLI output: ${detail}`
+        : `${label} returned an empty ${emptyResultName}.`,
+      failureOutput: captureAgentGenerationFailureOutput(label, code, stdout, stderr) ?? undefined
+    })
     return
   }
   finalize({
@@ -786,7 +837,9 @@ async function runRemotePlan(
       emptyResultName,
       finalize: resolve,
       // Why: remote agent output reflects the SSH target, not this Mac's DNS.
-      includeLocalMacDnsHint: false
+      includeLocalMacDnsHint: false,
+      // Branch failures persist into synced metadata; stdout may echo the prompt.
+      includeStdoutDetail: operation !== 'branch-name'
     })
   })
 }
@@ -795,7 +848,8 @@ function formatCommitMessageGenerationResult(
   result: InternalTextGenerationResult
 ): GenerateCommitMessageResult {
   if (!result.success) {
-    return result
+    // Keep the bulky local-only capture off the renderer-bound payload.
+    return { success: false, error: result.error, canceled: result.canceled }
   }
   let commitMessage: GeneratedCommitMessage
   try {
@@ -853,8 +907,11 @@ function formatPullRequestFieldsGenerationResult(
   context: PullRequestDraftContext
 ): GeneratePullRequestFieldsResult {
   if (!result.success) {
+    // Keep the bulky local-only capture off the renderer-bound payload.
     return {
-      ...result,
+      success: false,
+      error: result.error,
+      canceled: result.canceled,
       branchChangedByPreparation: context.branchChangedByPreparation
     }
   }
@@ -918,7 +975,12 @@ export async function generatePullRequestFieldsFromContext(
 
 export type GenerateBranchNameResult =
   | { success: true; slug: string; agentLabel?: string }
-  | { success: false; error: string; canceled?: boolean }
+  | {
+      success: false
+      error: string
+      canceled?: boolean
+      failureOutput?: AgentGenerationFailureOutput
+    }
 
 /**
  * Generate a short kebab-case branch name from the work the agent is starting.
@@ -960,7 +1022,14 @@ export async function generateBranchNameFromContext(
   }
   const slug = sanitizeBranchSlug(internalResult.rawOutput)
   if (!slug) {
-    return { success: false, error: 'Generated branch name was empty after sanitization.' }
+    return {
+      success: false,
+      error: 'Generated branch name was empty after sanitization.',
+      // What the model actually returned is the whole diagnosis here.
+      failureOutput:
+        captureAgentGenerationFailureOutput(planned.plan.label, 0, internalResult.rawOutput, '') ??
+        undefined
+    }
   }
   return { success: true, slug, agentLabel: internalResult.agentLabel }
 }
diff --git a/src/main/update-install-exit-watchdog.test.ts b/src/main/update-install-exit-watchdog.test.ts
new file mode 100644
index 00000000000..80457230a3c
--- /dev/null
+++ b/src/main/update-install-exit-watchdog.test.ts
@@ -0,0 +1,72 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { appMock, recordUpdaterLifecycleMock } = vi.hoisted(() => ({
+  appMock: { exit: vi.fn() },
+  recordUpdaterLifecycleMock: vi.fn()
+}))
+
+vi.mock('electron', () => ({ app: appMock }))
+vi.mock('./updater-lifecycle-diagnostics', () => ({
+  recordUpdaterLifecycle: recordUpdaterLifecycleMock
+}))
+
+import {
+  armUpdateInstallExitWatchdog,
+  disarmUpdateInstallExitWatchdog,
+  UPDATE_INSTALL_EXIT_TIMEOUT_MS
+} from './update-install-exit-watchdog'
+
+describe('update install exit watchdog', () => {
+  beforeEach(() => {
+    vi.useFakeTimers()
+    appMock.exit.mockClear()
+    recordUpdaterLifecycleMock.mockClear()
+  })
+
+  afterEach(() => {
+    disarmUpdateInstallExitWatchdog()
+    vi.useRealTimers()
+  })
+
+  it('force-exits with code 0 when the deadline passes', () => {
+    armUpdateInstallExitWatchdog()
+
+    vi.advanceTimersByTime(UPDATE_INSTALL_EXIT_TIMEOUT_MS - 1)
+    expect(appMock.exit).not.toHaveBeenCalled()
+
+    vi.advanceTimersByTime(1)
+    expect(appMock.exit).toHaveBeenCalledExactlyOnceWith(0)
+    expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith(
+      'install_exit_watchdog_fired',
+      { timeoutMs: UPDATE_INSTALL_EXIT_TIMEOUT_MS },
+      expect.objectContaining({ level: 'warn' })
+    )
+  })
+
+  it('re-arming does not extend the original deadline', () => {
+    armUpdateInstallExitWatchdog()
+    vi.advanceTimersByTime(UPDATE_INSTALL_EXIT_TIMEOUT_MS - 1)
+
+    armUpdateInstallExitWatchdog()
+    vi.advanceTimersByTime(1)
+
+    expect(appMock.exit).toHaveBeenCalledExactlyOnceWith(0)
+  })
+
+  it('disarm cancels the forced exit', () => {
+    armUpdateInstallExitWatchdog()
+    disarmUpdateInstallExitWatchdog()
+
+    vi.advanceTimersByTime(UPDATE_INSTALL_EXIT_TIMEOUT_MS * 2)
+    expect(appMock.exit).not.toHaveBeenCalled()
+  })
+
+  it('can be armed again after a disarm (install recovery then retry)', () => {
+    armUpdateInstallExitWatchdog()
+    disarmUpdateInstallExitWatchdog()
+    armUpdateInstallExitWatchdog()
+
+    vi.advanceTimersByTime(UPDATE_INSTALL_EXIT_TIMEOUT_MS)
+    expect(appMock.exit).toHaveBeenCalledExactlyOnceWith(0)
+  })
+})
diff --git a/src/main/update-install-exit-watchdog.ts b/src/main/update-install-exit-watchdog.ts
new file mode 100644
index 00000000000..69986d1154e
--- /dev/null
+++ b/src/main/update-install-exit-watchdog.ts
@@ -0,0 +1,51 @@
+import { app } from 'electron'
+import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics'
+
+// Why 20s: comfortably above a healthy shutdown (renderer buffer capture,
+// daemon final checkpoints, bounded 2s telemetry flush) but bounded — a wedged
+// teardown otherwise keeps the old process alive forever and the installer
+// never replaces the bundle or relaunches (issue #4438).
+export const UPDATE_INSTALL_EXIT_TIMEOUT_MS = 20_000
+
+let exitTimer: ReturnType | null = null
+
+/**
+ * Guarantee the old app process exits once an update install has committed.
+ *
+ * Squirrel's ShipIt on macOS (and the Win/Linux installers) wait for the old
+ * app process to exit before installing the staged update and relaunching.
+ * The normal quit path defers exit behind unbounded async teardown — daemon
+ * checkpoint RPCs (30s timeout each, per session), SSH disconnects, watcher
+ * and emulator shutdown. If any of those wedge, the app looks closed (windows
+ * gone) but the process survives, ShipIt stalls, and the update never applies.
+ * Once armed, the process force-exits after the deadline if it is still alive.
+ */
+export function armUpdateInstallExitWatchdog(timeoutMs = UPDATE_INSTALL_EXIT_TIMEOUT_MS): void {
+  if (exitTimer) {
+    return
+  }
+  exitTimer = setTimeout(() => {
+    recordUpdaterLifecycle(
+      'install_exit_watchdog_fired',
+      { timeoutMs },
+      {
+        level: 'warn',
+        message: `Shutdown did not finish within ${timeoutMs}ms of committing the update install; forcing exit so the installer can relaunch`
+      }
+    )
+    // Why exit(0): the quit is already committed and cleanup is wedged, not
+    // failed — a clean code keeps ShipIt/launchd on the normal relaunch path.
+    app.exit(0)
+  }, timeoutMs)
+  // Why unref: the watchdog must never be the thing keeping the process alive.
+  exitTimer.unref?.()
+}
+
+/** Cancel the watchdog when install recovery re-opens the app (pre-commit
+ * native errors on macOS reset quit state and keep the session running). */
+export function disarmUpdateInstallExitWatchdog(): void {
+  if (exitTimer) {
+    clearTimeout(exitTimer)
+    exitTimer = null
+  }
+}
diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts
index f9905ae1824..0bed8d0a3ce 100644
--- a/src/main/updater.test.ts
+++ b/src/main/updater.test.ts
@@ -130,6 +130,16 @@ vi.mock('./updater-nudge', () => ({
   shouldApplyNudge: shouldApplyNudgeMock
 }))
 
+const { armExitWatchdogMock, disarmExitWatchdogMock } = vi.hoisted(() => ({
+  armExitWatchdogMock: vi.fn(),
+  disarmExitWatchdogMock: vi.fn()
+}))
+
+vi.mock('./update-install-exit-watchdog', () => ({
+  armUpdateInstallExitWatchdog: armExitWatchdogMock,
+  disarmUpdateInstallExitWatchdog: disarmExitWatchdogMock
+}))
+
 const { fetchNewerReleaseTagsMock } = vi.hoisted(() => ({
   fetchNewerReleaseTagsMock: vi.fn()
 }))
@@ -158,6 +168,8 @@ describe('updater', () => {
     appMock.isPackaged = true
     isMock.dev = false
     killAllPtyMock.mockReset()
+    armExitWatchdogMock.mockReset()
+    disarmExitWatchdogMock.mockReset()
     powerMonitorOnMock.mockReset()
     fetchNudgeMock.mockReset().mockResolvedValue(null)
     shouldApplyNudgeMock.mockReset().mockReturnValue(false)
@@ -1339,6 +1351,79 @@ describe('updater', () => {
     expect(sendMock).not.toHaveBeenCalled()
   })
 
+  it('arms the forced-exit watchdog once the install commits', async () => {
+    const sendMock = vi.fn()
+    const mainWindow = { webContents: { send: sendMock } }
+    fetchNewerReleaseTagsMock.mockResolvedValue({ tags: ['v1.0.61'], state: 'ready' })
+    autoUpdaterMock.checkForUpdates.mockImplementation(() => {
+      autoUpdaterMock.emit('checking-for-update')
+      queueMicrotask(() => {
+        autoUpdaterMock.emit('update-available', { version: '1.0.61' })
+      })
+      return Promise.resolve(undefined)
+    })
+
+    const { setupAutoUpdater, checkForUpdatesFromMenu, quitAndInstall } = await import('./updater')
+
+    setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
+    checkForUpdatesFromMenu()
+
+    await vi.waitFor(() => {
+      expect(sendMock).toHaveBeenCalledWith('updater:status', {
+        state: 'available',
+        version: '1.0.61',
+        changelog: null
+      })
+    })
+
+    autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' })
+    // Why: on macOS install only commits once Squirrel is ready; mark it ready
+    // so this test covers the committed path on all platforms.
+    if (process.platform === 'darwin') {
+      const nativeDownloadedHandler = nativeUpdaterMock.on.mock.calls.find(
+        ([eventName]) => eventName === 'update-downloaded'
+      )?.[1] as (() => void) | undefined
+      expect(nativeDownloadedHandler).toBeTypeOf('function')
+      nativeDownloadedHandler?.()
+    }
+
+    expect(armExitWatchdogMock).not.toHaveBeenCalled()
+
+    quitAndInstall()
+    await vi.waitFor(() => {
+      expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
+    })
+
+    // Why: the installer (ShipIt/NSIS) waits for this process to exit; the
+    // watchdog guarantees a wedged async shutdown cannot strand the update.
+    expect(armExitWatchdogMock).toHaveBeenCalledTimes(1)
+  })
+
+  it('disarms the forced-exit watchdog when sync install error recovery keeps the app open', async () => {
+    vi.useFakeTimers()
+
+    autoUpdaterMock.quitAndInstall.mockImplementation(() => {
+      autoUpdaterMock.emit(
+        'error',
+        new Error("No update filepath provided, can't quit and install")
+      )
+    })
+
+    const mainWindow = { webContents: { send: vi.fn() } }
+    const { setupAutoUpdater, quitAndInstall, isQuittingForUpdate } = await import('./updater')
+
+    setupAutoUpdater(mainWindow as never)
+    quitAndInstall()
+
+    await vi.advanceTimersByTimeAsync(100)
+
+    expect(isQuittingForUpdate()).toBe(false)
+    // Why: recovery leaves the app running — a live watchdog would force-exit
+    // a healthy session 20s later.
+    expect(armExitWatchdogMock).not.toHaveBeenCalled()
+    expect(disarmExitWatchdogMock).toHaveBeenCalled()
+  })
+
   it('does not treat pre-native autoUpdater errors as quitAndInstall recovery', async () => {
     vi.useFakeTimers()
 
diff --git a/src/main/updater.ts b/src/main/updater.ts
index 5bb2340fbf9..290100f0f63 100644
--- a/src/main/updater.ts
+++ b/src/main/updater.ts
@@ -13,6 +13,10 @@ import {
   markMacQuitAndInstallInFlight,
   resetMacInstallState
 } from './updater-mac-install'
+import {
+  armUpdateInstallExitWatchdog,
+  disarmUpdateInstallExitWatchdog
+} from './update-install-exit-watchdog'
 import { registerAutoUpdaterHandlers } from './updater-events'
 import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics'
 import {
@@ -635,6 +639,10 @@ async function performQuitAndInstall(): Promise {
       // still recover flags (PTYs may already be dead — residual OK).
       if (process.platform !== 'darwin' || isMacInstallerReady()) {
         updateInstallCommitted = true
+        // Why: past this point recovery is forbidden and the installer waits
+        // for this process to exit; a wedged async shutdown would otherwise
+        // strand the user with no app and no update (#4438).
+        armUpdateInstallExitWatchdog()
       }
     })
   } catch (error) {
@@ -658,6 +666,7 @@ function resetQuitForUpdateState(): void {
   quittingForUpdate = false
   updateInstallCommitted = false
   quitAndInstallNativeInvoked = false
+  disarmUpdateInstallExitWatchdog()
   resetMacInstallState()
 }
 
diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts
index 718711b629d..d68c315d6e1 100644
--- a/src/main/window/attach-main-window-services.test.ts
+++ b/src/main/window/attach-main-window-services.test.ts
@@ -202,15 +202,14 @@ describe('attachMainWindowServices', () => {
   it('reloads the app renderer through main and marks expected renderer teardown', async () => {
     const onBeforeRendererReload = vi.fn()
     const mainWindow = createMainWindow()
+    const store = createStore()
+    const runtime = createRuntime()
 
-    attachMainWindowServices(
-      mainWindow as never,
-      createStore(),
-      createRuntime() as never,
-      undefined,
-      undefined,
-      { onBeforeRendererReload }
-    )
+    attachMainWindowServices(mainWindow as never, store, runtime as never, undefined, undefined, {
+      onBeforeRendererReload
+    })
+
+    expect(registerRepoHandlersMock).toHaveBeenCalledWith(mainWindow, store, runtime)
 
     expect(removeHandlerMock).toHaveBeenCalledWith('app:reload')
     const reloadHandler = handleMock.mock.calls.find(([channel]) => channel === 'app:reload')?.[1]
@@ -631,13 +630,20 @@ describe('attachMainWindowServices', () => {
     const notifier = runtime.setNotifier.mock.calls[0][0] as {
       revealTerminalSession: (
         worktreeId: string,
-        opts: { ptyId: string; title?: string; cwd?: string; activate?: boolean }
+        opts: {
+          ptyId: string
+          title?: string
+          cwd?: string
+          viewMode?: 'terminal' | 'chat'
+          activate?: boolean
+        }
       ) => Promise<{ tabId: string; title?: string }>
     }
     const revealPromise = notifier.revealTerminalSession('wt-1', {
       ptyId: 'pty-1',
       title: 'SSH tmux',
-      cwd: '/repo/packages/web'
+      cwd: '/repo/packages/web',
+      viewMode: 'chat'
     })
     const sentPayload = sendMock.mock.calls.find(
       ([channel]) => channel === 'ui:createTerminal'
@@ -645,7 +651,7 @@ describe('attachMainWindowServices', () => {
     const handler = onMock.mock.calls.find(
       ([channel]) => channel === 'terminal:tabCreateReply'
     )?.[1]
-    expect(sentPayload.cwd).toBe('/repo/packages/web')
+    expect(sentPayload).toMatchObject({ cwd: '/repo/packages/web', viewMode: 'chat' })
 
     handler?.(
       { sender: { send: vi.fn() } },
diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts
index cc03c40a904..7fbde11c94e 100644
--- a/src/main/window/attach-main-window-services.ts
+++ b/src/main/window/attach-main-window-services.ts
@@ -81,7 +81,7 @@ export function attachMainWindowServices(
   }
 ): void {
   registerAppReloadHandler(mainWindow, options?.onBeforeRendererReload)
-  registerRepoHandlers(mainWindow, store)
+  registerRepoHandlers(mainWindow, store, runtime)
   registerWorktreeHandlers(mainWindow, store, runtime)
   // Why: repo/settings mutations resync watchers through this attached main-window context.
   setWorktreeBaseDirectoryWatcherSyncContext(store, mainWindow)
@@ -328,6 +328,7 @@ function registerRuntimeWindowLifecycle(
           ...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}),
           ...(opts.launchToken ? { launchToken: opts.launchToken } : {}),
           ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}),
+          ...(opts.viewMode ? { viewMode: opts.viewMode } : {}),
           activate: opts.activate !== false,
           ...(opts.presentation ? { presentation: opts.presentation } : {}),
           // Why: pre-minted tabId from main keeps the renderer's tab id aligned
diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts
index 54b4728f7cd..8899ba81bbf 100644
--- a/src/main/window/createMainWindow.test.ts
+++ b/src/main/window/createMainWindow.test.ts
@@ -2837,11 +2837,39 @@ describe('createMainWindow', () => {
     })
   })
 
-  it('does not install the startup reveal fallback off Windows', () => {
+  it('reveals the startup window on Linux when ready-to-show never fires', () => {
     vi.useFakeTimers()
     const { browserWindowInstance } = createStartupRevealWindowFixture()
 
     withPlatform('linux', () => {
+      createMainWindow(null)
+      vi.advanceTimersByTime(9_999)
+      expect(browserWindowInstance.show).not.toHaveBeenCalled()
+
+      vi.advanceTimersByTime(1)
+
+      expect(browserWindowInstance.show).toHaveBeenCalledTimes(1)
+    })
+  })
+
+  it('cancels the Linux startup reveal fallback after ready-to-show', () => {
+    vi.useFakeTimers()
+    const { browserWindowInstance, windowHandlers } = createStartupRevealWindowFixture()
+
+    withPlatform('linux', () => {
+      createMainWindow(null)
+      windowHandlers['ready-to-show']()
+      vi.advanceTimersByTime(10_000)
+
+      expect(browserWindowInstance.show).toHaveBeenCalledTimes(1)
+    })
+  })
+
+  it('does not install the startup reveal fallback on macOS', () => {
+    vi.useFakeTimers()
+    const { browserWindowInstance } = createStartupRevealWindowFixture()
+
+    withPlatform('darwin', () => {
       createMainWindow(null)
       vi.advanceTimersByTime(10_000)
 
@@ -2901,6 +2929,57 @@ describe('createMainWindow', () => {
     })
   })
 
+  it('keeps the headless E2E window hidden when the Linux fallback fires', () => {
+    vi.useFakeTimers()
+    const previousHeadless = process.env.ORCA_E2E_HEADLESS
+    process.env.ORCA_E2E_HEADLESS = '1'
+    const { browserWindowInstance } = createStartupRevealWindowFixture()
+
+    try {
+      withPlatform('linux', () => {
+        createMainWindow(createStartupRevealStore(true) as never)
+        vi.advanceTimersByTime(10_000)
+
+        expect(browserWindowInstance.show).not.toHaveBeenCalled()
+        expect(browserWindowInstance.maximize).not.toHaveBeenCalled()
+      })
+    } finally {
+      if (previousHeadless === undefined) {
+        delete process.env.ORCA_E2E_HEADLESS
+      } else {
+        process.env.ORCA_E2E_HEADLESS = previousHeadless
+      }
+    }
+  })
+
+  it('clears the Linux startup reveal fallback when the window is closed', () => {
+    vi.useFakeTimers()
+    const { browserWindowInstance, windowHandlers } = createStartupRevealWindowFixture()
+
+    withPlatform('linux', () => {
+      createMainWindow(createStartupRevealStore(true) as never)
+      windowHandlers.closed()
+      vi.advanceTimersByTime(10_000)
+
+      expect(browserWindowInstance.show).not.toHaveBeenCalled()
+      expect(browserWindowInstance.maximize).not.toHaveBeenCalled()
+    })
+  })
+
+  it('does not show or maximize a destroyed window when the Linux fallback fires', () => {
+    vi.useFakeTimers()
+    const { browserWindowInstance } = createStartupRevealWindowFixture()
+
+    withPlatform('linux', () => {
+      createMainWindow(createStartupRevealStore(true) as never)
+      browserWindowInstance.isDestroyed.mockReturnValue(true)
+      vi.advanceTimersByTime(10_000)
+
+      expect(browserWindowInstance.show).not.toHaveBeenCalled()
+      expect(browserWindowInstance.maximize).not.toHaveBeenCalled()
+    })
+  })
+
   describe('system resume relay', () => {
     function setupResumeWindow() {
       const windowHandlers: Record void> = {}
diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts
index ff66b5ebc16..52c84ec6048 100644
--- a/src/main/window/createMainWindow.ts
+++ b/src/main/window/createMainWindow.ts
@@ -337,10 +337,11 @@ export function createMainWindow(
   // the window back to full-screen after the user already resized it (#591).
   let handledInitialReadyToShow = false
   let initialRevealFallbackTimer: ReturnType | null =
-    process.platform === 'win32'
+    process.platform === 'win32' || process.platform === 'linux'
       ? setTimeout(() => {
-          // Why: GPU/driver failures on Windows can prevent ready-to-show forever,
-          // leaving the only app window hidden while the main process stays alive.
+          // Why: GPU/driver failures on Windows and Linux/X11 can prevent
+          // ready-to-show forever, leaving the only app window hidden while the
+          // main process stays alive (#8421).
           initialRevealFallbackTimer = null
           revealInitialWindow()
         }, 10_000)
diff --git a/src/main/wsl.test.ts b/src/main/wsl.test.ts
index fdb5d01a22c..597b1e795f4 100644
--- a/src/main/wsl.test.ts
+++ b/src/main/wsl.test.ts
@@ -1,7 +1,8 @@
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import type * as childProcess from 'node:child_process'
 
-const { execFileSyncMock } = vi.hoisted(() => ({
+const { execFileMock, execFileSyncMock } = vi.hoisted(() => ({
+  execFileMock: vi.fn(),
   execFileSyncMock: vi.fn()
 }))
 
@@ -9,11 +10,21 @@ vi.mock('child_process', async (importOriginal) => {
   const actual = await importOriginal()
   return {
     ...actual,
+    execFile: execFileMock,
     execFileSync: execFileSyncMock
   }
 })
 
-import { toLinuxPath, toWindowsWslPath, parseWslPath, wslUncDirectoryExists } from './wsl'
+import {
+  _resetWslCachesForTests,
+  getCachedWslDistros,
+  listWslDistros,
+  listWslDistrosAsync,
+  parseWslPath,
+  toLinuxPath,
+  toWindowsWslPath,
+  wslUncDirectoryExists
+} from './wsl'
 
 function withPlatform(value: NodeJS.Platform, fn: () => T): T {
   const original = process.platform
@@ -25,6 +36,70 @@ function withPlatform(value: NodeJS.Platform, fn: () => T): T {
   }
 }
 
+async function withPlatformAsync(value: NodeJS.Platform, fn: () => Promise): Promise {
+  const original = process.platform
+  Object.defineProperty(process, 'platform', { configurable: true, value })
+  try {
+    return await fn()
+  } finally {
+    Object.defineProperty(process, 'platform', { configurable: true, value: original })
+  }
+}
+
+describe('WSL distro discovery cache', () => {
+  afterEach(() => {
+    execFileMock.mockReset()
+    execFileSyncMock.mockReset()
+    _resetWslCachesForTests()
+  })
+
+  it('retries asynchronous discovery after a transient wsl.exe failure', async () => {
+    vi.useFakeTimers()
+    execFileMock
+      .mockImplementationOnce((_command, _args, _options, callback) => {
+        callback(new Error('transient failure'), '')
+      })
+      .mockImplementationOnce((_command, _args, _options, callback) => {
+        callback(null, 'Ubuntu\n')
+      })
+
+    try {
+      await withPlatformAsync('win32', async () => {
+        await expect(listWslDistrosAsync()).resolves.toEqual([])
+        expect(getCachedWslDistros()).toBeNull()
+        // Brief negative caching bounds the wsl.exe spawn rate between retries.
+        await expect(listWslDistrosAsync()).resolves.toEqual([])
+        expect(execFileMock).toHaveBeenCalledTimes(1)
+        vi.advanceTimersByTime(15_000)
+        await expect(listWslDistrosAsync()).resolves.toEqual(['Ubuntu'])
+      })
+    } finally {
+      vi.useRealTimers()
+    }
+  })
+
+  it('retries synchronous discovery after a transient wsl.exe failure', () => {
+    vi.useFakeTimers()
+    execFileSyncMock.mockImplementationOnce(() => {
+      throw new Error('transient failure')
+    })
+    execFileSyncMock.mockReturnValueOnce('Ubuntu\n')
+
+    try {
+      withPlatform('win32', () => {
+        expect(listWslDistros()).toEqual([])
+        expect(getCachedWslDistros()).toBeNull()
+        expect(listWslDistros()).toEqual([])
+        expect(execFileSyncMock).toHaveBeenCalledTimes(1)
+        vi.advanceTimersByTime(15_000)
+        expect(listWslDistros()).toEqual(['Ubuntu'])
+      })
+    } finally {
+      vi.useRealTimers()
+    }
+  })
+})
+
 describe('wsl path helpers', () => {
   it('parses WSL UNC paths on Windows', () => {
     const originalPlatform = process.platform
diff --git a/src/main/wsl.ts b/src/main/wsl.ts
index d668a064f66..1d27b694d2c 100644
--- a/src/main/wsl.ts
+++ b/src/main/wsl.ts
@@ -113,6 +113,12 @@ export function toWindowsWslPath(linuxPath: string, distro: string): string {
 
 const wslHomeCache = new Map()
 let wslDistroCache: string[] | null = null
+// Why: a wsl.exe failure must stay retryable (a transient error would
+// otherwise hide every distro until restart), but repeated failures cannot
+// re-spawn a blocking wsl.exe on every caller; brief negative caching bounds
+// the spawn rate on machines where WSL is absent or persistently broken.
+const WSL_DISTRO_LIST_FAILURE_TTL_MS = 15_000
+let wslDistroListFailedUntilMs = 0
 
 function normalizeWslListOutput(output: string): string[] {
   // Why: wsl.exe can emit UTF-16-looking NUL bytes when inherited through
@@ -138,6 +144,10 @@ export function listWslDistros(): string[] {
     return wslDistroCache
   }
 
+  if (Date.now() < wslDistroListFailedUntilMs) {
+    return []
+  }
+
   try {
     const output = execFileSync('wsl.exe', ['--list', '--quiet'], {
       encoding: 'utf-8',
@@ -147,8 +157,8 @@ export function listWslDistros(): string[] {
     wslDistroCache = normalizeWslListOutput(output).filter(isUserWslDistro)
     return wslDistroCache
   } catch {
-    wslDistroCache = []
-    return wslDistroCache
+    wslDistroListFailedUntilMs = Date.now() + WSL_DISTRO_LIST_FAILURE_TTL_MS
+    return []
   }
 }
 
@@ -162,13 +172,17 @@ export async function listWslDistrosAsync(): Promise {
     return wslDistroCache
   }
 
+  if (Date.now() < wslDistroListFailedUntilMs) {
+    return []
+  }
+
   try {
     const output = await execFileUtf8('wsl.exe', ['--list', '--quiet'])
     wslDistroCache = normalizeWslListOutput(output).filter(isUserWslDistro)
     return wslDistroCache
   } catch {
-    wslDistroCache = []
-    return wslDistroCache
+    wslDistroListFailedUntilMs = Date.now() + WSL_DISTRO_LIST_FAILURE_TTL_MS
+    return []
   }
 }
 
@@ -279,6 +293,7 @@ export function getCachedWslAvailability(): boolean | null {
 export function _resetWslCachesForTests(): void {
   wslHomeCache.clear()
   wslDistroCache = null
+  wslDistroListFailedUntilMs = 0
   wslAvailableCache = null
 }
 
diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts
index 4babcd587bb..62513ca0f96 100644
--- a/src/preload/api-types.ts
+++ b/src/preload/api-types.ts
@@ -202,6 +202,7 @@ import type {
   UpdateStatus,
   Worktree,
   WorktreeBaseStatusEvent,
+  WorktreeHeadIdentity,
   WorktreeLineage,
   WorkspaceLineage,
   WorktreeMeta,
@@ -666,7 +667,11 @@ export type PtyManagementApi = {
   // `degraded` is true when the daemon is alive but cannot spawn fresh PTYs, so
   // new terminals run on the local provider without daemon persistence.
   listSessions: () => Promise<{ sessions: PtyManagementSession[]; degraded: boolean }>
-  killAll: () => Promise<{ killedCount: number; remainingCount: number }>
+  killAll: () => Promise<{
+    killedCount: number
+    remainingCount: number
+    killedSessionIds?: string[]
+  }>
   killOne: (args: { sessionId: string }) => Promise<{ success: boolean }>
   restart: () => Promise<{ success: boolean }>
 }
@@ -812,7 +817,11 @@ export type AiVaultApi = {
   onWindowFocused: (callback: () => void) => () => void
 }
 
-export type NativeChatReadSessionResult = { messages: NativeChatMessage[] } | { error: string }
+// notFound marks a miss caused by the transcript not existing on disk yet
+// (retry-worthy), as opposed to a real read/parse error (#8401).
+export type NativeChatReadSessionResult =
+  | { messages: NativeChatMessage[] }
+  | { error: string; notFound?: true }
 
 /** Messages appended to a live-tailed transcript since the previous emit. */
 export type NativeChatAppendedMessages = NativeChatMessage[]
@@ -1175,7 +1184,14 @@ export type PreloadApi = {
       noParent?: boolean
     }) => Promise
     persistSortOrder: (args: { orderedIds: string[] }) => Promise
+    /** Full CLI output of the last branch auto-rename generation failure, held
+     *  in main memory only — null after a restart or once the failure clears. */
+    getBranchRenameFailureOutput: (args: { worktreeId: string }) => Promise
     onChanged: (callback: (data: { repoId: string }) => void) => () => void
+    onGitStatusMetadataChanged: (callback: (data: { repoId: string }) => void) => () => void
+    onHeadIdentitiesChanged: (
+      callback: (data: { repoId: string; identities: WorktreeHeadIdentity[] }) => void
+    ) => () => void
     onBaseStatus: (callback: (data: WorktreeBaseStatusEvent) => void) => () => void
     onRemoteBranchConflict: (
       callback: (data: WorktreeRemoteBranchConflictEvent) => void
@@ -1260,7 +1276,14 @@ export type PreloadApi = {
     reportGeometry: (id: string, cols: number, rows: number) => void
     signal: (id: string, signal: string) => void
     clearBuffer: (id: string) => void
-    kill: (id: string, opts?: { keepHistory?: boolean }) => Promise
+    kill: (
+      id: string,
+      opts?: {
+        keepHistory?: boolean
+        expectedPaneKey?: string
+        expectedTabId?: string
+      }
+    ) => Promise
     ackColdRestore: (id: string) => void
     ackData: (id: string, charCount: number, processedChars?: number) => void
     onDeliveryResyncRequest: (callback: (payload: { requestId: number }) => void) => () => void
@@ -2070,6 +2093,7 @@ export type PreloadApi = {
      *  IPC — call sparingly. */
     getSync: () => GlobalSettings | null
     set: (args: Partial) => Promise
+    updatePRBotAuthorOverride: (args: { author: string; isBot: boolean }) => Promise
     listFonts: () => Promise
     previewGhosttyImport: () => Promise
     previewWarpThemeImport: (source: WarpThemeImportSource) => Promise
@@ -2787,6 +2811,7 @@ export type PreloadApi = {
         launchConfig?: SleepingAgentLaunchConfig
         launchToken?: string
         launchAgent?: TuiAgent
+        viewMode?: 'terminal' | 'chat'
         title?: string
         ptyId?: string
         activate?: boolean
@@ -3111,6 +3136,14 @@ export type PreloadApi = {
     /** 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. */
+    retirePaneAuthority: (paneKey: string) => void
+    /** Move hook authority when a live pane is detached into another tab. */
+    transferPaneAuthority: (args: {
+      fromPaneKey: string
+      toPaneKey: string
+      ptyId?: string
+    }) => void
   }
   mobile: {
     listNetworkInterfaces: () => Promise<{
@@ -3126,6 +3159,21 @@ export type PreloadApi = {
           deviceId: string
         }
     >
+    getWindowsFirewallStatus: (args?: { address?: string }) => Promise<
+      | { supported: false }
+      | {
+          supported: true
+          port: number
+          ruleAllowed: boolean
+          privateFirewallEnabled: boolean
+          networkCategory: 'private' | 'public' | 'domain' | 'unknown'
+          inspectionAvailable: boolean
+        }
+    >
+    repairWindowsFirewall: () => Promise<
+      { ok: true } | { ok: false; reason: 'cancelled' | 'failed' | 'unsupported' }
+    >
+    openWindowsNetworkSettings: () => Promise
     getRuntimePairingUrl: (args?: { address?: string; rotate?: boolean }) => Promise<
       | { available: false }
       | {
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 3c7af55f0af..4c38b685caa 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -53,6 +53,7 @@ import type {
   UpdateStatus,
   WorktreeBaseStatusEvent,
   WorktreeDefaultTabsLaunch,
+  WorktreeHeadIdentity,
   WorktreeRemoteBranchConflictEvent
 } from '../shared/types'
 import type { PtyModelRestoreNeededEvent } from '../shared/pty-model-restore-marker'
@@ -708,6 +709,9 @@ const api = {
 
     persistSortOrder: (args) => ipcRenderer.invoke('worktrees:persistSortOrder', args),
 
+    getBranchRenameFailureOutput: (args) =>
+      ipcRenderer.invoke('worktrees:getBranchRenameFailureOutput', args),
+
     onChanged: (
       callback: (data: {
         repoId: string
@@ -722,6 +726,24 @@ const api = {
       return () => ipcRenderer.removeListener('worktrees:changed', listener)
     },
 
+    onGitStatusMetadataChanged: (callback: (data: { repoId: string }) => void): (() => void) => {
+      const listener = (_event: Electron.IpcRendererEvent, data: { repoId: string }) =>
+        callback(data)
+      ipcRenderer.on('worktrees:gitStatusMetadataChanged', listener)
+      return () => ipcRenderer.removeListener('worktrees:gitStatusMetadataChanged', listener)
+    },
+
+    onHeadIdentitiesChanged: (
+      callback: (data: { repoId: string; identities: WorktreeHeadIdentity[] }) => void
+    ): (() => void) => {
+      const listener = (
+        _event: Electron.IpcRendererEvent,
+        data: { repoId: string; identities: WorktreeHeadIdentity[] }
+      ) => callback(data)
+      ipcRenderer.on('worktrees:headIdentitiesChanged', listener)
+      return () => ipcRenderer.removeListener('worktrees:headIdentitiesChanged', listener)
+    },
+
     onBaseStatus: (callback: (data: WorktreeBaseStatusEvent) => void): (() => void) => {
       const listener = (_event: Electron.IpcRendererEvent, data: WorktreeBaseStatusEvent) =>
         callback(data)
@@ -937,8 +959,10 @@ const api = {
       ipcRenderer.send('pty:terminalViewAttributes', attributes)
     },
 
-    kill: (id: string, opts?: { keepHistory?: boolean }): Promise =>
-      ipcRenderer.invoke('pty:kill', { id, keepHistory: opts?.keepHistory ?? false }),
+    kill: (
+      id: string,
+      opts?: { keepHistory?: boolean; expectedPaneKey?: string; expectedTabId?: string }
+    ): Promise => ipcRenderer.invoke('pty:kill', { id, ...opts }),
 
     listSessions: (): Promise<{ id: string; cwd: string; title: string }[]> =>
       ipcRenderer.invoke('pty:listSessions'),
@@ -1855,6 +1879,9 @@ const api = {
     set: (args: Record): Promise =>
       ipcRenderer.invoke('settings:set', args),
 
+    updatePRBotAuthorOverride: (args: { author: string; isBot: boolean }): Promise =>
+      ipcRenderer.invoke('settings:update-pr-bot-author-override', args),
+
     listFonts: (): Promise => ipcRenderer.invoke('settings:listFonts'),
 
     previewGhosttyImport: (): Promise =>
@@ -3420,6 +3447,7 @@ const api = {
         launchConfig?: SleepingAgentLaunchConfig
         launchToken?: string
         launchAgent?: TuiAgent
+        viewMode?: 'terminal' | 'chat'
         title?: string
         ptyId?: string
         activate?: boolean
@@ -3442,6 +3470,7 @@ const api = {
           launchConfig?: SleepingAgentLaunchConfig
           launchToken?: string
           launchAgent?: TuiAgent
+          viewMode?: 'terminal' | 'chat'
           title?: string
           ptyId?: string
           activate?: boolean
@@ -4236,6 +4265,13 @@ const api = {
         }
     > => ipcRenderer.invoke('mobile:getPairingQR', args),
 
+    getWindowsFirewallStatus: (args?: { address?: string }) =>
+      ipcRenderer.invoke('mobile:getWindowsFirewallStatus', args),
+
+    repairWindowsFirewall: () => ipcRenderer.invoke('mobile:repairWindowsFirewall'),
+
+    openWindowsNetworkSettings: () => ipcRenderer.invoke('mobile:openWindowsNetworkSettings'),
+
     getRuntimePairingUrl: (args?: {
       address?: string
       rotate?: boolean
@@ -4314,6 +4350,16 @@ const api = {
      *  explicit tab close even when the renderer has no matching local row. */
     dropByTabPrefix: (tabId: string): void => {
       ipcRenderer.send('agentStatus:dropByTabPrefix', tabId)
+    },
+    retirePaneAuthority: (paneKey: string): void => {
+      ipcRenderer.send('agentStatus:retirePaneAuthority', paneKey)
+    },
+    transferPaneAuthority: (args: {
+      fromPaneKey: string
+      toPaneKey: string
+      ptyId?: string
+    }): void => {
+      ipcRenderer.send('agentStatus:transferPaneAuthority', args)
     }
   },
 
diff --git a/src/relay/fs-handler.test.ts b/src/relay/fs-handler.test.ts
index 7d5354cf47a..63e399072e9 100644
--- a/src/relay/fs-handler.test.ts
+++ b/src/relay/fs-handler.test.ts
@@ -7,6 +7,7 @@ import * as fs from 'node:fs/promises'
 import * as path from 'node:path'
 import { mkdtempSync, writeFileSync, mkdirSync, symlinkSync } from 'node:fs'
 import { tmpdir } from 'node:os'
+import { subscribeWithInProcessWatcher } from '../main/ipc/parcel-watcher-in-process-fallback'
 
 const { mockSubscribe } = vi.hoisted(() => ({
   mockSubscribe: vi.fn()
@@ -121,7 +122,11 @@ describe('FsHandler', () => {
     tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-fs-'))
     dispatcher = createMockDispatcher()
     const ctx = new RelayContext()
-    handler = new FsHandler(dispatcher as unknown as RelayDispatcher, ctx)
+    handler = new FsHandler(dispatcher as unknown as RelayDispatcher, ctx, {
+      dispose: vi.fn(),
+      forgetRoot: vi.fn(),
+      subscribe: subscribeWithInProcessWatcher
+    })
   })
 
   afterEach(async () => {
diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts
index 6074fad2e2b..38f0dec9b5d 100644
--- a/src/relay/fs-handler.ts
+++ b/src/relay/fs-handler.ts
@@ -1,5 +1,5 @@
 /* eslint-disable max-lines -- Why: relay filesystem request handling shares
-   path expansion, file IO, search, streaming reads, Space scans, and watch lifecycle state. */
+   path expansion, file IO, search, streaming reads, and Space scans. */
 import { readdir, writeFile, stat, lstat, mkdir, rename, cp, rm, realpath } from 'node:fs/promises'
 import { execFile } from 'node:child_process'
 import { tmpdir } from 'node:os'
@@ -34,17 +34,8 @@ import { RelayStreamRegistry } from './fs-stream-registry'
 import { scanWorkspaceSpaceDirectory } from './workspace-space-scan'
 import { buildRelayCommandEnv } from './relay-command-env'
 import { assertNoClobberRenameDestinationAvailable } from '../shared/filesystem-rename-collision'
-import {
-  WATCHER_IGNORE_DIRS,
-  buildParcelWatcherIgnoreOptions
-} from '../main/ipc/filesystem-watcher-ignore'
-
-type WatchState = {
-  rootPath: string
-  unwatchFn: (() => void) | null
-  setupPromise: Promise | null
-  clients: Map boolean>
-}
+import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry'
+import type { RelayWatcherProcessPool } from './relay-watcher-process-pool'
 
 async function isDirectoryEntry(
   dirPath: string,
@@ -85,15 +76,19 @@ function fileStatFromLstat(stats: Awaited>) {
 
 export class FsHandler {
   private dispatcher: RelayDispatcher
-  private watches = new Map()
+  private watchRegistry: RelayFilesystemWatchRegistry
   private streamRegistry = new RelayStreamRegistry()
   private listFilesScans = new ListFilesScanCoordinator()
 
-  constructor(dispatcher: RelayDispatcher, _context: RelayContext) {
+  constructor(
+    dispatcher: RelayDispatcher,
+    _context: RelayContext,
+    watcherPool?: RelayWatcherProcessPool
+  ) {
     this.dispatcher = dispatcher
+    this.watchRegistry = new RelayFilesystemWatchRegistry(dispatcher, watcherPool)
     this.registerHandlers()
-    this.dispatcher.onClientDetached?.((clientId) => {
-      this.releaseClientWatches(clientId)
+    this.dispatcher.onClientDetached?.(() => {
       // Why: a detached client's fs.streamAck frames will never arrive; wake
       // any pump parked on the ack window so it re-checks staleness and exits
       // instead of stranding its open file handle.
@@ -122,8 +117,12 @@ export class FsHandler {
     this.dispatcher.onRequest('fs.search', (p) => this.search(p))
     this.dispatcher.onRequest('fs.listFiles', (p, c) => this.listFiles(p, c))
     this.dispatcher.onRequest('fs.workspaceSpaceScan', (p, c) => this.workspaceSpaceScan(p, c))
-    this.dispatcher.onRequest('fs.watch', (p, context) => this.watch(p, context))
-    this.dispatcher.onNotification('fs.unwatch', (p, context) => this.unwatch(p, context))
+    this.dispatcher.onRequest('fs.watch', (p, context) =>
+      this.watchRegistry.watch(expandTilde(p.rootPath as string), context)
+    )
+    this.dispatcher.onNotification('fs.unwatch', (p, context) =>
+      this.watchRegistry.unwatch(expandTilde(p.rootPath as string), context)
+    )
     this.dispatcher.onNotification('fs.cancelStream', (p) => this.cancelStream(p))
     this.dispatcher.onNotification('fs.streamAck', (p) => this.streamAck(p))
   }
@@ -414,125 +413,8 @@ export class FsHandler {
     return scanWorkspaceSpaceDirectory(rootPath, context)
   }
 
-  private async watch(params: Record, context?: RequestContext) {
-    const rootPath = expandTilde(params.rootPath as string)
-
-    this.releaseStaleWatches()
-
-    const existing = this.watches.get(rootPath)
-    if (existing) {
-      if ([...existing.clients.values()].some((isStale) => !isStale())) {
-        existing.clients.set(context?.clientId ?? 0, context?.isStale ?? (() => false))
-        if (existing.setupPromise) {
-          await existing.setupPromise
-        }
-        return
-      }
-      existing.unwatchFn?.()
-      this.watches.delete(rootPath)
-    }
-
-    if (this.watches.size >= 20) {
-      throw new Error('Maximum number of file watchers reached')
-    }
-
-    const watchState: WatchState = {
-      rootPath,
-      unwatchFn: null,
-      setupPromise: null,
-      clients: new Map([[context?.clientId ?? 0, context?.isStale ?? (() => false)]])
-    }
-    this.watches.set(rootPath, watchState)
-
-    const setupPromise = (async () => {
-      const watcher = await import('@parcel/watcher')
-      const subscription = await watcher.subscribe(
-        rootPath,
-        (err, events) => {
-          if (err) {
-            this.dispatcher.notify('fs.changed', {
-              events: [{ kind: 'overflow', absolutePath: rootPath }]
-            })
-            return
-          }
-          const mapped = events.map((evt) => ({
-            kind: evt.type,
-            absolutePath: evt.path
-          }))
-          this.dispatcher.notify('fs.changed', { events: mapped })
-        },
-        // Why: align remote watchers with the shared nested exclusion so
-        // generated trees neither exhaust inotify nor trigger slow glob regexes.
-        buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS)
-      )
-      watchState.unwatchFn = () => {
-        void subscription.unsubscribe()
-      }
-      if (
-        [...watchState.clients.values()].every((isStale) => isStale()) ||
-        this.watches.get(rootPath) !== watchState
-      ) {
-        // Why: if the only requesting client reconnects while watcher setup is
-        // in flight, no client can later balance it with fs.unwatch. Tear down
-        // only this request's subscription so a newer replacement watch for the
-        // same root is not removed.
-        void subscription.unsubscribe()
-        if (this.watches.get(rootPath) === watchState) {
-          this.watches.delete(rootPath)
-        }
-      }
-    })()
-    watchState.setupPromise = setupPromise
-
-    try {
-      await setupPromise
-    } catch {
-      if (this.watches.get(rootPath) === watchState) {
-        this.watches.delete(rootPath)
-      }
-      // @parcel/watcher not available -- polling fallback would go here
-      process.stderr.write('[relay] File watcher not available, fs.changed events disabled\n')
-    }
-  }
-
-  private unwatch(params: Record, context?: RequestContext): void {
-    const rootPath = expandTilde(params.rootPath as string)
-    const state = this.watches.get(rootPath)
-    if (state) {
-      this.releaseWatchClient(rootPath, state, context?.clientId ?? 0)
-    }
-  }
-
-  private releaseClientWatches(clientId: number): void {
-    for (const [rootPath, state] of this.watches) {
-      this.releaseWatchClient(rootPath, state, clientId)
-    }
-  }
-
-  private releaseStaleWatches(): void {
-    for (const [rootPath, state] of this.watches) {
-      if ([...state.clients.values()].some((isStale) => !isStale())) {
-        continue
-      }
-      state.unwatchFn?.()
-      this.watches.delete(rootPath)
-    }
-  }
-
-  private releaseWatchClient(rootPath: string, state: WatchState, clientId: number): void {
-    state.clients.delete(clientId)
-    if (state.clients.size > 0) {
-      return
-    }
-    state.unwatchFn?.()
-    this.watches.delete(rootPath)
-  }
-
   dispose(): void {
-    for (const [, state] of this.watches) {
-      state.unwatchFn?.()
-    }
-    this.watches.clear()
+    this.watchRegistry.dispose()
     void this.streamRegistry.disposeAll()
   }
 }
diff --git a/src/relay/git-handler-pull-reconciliation.test.ts b/src/relay/git-handler-pull-reconciliation.test.ts
index 1820faa0fec..5db00d16202 100644
--- a/src/relay/git-handler-pull-reconciliation.test.ts
+++ b/src/relay/git-handler-pull-reconciliation.test.ts
@@ -86,13 +86,33 @@ describe('GitHandler pull reconciliation', () => {
     return consumerDir
   }
 
-  it('explains how to configure a pull policy when divergent branches have no strategy', async () => {
+  it('falls back to a merge when divergent branches have no configured strategy', async () => {
     const consumerDir = createDivergentFixture()
 
-    await expect(dispatcher.callRequest('git.pull', { worktreePath: consumerDir })).rejects.toThrow(
-      'Pull needs a Git pull policy for divergent branches. Configure one for this repository'
-    )
+    await expect(
+      dispatcher.callRequest('git.pull', { worktreePath: consumerDir })
+    ).resolves.not.toThrow()
 
+    // Merge reconciliation yields a two-parent commit and both sides' files.
+    const parentRefs = execGit(consumerDir, ['log', '-1', '--pretty=%P']).trim().split(/\s+/)
+    expect(parentRefs).toHaveLength(2)
+    expect(existsSync(path.join(consumerDir, 'remote.txt'))).toBe(true)
+    expect(existsSync(path.join(consumerDir, 'local.txt'))).toBe(true)
+    expect(execGit(consumerDir, ['status', '--short'])).toBe('')
+  }, 15_000)
+
+  it('preserves configured fast-forward-only pull semantics on divergent branches', async () => {
+    const consumerDir = createDivergentFixture()
+    execGit(consumerDir, ['config', 'pull.ff', 'only'])
+
+    // Why: an explicit ff-only policy must still fail on divergence rather than
+    // getting silently reconciled by the merge fallback.
+    await expect(
+      dispatcher.callRequest('git.pull', { worktreePath: consumerDir })
+    ).rejects.toThrow()
+
+    const parentRefs = execGit(consumerDir, ['log', '-1', '--pretty=%P']).trim().split(/\s+/)
+    expect(parentRefs).toHaveLength(1)
     expect(execGit(consumerDir, ['status', '--short'])).toBe('')
   }, 15_000)
 
diff --git a/src/relay/git-handler-staging.test.ts b/src/relay/git-handler-staging.test.ts
index d4469f37cda..b113afd30c4 100644
--- a/src/relay/git-handler-staging.test.ts
+++ b/src/relay/git-handler-staging.test.ts
@@ -1,5 +1,5 @@
 /**
- * Tests for GitHandler commit and bulk-staging operations.
+ * Tests for GitHandler commit and staging operations.
  *
  * Why: split from git-handler.test.ts to stay under the oxlint max-lines (300) limit.
  */
@@ -19,6 +19,32 @@ import {
   type RelayDispatcher
 } from './git-handler-test-setup'
 
+const PATHSPEC_SELECTED_FILE = '[k]eep.log'
+const PATHSPEC_MATCHING_FILE = 'keep.log'
+const PATHSPEC_MUTATION_CASES = [
+  {
+    mode: 'single-file',
+    stageMethod: 'git.stage',
+    unstageMethod: 'git.unstage',
+    selection: { filePath: PATHSPEC_SELECTED_FILE }
+  },
+  {
+    mode: 'bulk',
+    stageMethod: 'git.bulkStage',
+    unstageMethod: 'git.bulkUnstage',
+    selection: { filePaths: [PATHSPEC_SELECTED_FILE] }
+  }
+] as const
+
+function createPathspecCollisionChanges(dir: string): void {
+  gitInit(dir)
+  writeFileSync(path.join(dir, PATHSPEC_SELECTED_FILE), 'selected')
+  writeFileSync(path.join(dir, PATHSPEC_MATCHING_FILE), 'matching')
+  gitCommit(dir, 'initial')
+  writeFileSync(path.join(dir, PATHSPEC_SELECTED_FILE), 'selected modified')
+  writeFileSync(path.join(dir, PATHSPEC_MATCHING_FILE), 'matching modified')
+}
+
 describe('GitHandler — commit & staging', () => {
   let dispatcher: MockDispatcher
   let tmpDir: string
@@ -77,7 +103,38 @@ describe('GitHandler — commit & staging', () => {
     })
   })
 
-  describe('bulkStage and bulkUnstage', () => {
+  describe('stage and unstage', () => {
+    it.each(PATHSPEC_MUTATION_CASES)(
+      'treats $mode stage paths with Git glob characters as literals',
+      async ({ stageMethod, selection }) => {
+        createPathspecCollisionChanges(tmpDir)
+
+        await dispatcher.callRequest(stageMethod, { worktreePath: tmpDir, ...selection })
+
+        const output = execFileSync('git', ['diff', '--cached', '--name-only'], {
+          cwd: tmpDir,
+          encoding: 'utf-8'
+        })
+        expect(output.trim()).toBe(PATHSPEC_SELECTED_FILE)
+      }
+    )
+
+    it.each(PATHSPEC_MUTATION_CASES)(
+      'treats $mode unstage paths with Git glob characters as literals',
+      async ({ unstageMethod, selection }) => {
+        createPathspecCollisionChanges(tmpDir)
+        execFileSync('git', ['add', '.'], { cwd: tmpDir, stdio: 'pipe' })
+
+        await dispatcher.callRequest(unstageMethod, { worktreePath: tmpDir, ...selection })
+
+        const output = execFileSync('git', ['diff', '--cached', '--name-only'], {
+          cwd: tmpDir,
+          encoding: 'utf-8'
+        })
+        expect(output.trim()).toBe(PATHSPEC_MATCHING_FILE)
+      }
+    )
+
     it('stages multiple files', async () => {
       gitInit(tmpDir)
       writeFileSync(path.join(tmpDir, 'a.txt'), 'a')
diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts
index 1ae68d3ce88..d674af9949e 100644
--- a/src/relay/git-handler.test.ts
+++ b/src/relay/git-handler.test.ts
@@ -1192,7 +1192,7 @@ describe('GitHandler', () => {
       await Promise.all([first, second])
 
       expect(gitBufferSpy).toHaveBeenCalledTimes(2)
-      expect(gitSpy).toHaveBeenCalledWith(['add', '--', 'src/file.ts'], tmpDir)
+      expect(gitSpy).toHaveBeenCalledWith(['add', '--', ':(literal)src/file.ts'], tmpDir)
       const submodulePathReads = gitSpy.mock.calls.filter(
         ([args]) => args[0] === 'config' && args.includes('.gitmodules')
       )
diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts
index d21dcf3cce9..510dd60f84b 100644
--- a/src/relay/git-handler.ts
+++ b/src/relay/git-handler.ts
@@ -44,7 +44,11 @@ import { gitExecMutatesRepository } from '../shared/git-exec-mutation'
 import { detectConflictOperation, getStatusOp } from './git-handler-status-ops'
 import { checkIgnoredPathsOp } from './git-handler-check-ignore'
 import { resolveRelayPushTarget } from './git-handler-push-target'
-import { isNoUpstreamError, normalizeGitErrorMessage } from '../shared/git-remote-error'
+import {
+  isNoUpstreamError,
+  normalizeGitErrorMessage,
+  runPullWithDivergenceFallback
+} from '../shared/git-remote-error'
 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'
@@ -488,7 +492,7 @@ export class GitHandler {
     const worktreePath = params.worktreePath as string
     const filePath = params.filePath as string
     try {
-      await this.git(['add', '--', filePath], worktreePath)
+      await this.git(['add', '--', this.literalPathspec(filePath)], worktreePath)
     } finally {
       this.clearGitMutationReadCaches()
     }
@@ -512,7 +516,7 @@ export class GitHandler {
     const worktreePath = params.worktreePath as string
     const filePath = params.filePath as string
     try {
-      await this.git(['restore', '--staged', '--', filePath], worktreePath)
+      await this.git(['restore', '--staged', '--', this.literalPathspec(filePath)], worktreePath)
     } finally {
       this.clearGitMutationReadCaches()
     }
@@ -525,7 +529,10 @@ export class GitHandler {
     try {
       for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) {
         const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE)
-        await this.git(['add', '--', ...chunk], worktreePath)
+        await this.git(
+          ['add', '--', ...chunk.map((filePath) => this.literalPathspec(filePath))],
+          worktreePath
+        )
       }
     } finally {
       this.clearGitMutationReadCaches()
@@ -539,7 +546,10 @@ export class GitHandler {
     try {
       for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) {
         const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE)
-        await this.git(['restore', '--staged', '--', ...chunk], worktreePath)
+        await this.git(
+          ['restore', '--staged', '--', ...chunk.map((filePath) => this.literalPathspec(filePath))],
+          worktreePath
+        )
       }
     } finally {
       this.clearGitMutationReadCaches()
@@ -1015,29 +1025,33 @@ export class GitHandler {
   private async pullWithArgs(params: Record, pullArgs: string[]) {
     this.clearGitMutationReadCaches()
     const worktreePath = params.worktreePath as string
+    const runPull = async (effectiveArgs: string[]): Promise => {
+      if (params.pushTarget !== undefined) {
+        assertGitPushTargetShape(params.pushTarget)
+        const pushTarget = params.pushTarget as GitPushTarget
+        await this.git(['check-ref-format', '--branch', pushTarget.branchName], worktreePath)
+        await this.git(
+          ['pull', ...effectiveArgs, pushTarget.remoteName, pushTarget.branchName],
+          worktreePath
+        )
+        return
+      }
+      const upstream = await resolveEffectiveGitUpstream((args) => this.git(args, worktreePath))
+      if (upstream && !upstream.isConfiguredUpstream) {
+        // Why: legacy Orca branches may still track origin/main while pushes
+        // target origin/. Pull the same effective branch the UI reports.
+        await this.git(
+          ['pull', ...effectiveArgs, upstream.remoteName, upstream.branchName],
+          worktreePath
+        )
+        return
+      }
+      await this.git(['pull', ...effectiveArgs], worktreePath)
+    }
+
     try {
       try {
-        if (params.pushTarget !== undefined) {
-          assertGitPushTargetShape(params.pushTarget)
-          const pushTarget = params.pushTarget as GitPushTarget
-          await this.git(['check-ref-format', '--branch', pushTarget.branchName], worktreePath)
-          await this.git(
-            ['pull', ...pullArgs, pushTarget.remoteName, pushTarget.branchName],
-            worktreePath
-          )
-          return
-        }
-        const upstream = await resolveEffectiveGitUpstream((args) => this.git(args, worktreePath))
-        if (upstream && !upstream.isConfiguredUpstream) {
-          // Why: legacy Orca branches may still track origin/main while pushes
-          // target origin/. Pull the same effective branch the UI reports.
-          await this.git(
-            ['pull', ...pullArgs, upstream.remoteName, upstream.branchName],
-            worktreePath
-          )
-          return
-        }
-        await this.git(['pull', ...pullArgs], worktreePath)
+        await runPullWithDivergenceFallback(pullArgs, runPull)
       } catch (error) {
         // Why: mirror the local gitPull normalization so SSH users see the same
         // actionable messages instead of raw git stderr.
diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts
index 8618d859290..af4817c26f4 100644
--- a/src/relay/pty-handler.test.ts
+++ b/src/relay/pty-handler.test.ts
@@ -174,6 +174,19 @@ describe('PtyHandler', () => {
     expect(handler.activePtyCount).toBe(1)
   })
 
+  it('keeps the 50-session cap atomic across concurrent module loads', async () => {
+    mockPtySpawn.mockImplementation(() => ({ ...mockPtyInstance }))
+
+    const results = await Promise.allSettled(
+      Array.from({ length: 51 }, () => dispatcher.callRequest('pty.spawn', {}))
+    )
+
+    expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(50)
+    expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1)
+    expect(mockPtySpawn).toHaveBeenCalledTimes(50)
+    expect(handler.activePtyCount).toBe(50)
+  })
+
   it('uses an explicit shell override and falls back to the default shell otherwise', async () => {
     const originalPlatform = process.platform
     Object.defineProperty(process, 'platform', {
@@ -694,6 +707,135 @@ describe('PtyHandler', () => {
     expect(killSpy).toHaveBeenCalledWith('SIGKILL')
   })
 
+  it('keeps stale-spawn fallback ownership when the first native kill throws', async () => {
+    const firstError = new Error('native ConPTY kill failed')
+    let onExitCb: ((event: { exitCode: number }) => void) | null = null
+    const killSpy = vi
+      .fn()
+      .mockImplementationOnce(() => {
+        throw firstError
+      })
+      .mockImplementationOnce(() => onExitCb?.({ exitCode: 137 }))
+    const term = {
+      ...mockPtyInstance,
+      kill: killSpy,
+      onData: vi.fn(),
+      onExit: vi.fn((cb: (event: { exitCode: number }) => void) => {
+        onExitCb = cb
+      })
+    }
+    mockPtySpawn.mockReturnValue(term)
+
+    await expect(dispatcher.callRequest('pty.spawn', {}, { isStale: () => true })).rejects.toThrow(
+      firstError
+    )
+
+    expect(handler.activePtyCount).toBe(1)
+    vi.advanceTimersByTime(5000)
+    expect(killSpy).toHaveBeenNthCalledWith(2, 'SIGKILL')
+    expect(handler.activePtyCount).toBe(0)
+  })
+
+  it('contains repeated stale-spawn throws and retains ownership until a proven retry', async () => {
+    let onExitCb: ((event: { exitCode: number }) => void) | null = null
+    const killSpy = vi
+      .fn()
+      .mockImplementationOnce(() => {
+        throw new Error('native ConPTY kill failed')
+      })
+      .mockImplementationOnce(() => {
+        throw new Error('native ConPTY kill failed')
+      })
+      .mockImplementationOnce(() => onExitCb?.({ exitCode: 137 }))
+    const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
+    const term = {
+      ...mockPtyInstance,
+      kill: killSpy,
+      onData: vi.fn(),
+      onExit: vi.fn((cb: (event: { exitCode: number }) => void) => {
+        onExitCb = cb
+      })
+    }
+    mockPtySpawn.mockReturnValue(term)
+
+    try {
+      await expect(
+        dispatcher.callRequest('pty.spawn', {}, { isStale: () => true })
+      ).rejects.toThrow('native ConPTY kill failed')
+      expect(() => vi.advanceTimersByTime(5000)).not.toThrow()
+      expect(killSpy).toHaveBeenCalledTimes(2)
+      expect(handler.activePtyCount).toBe(1)
+      expect(stderr).toHaveBeenCalledWith(expect.stringContaining('stale-spawn force kill failed'))
+      expect(() => vi.advanceTimersByTime(5000)).not.toThrow()
+      expect(killSpy).toHaveBeenNthCalledWith(3, 'SIGKILL')
+      expect(handler.activePtyCount).toBe(0)
+    } finally {
+      stderr.mockRestore()
+    }
+  })
+
+  it('reaps a stale spawn when fallback kill throws after OS exit proof', async () => {
+    const killSpy = vi
+      .fn()
+      .mockImplementationOnce(() => {})
+      .mockImplementationOnce(() => {
+        throw new Error('native handle already closed')
+      })
+    mockPtySpawn.mockReturnValue({
+      ...mockPtyInstance,
+      kill: killSpy,
+      onData: vi.fn(),
+      onExit: vi.fn()
+    })
+    const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(false)
+    try {
+      await dispatcher.callRequest('pty.spawn', {}, { isStale: () => true })
+      vi.advanceTimersByTime(5000)
+    } finally {
+      aliveSpy.mockRestore()
+    }
+
+    expect(killSpy).toHaveBeenCalledTimes(2)
+    expect(handler.activePtyCount).toBe(0)
+    expect(vi.getTimerCount()).toBe(0)
+  })
+
+  it('retains stale-spawn ownership beyond the eighth native failure', async () => {
+    let onExitCb: ((event: { exitCode: number }) => void) | null = null
+    const killSpy = vi.fn()
+    for (let attempt = 0; attempt < 9; attempt += 1) {
+      killSpy.mockImplementationOnce(() => {
+        throw new Error('native ConPTY kill failed')
+      })
+    }
+    killSpy.mockImplementationOnce(() => onExitCb?.({ exitCode: 137 }))
+    const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
+    mockPtySpawn.mockReturnValue({
+      ...mockPtyInstance,
+      kill: killSpy,
+      onData: vi.fn(),
+      onExit: vi.fn((cb: (event: { exitCode: number }) => void) => {
+        onExitCb = cb
+      })
+    })
+
+    try {
+      await expect(
+        dispatcher.callRequest('pty.spawn', {}, { isStale: () => true })
+      ).rejects.toThrow('native ConPTY kill failed')
+      vi.advanceTimersByTime(40_000)
+      expect(handler.activePtyCount).toBe(1)
+      expect(killSpy).toHaveBeenCalledTimes(9)
+
+      vi.advanceTimersByTime(5_000)
+      expect(killSpy).toHaveBeenCalledTimes(10)
+      expect(handler.activePtyCount).toBe(0)
+      expect(stderr).toHaveBeenCalledTimes(3)
+    } finally {
+      stderr.mockRestore()
+    }
+  })
+
   it('does not submit provider-delivered commands for stale spawn responses', async () => {
     const killSpy = vi.fn()
     const term = { ...mockPtyInstance, kill: killSpy, onData: vi.fn(), onExit: vi.fn() }
@@ -1018,6 +1160,90 @@ describe('PtyHandler', () => {
     expect(mockKill).toHaveBeenCalledWith('SIGTERM')
   })
 
+  // Why: node-pty's Windows agent throws "Signals not supported on windows."
+  // for any signal argument. killPtyForShutdown drops the signal on win32 and
+  // keeps one-shot ownership — cover every call site so neither can regress.
+  describe('kills PTY without a signal on Windows', () => {
+    async function withWindowsPlatform(fn: () => Promise): Promise {
+      const originalPlatform = process.platform
+      Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
+      try {
+        await fn()
+      } finally {
+        Object.defineProperty(process, 'platform', {
+          configurable: true,
+          value: originalPlatform
+        })
+      }
+    }
+
+    function mockKillablePty(): ReturnType {
+      const mockKill = vi.fn()
+      mockPtySpawn.mockReturnValue({
+        ...mockPtyInstance,
+        kill: mockKill,
+        onData: vi.fn(),
+        onExit: vi.fn()
+      })
+      return mockKill
+    }
+
+    function expectBareKills(mockKill: ReturnType, times: number): void {
+      expect(mockKill).toHaveBeenCalledTimes(times)
+      expect(mockKill.mock.calls.every((args) => args.length === 0)).toBe(true)
+    }
+
+    it('on graceful shutdown', async () => {
+      await withWindowsPlatform(async () => {
+        const mockKill = mockKillablePty()
+        await dispatcher.callRequest('pty.spawn', {})
+        await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: false })
+        expectBareKills(mockKill, 1)
+      })
+    })
+
+    it('on immediate shutdown', async () => {
+      await withWindowsPlatform(async () => {
+        const mockKill = mockKillablePty()
+        await dispatcher.callRequest('pty.spawn', {})
+        await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true })
+        expectBareKills(mockKill, 1)
+      })
+    })
+
+    it('on stale-spawn cleanup without reissuing an accepted close', async () => {
+      await withWindowsPlatform(async () => {
+        const mockKill = mockKillablePty()
+        await dispatcher.callRequest('pty.spawn', {}, { isStale: () => true })
+        expectBareKills(mockKill, 1)
+        vi.advanceTimersByTime(5000)
+        expectBareKills(mockKill, 1)
+        expect(handler.activePtyCount).toBe(1)
+      })
+    })
+
+    it('on graceful shutdown without reissuing an accepted close', async () => {
+      await withWindowsPlatform(async () => {
+        const mockKill = mockKillablePty()
+        await dispatcher.callRequest('pty.spawn', {})
+        await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: false })
+        expectBareKills(mockKill, 1)
+        vi.advanceTimersByTime(5000)
+        expectBareKills(mockKill, 1)
+        expect(handler.activePtyCount).toBe(1)
+      })
+    })
+
+    it('on dispose', async () => {
+      await withWindowsPlatform(async () => {
+        const mockKill = mockKillablePty()
+        await dispatcher.callRequest('pty.spawn', {})
+        handler.dispose()
+        expectBareKills(mockKill, 1)
+      })
+    })
+  })
+
   it('flushes pending PTY output before immediate shutdown cleanup', async () => {
     let dataCallback: ((data: string) => void) | undefined
     const mockKill = vi.fn()
@@ -1062,7 +1288,7 @@ describe('PtyHandler', () => {
 
     expect(mockKill).toHaveBeenCalledWith('SIGTERM')
     expect(mockKill).toHaveBeenCalledWith('SIGKILL')
-    expect(dispatcher.notify).toHaveBeenCalledWith('pty.exit', { id: 'pty-1', code: -1 })
+    expect(dispatcher.notify).toHaveBeenCalledWith('pty.exit', { id: 'pty-1', code: 137 })
     expect(exits).toEqual([{ id: 'pty-1', paneKey: 'tab-fallback:0' }])
     expect(handler.activePtyCount).toBe(0)
   })
@@ -1108,8 +1334,72 @@ describe('PtyHandler', () => {
     }
   })
 
-  it('keeps Windows graceful shutdown and its fallback signal-free', async () => {
+  it('does not reissue an accepted Windows close from a later shutdown request', async () => {
     const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')
+    let onExitCb!: (event: { exitCode: number }) => void
+    const mockKill = vi.fn()
+    mockPtySpawn.mockReturnValue({
+      ...mockPtyInstance,
+      kill: mockKill,
+      onData: vi.fn(),
+      onExit: vi.fn((cb: (event: { exitCode: number }) => void) => {
+        onExitCb = cb
+      })
+    })
+
+    Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
+    try {
+      await dispatcher.callRequest('pty.spawn', {})
+      await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true })
+      await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true })
+
+      expect(mockKill.mock.calls).toEqual([[]])
+      expect(handler.activePtyCount).toBe(1)
+      onExitCb({ exitCode: 137 })
+      expect(handler.activePtyCount).toBe(0)
+    } finally {
+      if (platformDescriptor) {
+        Object.defineProperty(process, 'platform', platformDescriptor)
+      }
+    }
+  })
+
+  it('reaps delayed OS death from inventory without reissuing an accepted Windows close', async () => {
+    const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')
+    const mockKill = vi.fn()
+    const destroy = vi.fn(() => mockKill())
+    const proc = {
+      ...mockPtyInstance,
+      pid: process.pid,
+      kill: mockKill,
+      destroy,
+      onData: vi.fn(),
+      onExit: vi.fn()
+    }
+    mockPtySpawn.mockReturnValue(proc)
+
+    Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
+    try {
+      await dispatcher.callRequest('pty.spawn', {})
+      await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true })
+      expect(handler.activePtyCount).toBe(1)
+
+      proc.pid = 2_147_483_647
+      await expect(dispatcher.callRequest('pty.listProcesses')).resolves.toEqual([])
+
+      expect(handler.activePtyCount).toBe(0)
+      expect(mockKill).toHaveBeenCalledOnce()
+      expect(destroy).not.toHaveBeenCalled()
+    } finally {
+      if (platformDescriptor) {
+        Object.defineProperty(process, 'platform', platformDescriptor)
+      }
+    }
+  })
+
+  it('does not reissue an accepted Windows graceful shutdown', async () => {
+    const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')
+    let onExitCb: ((event: { exitCode: number }) => void) | null = null
     const mockKill = vi.fn()
     const destroy = vi.fn(() => mockKill())
     mockPtySpawn.mockReturnValue({
@@ -1117,7 +1407,9 @@ describe('PtyHandler', () => {
       kill: mockKill,
       destroy,
       onData: vi.fn(),
-      onExit: vi.fn()
+      onExit: vi.fn((cb: (event: { exitCode: number }) => void) => {
+        onExitCb = cb
+      })
     })
 
     Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
@@ -1128,6 +1420,8 @@ describe('PtyHandler', () => {
 
       expect(mockKill.mock.calls).toEqual([[]])
       expect(destroy).not.toHaveBeenCalled()
+      expect(handler.activePtyCount).toBe(1)
+      onExitCb!({ exitCode: 137 })
       expect(handler.activePtyCount).toBe(0)
     } finally {
       if (platformDescriptor) {
@@ -1138,19 +1432,22 @@ describe('PtyHandler', () => {
 
   it('keeps a Windows PTY retryable when immediate native shutdown throws', async () => {
     const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')
+    let onExitCb: ((event: { exitCode: number }) => void) | null = null
     const mockKill = vi
       .fn()
       .mockImplementationOnce(() => {
         throw new Error('invalid ConPTY handle')
       })
-      .mockImplementationOnce(() => {})
+      .mockImplementationOnce(() => onExitCb?.({ exitCode: 137 }))
     const destroy = vi.fn(() => mockKill())
     mockPtySpawn.mockReturnValue({
       ...mockPtyInstance,
       kill: mockKill,
       destroy,
       onData: vi.fn(),
-      onExit: vi.fn()
+      onExit: vi.fn((cb: (event: { exitCode: number }) => void) => {
+        onExitCb = cb
+      })
     })
 
     Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
@@ -1172,8 +1469,34 @@ describe('PtyHandler', () => {
     }
   })
 
-  it('does not arm Windows graceful fallback until native shutdown succeeds', async () => {
+  it('accepts OS exit proof when immediate native shutdown throws after process death', async () => {
+    const killSpy = vi.fn(() => {
+      throw new Error('native handle already closed')
+    })
+    mockPtySpawn.mockReturnValue({
+      ...mockPtyInstance,
+      kill: killSpy,
+      onData: vi.fn(),
+      onExit: vi.fn()
+    })
+    const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(false)
+    try {
+      await dispatcher.callRequest('pty.spawn', {})
+      await expect(
+        dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true })
+      ).resolves.toBeUndefined()
+    } finally {
+      aliveSpy.mockRestore()
+    }
+
+    expect(killSpy).toHaveBeenCalledTimes(1)
+    expect(handler.activePtyCount).toBe(0)
+    expect(vi.getTimerCount()).toBe(0)
+  })
+
+  it('retries a thrown Windows graceful shutdown but not an accepted retry', async () => {
     const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')
+    let onExitCb: ((event: { exitCode: number }) => void) | null = null
     const mockKill = vi
       .fn()
       .mockImplementationOnce(() => {
@@ -1186,7 +1509,9 @@ describe('PtyHandler', () => {
       kill: mockKill,
       destroy,
       onData: vi.fn(),
-      onExit: vi.fn()
+      onExit: vi.fn((cb: (event: { exitCode: number }) => void) => {
+        onExitCb = cb
+      })
     })
 
     Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
@@ -1201,6 +1526,8 @@ describe('PtyHandler', () => {
       await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: false })
       vi.advanceTimersByTime(5000)
       expect(mockKill.mock.calls).toEqual([[], []])
+      expect(handler.activePtyCount).toBe(1)
+      onExitCb!({ exitCode: 137 })
       expect(handler.activePtyCount).toBe(0)
     } finally {
       if (platformDescriptor) {
@@ -1209,6 +1536,88 @@ describe('PtyHandler', () => {
     }
   })
 
+  it('polls after accepted Windows graceful shutdown without a fallback close', async () => {
+    const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')
+    let onExitCb: ((event: { exitCode: number }) => void) | null = null
+    const mockKill = vi.fn()
+    const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
+    mockPtySpawn.mockReturnValue({
+      ...mockPtyInstance,
+      kill: mockKill,
+      onData: vi.fn(),
+      onExit: vi.fn((cb: (event: { exitCode: number }) => void) => {
+        onExitCb = cb
+      })
+    })
+
+    Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
+    try {
+      await dispatcher.callRequest('pty.spawn', {})
+      await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: false })
+
+      expect(() => vi.advanceTimersByTime(5000)).not.toThrow()
+      expect(handler.activePtyCount).toBe(1)
+      expect(() => vi.advanceTimersByTime(5000)).not.toThrow()
+      expect(mockKill.mock.calls).toEqual([[]])
+      onExitCb!({ exitCode: 137 })
+      expect(handler.activePtyCount).toBe(0)
+    } finally {
+      stderr.mockRestore()
+      if (platformDescriptor) {
+        Object.defineProperty(process, 'platform', platformDescriptor)
+      }
+    }
+  })
+
+  it('reaps graceful fallback when native throw is followed by OS exit proof', async () => {
+    const killSpy = vi
+      .fn()
+      .mockImplementationOnce(() => {})
+      .mockImplementationOnce(() => {
+        throw new Error('native handle already closed')
+      })
+    mockPtySpawn.mockReturnValue({
+      ...mockPtyInstance,
+      kill: killSpy,
+      onData: vi.fn(),
+      onExit: vi.fn()
+    })
+    const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(false)
+    try {
+      await dispatcher.callRequest('pty.spawn', {})
+      await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: false })
+      vi.advanceTimersByTime(5000)
+    } finally {
+      aliveSpy.mockRestore()
+    }
+
+    expect(handler.activePtyCount).toBe(0)
+    expect(vi.getTimerCount()).toBe(0)
+  })
+
+  it('completes whole-relay shutdown when native throw follows OS exit proof', async () => {
+    mockPtySpawn.mockReturnValue({
+      ...mockPtyInstance,
+      kill: vi.fn(() => {
+        throw new Error('native handle already closed')
+      }),
+      onData: vi.fn(),
+      onExit: vi.fn()
+    })
+    const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(false)
+    const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
+    try {
+      await dispatcher.callRequest('pty.spawn', {})
+      await expect(handler.shutdownAndWait(1000)).resolves.toBeUndefined()
+    } finally {
+      aliveSpy.mockRestore()
+      stderr.mockRestore()
+    }
+
+    expect(handler.activePtyCount).toBe(0)
+    expect(vi.getTimerCount()).toBe(0)
+  })
+
   it('throws for attach on nonexistent PTY', async () => {
     await expect(dispatcher.callRequest('pty.attach', { id: 'pty-999' })).rejects.toThrow(
       'PTY "pty-999" not found'
@@ -1562,6 +1971,37 @@ describe('PtyHandler', () => {
     }
   )
 
+  it('whole-relay shutdown retries native failure and waits for exit proof', async () => {
+    let onExitCb: ((event: { exitCode: number }) => void) | null = null
+    const kill = vi
+      .fn()
+      .mockImplementationOnce(() => {
+        throw new Error('native force kill failed')
+      })
+      .mockImplementationOnce(() => onExitCb?.({ exitCode: 137 }))
+    const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
+    mockPtySpawn.mockReturnValue({
+      ...mockPtyInstance,
+      kill,
+      onExit: vi.fn((cb: (event: { exitCode: number }) => void) => {
+        onExitCb = cb
+      })
+    })
+    try {
+      await dispatcher.callRequest('pty.spawn', {})
+
+      const shutdown = handler.shutdownAndWait(1_000)
+      expect(handler.activePtyCount).toBe(1)
+      await vi.advanceTimersByTimeAsync(100)
+
+      await expect(shutdown).resolves.toBeUndefined()
+      expect(kill).toHaveBeenCalledTimes(2)
+      expect(handler.activePtyCount).toBe(0)
+    } finally {
+      stderr.mockRestore()
+    }
+  })
+
   it('revive restores pane identity env alongside hook-server coordinates', async () => {
     await dispatcher.callRequest('pty.spawn', {
       cols: 90,
@@ -1869,6 +2309,8 @@ describe('PtyHandler', () => {
       env: { ORCA_PANE_KEY: 'tab-shutdown:0' }
     })
     await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true })
+    expect(exits).toEqual([])
+    expect(handler.activePtyCount).toBe(1)
     onExitCb!({ exitCode: 0 })
 
     expect(mockKill).toHaveBeenCalledWith('SIGKILL')
@@ -1876,6 +2318,42 @@ describe('PtyHandler', () => {
     expect(handler.activePtyCount).toBe(0)
   })
 
+  it('rejects shutdown when a recycled id belongs to another pane generation', async () => {
+    let onExitCb: ((event: { exitCode: number }) => void) | null = null
+    const mockKill = vi.fn(() => onExitCb?.({ exitCode: 137 }))
+    mockPtySpawn.mockReturnValue({
+      ...mockPtyInstance,
+      kill: mockKill,
+      onData: vi.fn(),
+      onExit: vi.fn((cb: (event: { exitCode: number }) => void) => {
+        onExitCb = cb
+      })
+    })
+    await dispatcher.callRequest('pty.spawn', {
+      paneKey: 'tab-new:leaf-new',
+      tabId: 'tab-new'
+    })
+
+    await expect(
+      dispatcher.callRequest('pty.shutdown', {
+        id: 'pty-1',
+        immediate: true,
+        expectedPaneKey: 'tab-old:leaf-old',
+        expectedTabId: 'tab-old'
+      })
+    ).rejects.toThrow('identity mismatch')
+    expect(mockKill).not.toHaveBeenCalled()
+    expect(handler.activePtyCount).toBe(1)
+
+    await dispatcher.callRequest('pty.shutdown', {
+      id: 'pty-1',
+      immediate: true,
+      expectedPaneKey: 'tab-new:leaf-new',
+      expectedTabId: 'tab-new'
+    })
+    expect(handler.activePtyCount).toBe(0)
+  })
+
   it('dispose kills all PTYs with SIGKILL and invokes exit listeners', async () => {
     const mockKill = vi.fn()
     mockPtySpawn.mockReturnValue({
diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts
index 405f9837d1a..0d5ff302b01 100644
--- a/src/relay/pty-handler.ts
+++ b/src/relay/pty-handler.ts
@@ -29,15 +29,24 @@ function killPtyForShutdown(managed: ManagedPty, signal: 'SIGTERM' | 'SIGKILL'):
   // Why: node-pty's Windows backend rejects signal arguments; its no-arg kill
   // is the equivalent shutdown operation for ConPTY.
   if (process.platform === 'win32') {
-    if (!managed.windowsShutdownKillIssued) {
+    // Why: the native ConPTY close is one-shot and survives client reconnects.
+    // A successful prior call must be polled, never issued against the handle again.
+    if (managed.windowsShutdownKillIssued) {
+      return
+    }
+    managed.windowsShutdownKillIssued = true
+    try {
       managed.pty.kill()
-      // Why: a native kill throw can leave the process alive. Mark success only
-      // after return so provider ownership remains retryable on failure.
-      managed.windowsShutdownKillIssued = true
+    } catch (error) {
+      // Why: a native throw is not acceptance; allow the retained owner to
+      // retry, while the pre-call mark prevents synchronous onExit double-kill.
+      managed.windowsShutdownKillIssued = false
+      throw error
     }
   } else {
     managed.pty.kill(signal)
   }
+  managed.shutdownKillAccepted = true
 }
 
 // Why: node-pty is a native addon that may not be installed on the remote.
@@ -45,16 +54,25 @@ function killPtyForShutdown(managed: ManagedPty, signal: 'SIGTERM' | 'SIGKILL'):
 // when the native module is unavailable. The static type import lets vitest
 // intercept it in tests.
 let ptyModule: typeof NodePty | null = null
+let ptyModuleLoad: Promise | null = null
 async function loadPty(): Promise {
   if (ptyModule) {
     return ptyModule
   }
-  try {
-    ptyModule = await import('node-pty')
-    return ptyModule
-  } catch {
-    return null
+  if (!ptyModuleLoad) {
+    // Why: 51 cold concurrent spawns must share one native-addon load; some
+    // loaders reject overlapping evaluation even though the module is valid.
+    ptyModuleLoad = import('node-pty')
+      .then((loaded) => {
+        ptyModule = loaded
+        return loaded
+      })
+      .catch(() => null)
+      .finally(() => {
+        ptyModuleLoad = null
+      })
   }
+  return ptyModuleLoad
 }
 
 type ManagedPty = {
@@ -65,6 +83,7 @@ type ManagedPty = {
   /** Timer for SIGKILL fallback after a graceful SIGTERM shutdown. */
   killTimer?: ReturnType
   windowsShutdownKillIssued?: boolean
+  shutdownKillAccepted?: boolean
   /** True once disposeManagedPty has run. Prevents double-dispose (onExit + an
    *  explicit shutdown can both fire for the same PTY) and converts post-dispose
    *  entry-point calls into a clean "not found" error instead of a silent no-op
@@ -144,6 +163,8 @@ const INTERACTIVE_REDRAW_MAX_CHARS = PTY_OUTPUT_FLUSH_CHUNK_CHARS
 const INTERACTIVE_OUTPUT_BUDGET_CHARS = 32 * 1024
 const STARTUP_COMMAND_WRITE_DELAY_MS = 50
 const STARTUP_COMMAND_SHELL_READY_FALLBACK_MS = 1500
+const STALE_SPAWN_KILL_RETRY_MS = 5000
+const RETAINED_KILL_LOG_MILESTONE = 8
 const ALLOWED_SIGNALS = new Set([
   'SIGINT',
   'SIGTERM',
@@ -255,6 +276,7 @@ export class PtyHandler {
   private pendingOutputByPty = new Map()
   private lastInputAtByPty = new Map()
   private interactiveOutputCharsByPty = new Map()
+  private emptyWaiters = new Set<() => void>()
   // Why: external observers need to drop per-pane state when a PTY exits.
   // Today the relay composes multiple consumers (hook-server cache eviction
   // and plugin-overlay dir cleanup) into a single callback at the call site
@@ -456,6 +478,7 @@ export class PtyHandler {
       this.dispatcher.notify('pty.exit', { id: managed.id, code: exitCode })
       this.notifyExitListener(managed)
       this.ptys.delete(managed.id)
+      this.notifyIfEmpty()
       this.clearPtyFlowState(managed.id)
       // Why: release the ptmx fd on the natural-exit path. Without this the
       // node-pty wrapper's _socket stays alive until GC and the master fd
@@ -484,6 +507,72 @@ export class PtyHandler {
     }
   }
 
+  private reapManagedPtyIfProvablyExited(managed: ManagedPty, code = -1): boolean {
+    if (managed.disposed || this.ptys.get(managed.id) !== managed) {
+      return true
+    }
+    const pid = managed.pty.pid
+    if (!pid || isProcessAlive(pid)) {
+      return false
+    }
+    // Why: node-pty can omit onExit after accepting a native kill. ESRCH is
+    // independent OS proof, so synthesize the same one-shot cleanup here.
+    this.flushPtyOutput(managed.id)
+    this.dispatcher.notify('pty.exit', { id: managed.id, code })
+    this.notifyExitListener(managed)
+    this.ptys.delete(managed.id)
+    this.notifyIfEmpty()
+    this.clearPtyFlowState(managed.id)
+    disposeManagedPty(managed)
+    return true
+  }
+
+  private scheduleManagedPtyForceShutdown(
+    managed: ManagedPty,
+    state: {
+      forceKillAccepted: boolean
+      failedAttempts: number
+      label: string
+      retryDelayMs?: number
+    }
+  ): void {
+    if (managed.disposed || managed.killTimer || this.ptys.get(managed.id) !== managed) {
+      return
+    }
+    managed.killTimer = setTimeout(() => {
+      const still = this.ptys.get(managed.id)
+      if (!still || still.disposed) {
+        return
+      }
+      still.killTimer = undefined
+      if (!state.forceKillAccepted) {
+        try {
+          killPtyForShutdown(still, 'SIGKILL')
+          state.forceKillAccepted = true
+        } catch (error) {
+          // Why: native close can throw after the OS has already reaped the
+          // child. Accept that independent proof before retaining a retry.
+          if (this.reapManagedPtyIfProvablyExited(still)) {
+            return
+          }
+          state.failedAttempts += 1
+          if (state.failedAttempts <= 2 || state.failedAttempts === RETAINED_KILL_LOG_MILESTONE) {
+            process.stderr.write(
+              `[pty-handler] ${state.label} force kill failed: ${error instanceof Error ? error.message : String(error)}\n`
+            )
+          }
+          this.scheduleManagedPtyForceShutdown(still, state)
+          return
+        }
+      }
+      if (!this.reapManagedPtyIfProvablyExited(still)) {
+        // Why: a stale spawn has no caller left to retry, and a graceful timer
+        // must never throw into relay-global uncaughtException. Keep one owner.
+        this.scheduleManagedPtyForceShutdown(still, state)
+      }
+    }, state.retryDelayMs ?? STALE_SPAWN_KILL_RETRY_MS)
+  }
+
   private registerHandlers(): void {
     this.dispatcher.onRequest('pty.spawn', (p, context) => this.spawn(p, context))
     this.dispatcher.onRequest('pty.attach', (p) => this.attach(p))
@@ -615,6 +704,11 @@ export class PtyHandler {
     if (!pty) {
       throw new Error('node-pty is not available on this remote host')
     }
+    // Why: concurrent requests can all cross the pre-await size check while
+    // node-pty loads. Spawn/store below is synchronous, so recheck atomically.
+    if (this.ptys.size >= 50) {
+      throw new Error('Maximum number of PTY sessions reached (50)')
+    }
 
     const cols = (params.cols as number) || 80
     const rows = (params.rows as number) || 24
@@ -728,20 +822,19 @@ export class PtyHandler {
       // response is discarded and no renderer can own this PTY. Shut it down
       // immediately so it does not linger as an unreachable remote shell.
       this.releaseStartupCommand(managed)
+      const shutdownState = {
+        forceKillAccepted: false,
+        failedAttempts: 0,
+        label: 'stale-spawn'
+      }
+      this.scheduleManagedPtyForceShutdown(managed, shutdownState)
+      // Why: arm fallback ownership before native shutdown. node-pty can throw
+      // synchronously, especially during ConPTY startup, and the orphan still
+      // needs a later force-close owner even when this request rejects.
       killPtyForShutdown(managed, 'SIGTERM')
-      managed.killTimer = setTimeout(() => {
-        const still = this.ptys.get(id)
-        if (still && !still.disposed) {
-          killPtyForShutdown(still, 'SIGKILL')
-          // Why: stale-spawn cleanup has no client who will ever attach. If
-          // SIGKILL's onExit is missed (kernel edge case, uninterruptible
-          // sleep), the managed entry + ptmx fd would leak forever. Dispose
-          // synchronously so the entry is gone regardless of onExit timing.
-          this.notifyExitListener(still)
-          disposeManagedPty(still)
-          this.ptys.delete(id)
-        }
-      }, 5000)
+      if (process.platform === 'win32') {
+        shutdownState.forceKillAccepted = true
+      }
     } else if (managed.startupCommand) {
       this.scheduleStartupCommandDelivery(
         managed,
@@ -776,6 +869,7 @@ export class PtyHandler {
       this.notifyExitListener(managed)
       disposeManagedPty(managed)
       this.ptys.delete(id)
+      this.notifyIfEmpty()
       this.clearPtyFlowState(id)
       throw new Error(`PTY "${id}" not found`)
     }
@@ -853,30 +947,41 @@ export class PtyHandler {
     if (!managed) {
       return
     }
+    const mismatch = attachIdentityMismatches(
+      {
+        paneKey: typeof params.expectedPaneKey === 'string' ? params.expectedPaneKey : undefined,
+        tabId: typeof params.expectedTabId === 'string' ? params.expectedTabId : undefined
+      },
+      managed.attachIdentity ?? { paneKey: managed.paneKey, tabId: managed.tabId }
+    )
+    if (mismatch) {
+      throw new Error(`PTY "${id}" not found (identity mismatch)`)
+    }
 
     if (immediate) {
       this.releaseStartupCommand(managed)
       this.flushPtyOutput(id)
-      killPtyForShutdown(managed, 'SIGKILL')
-      // Why: SIGKILL has already reaped the child; release the ptmx fd on the
-      // same tick. Deferring to onExit leaves a window where the fd is live
-      // with a dead child. Idempotent via the disposed guard — if onExit fires
-      // later and also calls disposeManagedPty, the second call is a no-op.
-      disposeManagedPty(managed)
-      // Why: mirror the graceful-shutdown killTimer cleanup. If SIGKILL's
-      // onExit never fires (kernel edge case: uninterruptible sleep,
-      // D-state child on a bad NFS mount), the disposed managed entry would
-      // linger in this.ptys forever. Each stranded entry consumes a slot in
-      // the 50-PTY cap and is returned by listProcesses/serialize. Deleting
-      // here makes the map hygiene a hard guarantee, not "hopefully onExit
-      // runs". If onExit DOES fire later, its own `this.ptys.delete(id)` is
-      // a no-op.
-      this.notifyExitListener(managed)
-      this.ptys.delete(id)
-      this.clearPtyFlowState(id)
+      try {
+        killPtyForShutdown(managed, 'SIGKILL')
+      } catch (error) {
+        if (this.reapManagedPtyIfProvablyExited(managed)) {
+          return
+        }
+        throw error
+      }
+      // Why: kill returning is only acceptance. Preserve the PTY and listeners
+      // unless synchronous onExit or an ESRCH readback proves physical exit.
+      this.reapManagedPtyIfProvablyExited(managed)
     } else {
       this.releaseStartupCommand(managed)
-      killPtyForShutdown(managed, 'SIGTERM')
+      try {
+        killPtyForShutdown(managed, 'SIGTERM')
+      } catch (error) {
+        if (this.reapManagedPtyIfProvablyExited(managed)) {
+          return
+        }
+        throw error
+      }
 
       // Why: Some processes ignore SIGTERM (e.g. a hung child, a custom signal
       // handler). Without a SIGKILL fallback the PTY process would leak and the
@@ -887,28 +992,13 @@ export class PtyHandler {
       // collapses the graceful-shutdown window and risks interrupting shell
       // EXIT traps. Fd release happens via onExit (natural exit) or via the
       // killTimer → SIGKILL → disposeManagedPty chain below.
-      managed.killTimer = setTimeout(() => {
-        const still = this.ptys.get(id)
-        if (still && !still.disposed) {
-          killPtyForShutdown(still, 'SIGKILL')
-          this.flushPtyOutput(id)
-          // Why: emit pty.exit BEFORE disposeManagedPty sets disposed=true.
-          // The natural onExit short-circuits on `managed.disposed`, so
-          // without this notify the renderer never learns the pane is dead
-          // when the SIGKILL fallback fires for a SIGTERM-ignoring child.
-          this.dispatcher.notify('pty.exit', { id, code: -1 })
-          // Why: if SIGKILL's onExit never fires (kernel edge case,
-          // uninterruptible sleep, child wedged on a bad NFS mount), the
-          // fd and map entry would leak forever. Dispose synchronously so
-          // graceful-shutdown's SIGKILL fallback is a hard guarantee, not
-          // "hopefully onExit will run". The disposed guard inside
-          // disposeManagedPty makes a later onExit's dispose a no-op.
-          this.notifyExitListener(still)
-          disposeManagedPty(still)
-          this.ptys.delete(id)
-          this.clearPtyFlowState(id)
-        }
-      }, 5000)
+      this.scheduleManagedPtyForceShutdown(managed, {
+        // Why: Windows maps graceful and force shutdown to the same one-shot
+        // native close. POSIX still needs its distinct SIGKILL escalation.
+        forceKillAccepted: process.platform === 'win32',
+        failedAttempts: 0,
+        label: 'graceful-shutdown'
+      })
     }
   }
 
@@ -975,6 +1065,9 @@ export class PtyHandler {
   private async listProcesses(): Promise {
     const results: PtyProcessSummary[] = []
     for (const [id, managed] of this.ptys) {
+      if (managed.shutdownKillAccepted && this.reapManagedPtyIfProvablyExited(managed)) {
+        continue
+      }
       const title =
         (await getForegroundProcessName(managed.pty.pid, managed.pty.process || null)) || 'shell'
       results.push({
@@ -1166,6 +1259,74 @@ export class PtyHandler {
       disposeManagedPty(managed)
     }
     this.ptys.clear()
+    this.notifyIfEmpty()
+  }
+
+  async shutdownAndWait(timeoutMs: number): Promise {
+    this.cancelGraceTimer()
+    for (const [, managed] of this.ptys) {
+      if (managed.killTimer) {
+        clearTimeout(managed.killTimer)
+        managed.killTimer = undefined
+      }
+      this.clearStartupCommandTimer(managed)
+      const state = {
+        forceKillAccepted: false,
+        failedAttempts: 0,
+        label: 'relay-shutdown',
+        retryDelayMs: 100
+      }
+      try {
+        killPtyForShutdown(managed, 'SIGKILL')
+        state.forceKillAccepted = true
+      } catch (error) {
+        state.failedAttempts = 1
+        process.stderr.write(
+          `[pty-handler] relay-shutdown force kill failed: ${error instanceof Error ? error.message : String(error)}\n`
+        )
+      }
+      if (!this.reapManagedPtyIfProvablyExited(managed)) {
+        this.scheduleManagedPtyForceShutdown(managed, state)
+      }
+    }
+    this.notifyIfEmpty()
+    if (!(await this.waitForEmpty(timeoutMs))) {
+      throw new Error(`Timed out waiting for ${this.ptys.size} relay PTY session(s) to exit`)
+    }
+    if (this.outputFlushTimer !== null) {
+      clearTimeout(this.outputFlushTimer)
+      this.outputFlushTimer = null
+    }
+    this.pendingOutputByPty.clear()
+    this.lastInputAtByPty.clear()
+    this.interactiveOutputCharsByPty.clear()
+  }
+
+  private waitForEmpty(timeoutMs: number): Promise {
+    if (this.ptys.size === 0) {
+      return Promise.resolve(true)
+    }
+    return new Promise((resolve) => {
+      const waiter = (): void => {
+        clearTimeout(timer)
+        this.emptyWaiters.delete(waiter)
+        resolve(true)
+      }
+      const timer = setTimeout(() => {
+        this.emptyWaiters.delete(waiter)
+        resolve(false)
+      }, timeoutMs)
+      this.emptyWaiters.add(waiter)
+    })
+  }
+
+  private notifyIfEmpty(): void {
+    if (this.ptys.size !== 0) {
+      return
+    }
+    for (const waiter of this.emptyWaiters) {
+      waiter()
+    }
   }
 
   get activePtyCount(): number {
diff --git a/src/relay/relay-filesystem-watch-registry.test.ts b/src/relay/relay-filesystem-watch-registry.test.ts
new file mode 100644
index 00000000000..7c505ae652d
--- /dev/null
+++ b/src/relay/relay-filesystem-watch-registry.test.ts
@@ -0,0 +1,269 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+import { WatcherProcessFailure } from '../main/ipc/parcel-watcher-process-failure'
+import { WatcherProcessSupervisor } from '../main/ipc/parcel-watcher-process-supervisor'
+import type {
+  WatcherProcessCallback,
+  WatcherProcessHooks,
+  WatcherProcessSubscription
+} from '../main/ipc/parcel-watcher-process-subscription'
+import type { RelayDispatcher, RequestContext } from './dispatcher'
+import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry'
+import { createRelayWatcherProcessPool } from './relay-watcher-process-pool'
+
+type InstalledWatch = {
+  callback: WatcherProcessCallback
+  hooks: WatcherProcessHooks
+  unsubscribe: ReturnType Promise>>
+}
+
+type InstalledSupervisorWatch = InstalledWatch & { dir: string }
+
+function stubRelayWatcherSupervisors() {
+  const installed = new Map()
+  vi.spyOn(WatcherProcessSupervisor.prototype, 'subscribe').mockImplementation(function (
+    this: WatcherProcessSupervisor,
+    dir,
+    callback,
+    _options,
+    hooks = {}
+  ) {
+    const watches = installed.get(this) ?? []
+    const unsubscribe = vi.fn(async () => undefined)
+    watches.push({ dir, callback, hooks, unsubscribe })
+    installed.set(this, watches)
+    return Promise.resolve({ unsubscribe })
+  })
+  const dispose = vi
+    .spyOn(WatcherProcessSupervisor.prototype, 'dispose')
+    .mockImplementation(() => undefined)
+  return { installed, dispose }
+}
+
+class FakeWatcherPool {
+  readonly installed: InstalledWatch[] = []
+  readonly dispose = vi.fn()
+  readonly forgetRoot = vi.fn()
+
+  async subscribe(
+    _rootPath: string,
+    callback: WatcherProcessCallback,
+    _options: object,
+    hooks: WatcherProcessHooks
+  ): Promise {
+    const unsubscribe = vi.fn(async () => undefined)
+    this.installed.push({ callback, hooks, unsubscribe })
+    return { unsubscribe }
+  }
+}
+
+function createDispatcher() {
+  const detached = new Set<(clientId: number) => void>()
+  return {
+    notify: vi.fn(),
+    onClientDetached: vi.fn((listener: (clientId: number) => void) => {
+      detached.add(listener)
+      return () => detached.delete(listener)
+    })
+  }
+}
+
+function context(clientId: number): RequestContext {
+  return { clientId, isStale: () => false }
+}
+
+describe('RelayFilesystemWatchRegistry', () => {
+  let dispatcher: ReturnType
+  let pool: FakeWatcherPool
+  let registry: RelayFilesystemWatchRegistry
+
+  beforeEach(() => {
+    dispatcher = createDispatcher()
+    pool = new FakeWatcherPool()
+    registry = new RelayFilesystemWatchRegistry(dispatcher as unknown as RelayDispatcher, pool)
+  })
+
+  it('emits overflow around child replacement and resumes ordered event delivery', async () => {
+    await registry.watch('/repo', context(1))
+    const first = pool.installed[0]
+
+    first.callback(null, [{ type: 'create', path: '/repo/before.txt' }])
+    first.hooks.onInterruption?.()
+    first.callback(null, [{ type: 'update', path: '/repo/after-resubscribe.txt' }])
+
+    expect(dispatcher.notify.mock.calls).toEqual([
+      ['fs.changed', { events: [{ kind: 'create', absolutePath: '/repo/before.txt' }] }],
+      ['fs.changed', { events: [{ kind: 'overflow', absolutePath: '/repo' }] }],
+      ['fs.changed', { events: [{ kind: 'update', absolutePath: '/repo/after-resubscribe.txt' }] }]
+    ])
+  })
+
+  it('moves a terminal shard failure into recovery without dropping shared clients', async () => {
+    await registry.watch('/repo', context(1))
+    await registry.watch('/repo', context(2))
+    const first = pool.installed[0]
+    first.hooks.onTerminalError?.(
+      new WatcherProcessFailure(
+        'file watcher process crashed repeatedly',
+        'supervisor',
+        'supervisor_crash_fuse'
+      )
+    )
+    await Promise.resolve()
+
+    expect(pool.installed).toHaveLength(2)
+    pool.installed[1].callback(null, [{ type: 'create', path: '/repo/recovered.txt' }])
+    expect(dispatcher.notify).toHaveBeenNthCalledWith(1, 'fs.changed', {
+      events: [{ kind: 'overflow', absolutePath: '/repo' }]
+    })
+    expect(dispatcher.notify).toHaveBeenNthCalledWith(2, 'fs.changed', {
+      events: [{ kind: 'create', absolutePath: '/repo/recovered.txt' }]
+    })
+
+    registry.unwatch('/repo', context(1))
+    expect(pool.installed[1].unsubscribe).not.toHaveBeenCalled()
+    registry.unwatch('/repo', context(2))
+    expect(pool.installed[1].unsubscribe).toHaveBeenCalledTimes(1)
+  })
+
+  it('aborts a pending crawl only after the last same-root client leaves', async () => {
+    let sharedSignal: AbortSignal | undefined
+    let rejectSubscribe: ((error: Error) => void) | undefined
+    vi.spyOn(pool, 'subscribe').mockImplementation(
+      (_rootPath, _callback, _options, hooks): Promise =>
+        new Promise((_resolve, reject) => {
+          sharedSignal = hooks.signal
+          rejectSubscribe = reject
+          hooks.signal?.addEventListener(
+            'abort',
+            () =>
+              reject(
+                new WatcherProcessFailure(
+                  'file watcher subscription aborted',
+                  'subscription',
+                  'subscribe_aborted'
+                )
+              ),
+            { once: true }
+          )
+        })
+    )
+    const firstAbort = new AbortController()
+    const secondAbort = new AbortController()
+    const first = registry.watch('/repo', {
+      ...context(1),
+      signal: firstAbort.signal
+    })
+    const second = registry.watch('/repo', {
+      ...context(2),
+      signal: secondAbort.signal
+    })
+
+    firstAbort.abort()
+    await first
+    expect(sharedSignal?.aborted).toBe(false)
+
+    secondAbort.abort()
+    await second
+    expect(sharedSignal?.aborted).toBe(true)
+    expect(rejectSubscribe).toBeDefined()
+  })
+})
+
+describe('createRelayWatcherProcessPool', () => {
+  afterEach(() => vi.restoreAllMocks())
+
+  it('uses one healthy watcher process per SSH host for each standard repo', async () => {
+    const { installed } = stubRelayWatcherSupervisors()
+    const firstHostPool = createRelayWatcherProcessPool()
+    const secondHostPool = createRelayWatcherProcessPool()
+    const repoBase = join(tmpdir(), 'ssh-repo')
+    const roots = [repoBase, join(repoBase, '.git'), join(repoBase, 'worktree')]
+    try {
+      for (const root of roots) {
+        await firstHostPool.subscribe(root, vi.fn(), {}, {})
+        await secondHostPool.subscribe(root, vi.fn(), {}, {})
+      }
+
+      expect(
+        Array.from(installed.values()).map((watches) => watches.map(({ dir }) => dir))
+      ).toEqual([roots, roots])
+    } finally {
+      firstHostPool.dispose()
+      secondHostPool.dispose()
+    }
+  })
+
+  it('quarantines and recovers each standard-repo root after the shared child fails', async () => {
+    const { installed, dispose } = stubRelayWatcherSupervisors()
+    const pool = createRelayWatcherProcessPool()
+    const dispatcher = createDispatcher()
+    const registry = new RelayFilesystemWatchRegistry(
+      dispatcher as unknown as RelayDispatcher,
+      pool
+    )
+    const repoBase = join(tmpdir(), 'ssh-repo-recovery')
+    const roots = [repoBase, join(repoBase, '.git'), join(repoBase, 'worktree')]
+    try {
+      for (const root of roots) {
+        await registry.watch(root, context(1))
+      }
+      const healthyWatches = Array.from(installed.values())[0]
+      const failure = new WatcherProcessFailure(
+        'file watcher process crashed repeatedly',
+        'supervisor',
+        'supervisor_crash_fuse'
+      )
+
+      for (const watch of healthyWatches) {
+        watch.hooks.onTerminalError?.(failure)
+      }
+      await Promise.resolve()
+
+      const recoveredWatches = Array.from(installed.values()).slice(1)
+      expect(dispose).toHaveBeenCalledTimes(1)
+      expect(recoveredWatches.map((watches) => watches.map(({ dir }) => dir))).toEqual(
+        roots.map((root) => [root])
+      )
+      expect(dispatcher.notify.mock.calls.slice(0, roots.length)).toEqual(
+        roots.map((root) => ['fs.changed', { events: [{ kind: 'overflow', absolutePath: root }] }])
+      )
+
+      for (const watches of recoveredWatches) {
+        const [{ callback, dir }] = watches
+        callback(null, [{ type: 'update', path: join(dir, 'recovered.txt') }])
+      }
+      expect(dispatcher.notify.mock.calls.slice(roots.length)).toEqual(
+        roots.map((root) => [
+          'fs.changed',
+          {
+            events: [{ kind: 'update', absolutePath: join(root, 'recovered.txt') }]
+          }
+        ])
+      )
+    } finally {
+      registry.dispose()
+    }
+  })
+
+  it('fails closed instead of loading the native watcher in the relay process', async () => {
+    const previousVitest = process.env.VITEST
+    process.env.VITEST = 'true'
+    const pool = createRelayWatcherProcessPool(
+      join(tmpdir(), `missing-relay-watcher-${process.pid}.js`)
+    )
+    try {
+      await expect(pool.subscribe('/repo', vi.fn(), {}, {})).rejects.toMatchObject({
+        code: 'entry_missing'
+      })
+    } finally {
+      pool.dispose()
+      if (previousVitest === undefined) {
+        delete process.env.VITEST
+      } else {
+        process.env.VITEST = previousVitest
+      }
+    }
+  })
+})
diff --git a/src/relay/relay-filesystem-watch-registry.ts b/src/relay/relay-filesystem-watch-registry.ts
new file mode 100644
index 00000000000..a1ccda05826
--- /dev/null
+++ b/src/relay/relay-filesystem-watch-registry.ts
@@ -0,0 +1,312 @@
+import type { RelayDispatcher, RequestContext } from './dispatcher'
+import { MAX_BATCHED_WATCHER_EVENTS } from '../main/ipc/filesystem-watcher-event-batch'
+import { isWatcherProcessFailure } from '../main/ipc/parcel-watcher-process-failure'
+import type {
+  WatcherProcessEvent,
+  WatcherProcessSubscription
+} from '../main/ipc/parcel-watcher-process'
+import {
+  WATCHER_IGNORE_DIRS,
+  buildParcelWatcherIgnoreOptions
+} from '../main/ipc/filesystem-watcher-ignore'
+import {
+  createRelayWatcherProcessPool,
+  type RelayWatcherProcessPool
+} from './relay-watcher-process-pool'
+
+const MAX_RELAY_WATCH_ROOTS = 20
+const RELAY_WATCH_CRAWL_TIMEOUT_MS = 60_000
+const RELAY_WATCH_OPTIONS = buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS)
+
+type RelayWatchState = {
+  rootPath: string
+  clients: Map boolean>
+  setupPromise: Promise
+  subscription: WatcherProcessSubscription | null
+  abortController: AbortController
+  generation: number
+  closed: boolean
+}
+
+function overflowEvent(rootPath: string): Record {
+  return { events: [{ kind: 'overflow', absolutePath: rootPath }] }
+}
+
+function createWatchAbortError(): Error {
+  const error = new Error('Request "fs.watch" was cancelled')
+  error.name = 'AbortError'
+  return error
+}
+
+function shouldRetryInitialWatch(error: unknown): boolean {
+  return (
+    isWatcherProcessFailure(error) &&
+    error.code !== 'entry_missing' &&
+    error.code !== 'subscribe_aborted' &&
+    error.code !== 'supervisor_disposed' &&
+    (error.scope === 'supervisor' || error.code === 'subscribe_timeout')
+  )
+}
+
+export class RelayFilesystemWatchRegistry {
+  private readonly watches = new Map()
+
+  constructor(
+    private readonly dispatcher: RelayDispatcher,
+    private readonly watcherPool: RelayWatcherProcessPool = createRelayWatcherProcessPool()
+  ) {
+    this.dispatcher.onClientDetached?.((clientId) => this.releaseClientWatches(clientId))
+  }
+
+  async watch(rootPath: string, context?: RequestContext): Promise {
+    this.releaseStaleWatches()
+    const clientId = context?.clientId ?? 0
+    const isStale = context?.isStale ?? (() => false)
+    const existing = this.watches.get(rootPath)
+    if (existing) {
+      existing.clients.set(clientId, isStale)
+      await this.awaitSetupForClient(existing, clientId, context)
+      return
+    }
+
+    if (this.watches.size >= MAX_RELAY_WATCH_ROOTS) {
+      throw new Error('Maximum number of file watchers reached')
+    }
+
+    const state: RelayWatchState = {
+      rootPath,
+      clients: new Map([[clientId, isStale]]),
+      setupPromise: Promise.resolve(),
+      subscription: null,
+      abortController: new AbortController(),
+      generation: 0,
+      closed: false
+    }
+    this.watches.set(rootPath, state)
+    state.setupPromise = this.startInitialWatch(state)
+    await this.awaitSetupForClient(state, clientId, context)
+  }
+
+  unwatch(rootPath: string, context?: RequestContext): void {
+    const state = this.watches.get(rootPath)
+    if (state) {
+      this.releaseWatchClient(state, context?.clientId ?? 0)
+    }
+  }
+
+  dispose(): void {
+    for (const state of Array.from(this.watches.values())) {
+      this.closeWatch(state)
+    }
+    this.watcherPool.dispose()
+  }
+
+  private async startInitialWatch(state: RelayWatchState): Promise {
+    try {
+      await this.subscribeState(state)
+    } catch (firstError) {
+      if (!state.closed && shouldRetryInitialWatch(firstError)) {
+        try {
+          await this.subscribeState(state)
+          this.emitOverflow(state)
+          return
+        } catch (quarantineError) {
+          this.closeWatch(state)
+          throw quarantineError
+        }
+      }
+      this.closeWatch(state)
+      throw firstError
+    }
+  }
+
+  private subscribeState(state: RelayWatchState): Promise {
+    const generation = ++state.generation
+    const emitOverflow = (): void => {
+      if (state.generation === generation) {
+        this.emitOverflow(state)
+      }
+    }
+    return this.watcherPool
+      .subscribe(
+        state.rootPath,
+        (error, events) => {
+          if (state.closed || state.generation !== generation) {
+            return
+          }
+          if (error) {
+            process.stderr.write(
+              `[relay] File watcher error for ${state.rootPath}: ${error.message}\n`
+            )
+            emitOverflow()
+            return
+          }
+          this.emitEvents(state, events)
+        },
+        RELAY_WATCH_OPTIONS,
+        {
+          delivery: { maxEventsPerBatch: MAX_BATCHED_WATCHER_EVENTS },
+          onInterruption: emitOverflow,
+          onOverflow: emitOverflow,
+          onTerminalError: (error) => this.recoverWatch(state, generation, error),
+          signal: state.abortController.signal,
+          subscribeTimeoutMs: RELAY_WATCH_CRAWL_TIMEOUT_MS
+        }
+      )
+      .then(async (subscription) => {
+        if (
+          state.closed ||
+          state.generation !== generation ||
+          this.watches.get(state.rootPath) !== state
+        ) {
+          await subscription.unsubscribe()
+          return
+        }
+        state.subscription = subscription
+      })
+  }
+
+  private recoverWatch(state: RelayWatchState, failedGeneration: number, error: Error): void {
+    if (state.closed || state.generation !== failedGeneration) {
+      return
+    }
+    state.subscription = null
+    this.emitOverflow(state)
+    const recovery = this.subscribeState(state)
+    state.setupPromise = recovery
+    void recovery.catch((recoveryError: unknown) => {
+      if (!state.closed) {
+        const message = recoveryError instanceof Error ? recoveryError.message : error.message
+        process.stderr.write(
+          `[relay] File watcher disabled after bounded recovery for ${state.rootPath}: ${message}\n`
+        )
+        this.closeWatch(state)
+      }
+    })
+  }
+
+  private emitEvents(state: RelayWatchState, events: readonly WatcherProcessEvent[]): void {
+    if (state.closed || events.length === 0) {
+      return
+    }
+    this.dispatcher.notify('fs.changed', {
+      events: events.map((event) => ({
+        kind: event.type,
+        absolutePath: event.path,
+        ...(event.isDirectory === undefined ? {} : { isDirectory: event.isDirectory })
+      }))
+    })
+  }
+
+  private emitOverflow(state: RelayWatchState): void {
+    if (!state.closed) {
+      this.dispatcher.notify('fs.changed', overflowEvent(state.rootPath))
+    }
+  }
+
+  private async awaitSetupForClient(
+    state: RelayWatchState,
+    clientId: number,
+    context?: RequestContext
+  ): Promise {
+    try {
+      await this.awaitSetupWithAbort(state.setupPromise, context?.signal)
+    } catch (error) {
+      this.releaseWatchClient(state, clientId)
+      const expectedAbort =
+        (error instanceof Error && error.name === 'AbortError') ||
+        (isWatcherProcessFailure(error) && error.code === 'subscribe_aborted')
+      if (!expectedAbort && error instanceof Error) {
+        process.stderr.write(
+          `[relay] File watcher not available for ${state.rootPath}: ${error.message}\n`
+        )
+        throw error
+      }
+      return
+    }
+    if (context?.isStale()) {
+      this.releaseWatchClient(state, clientId)
+    }
+  }
+
+  private awaitSetupWithAbort(setupPromise: Promise, signal?: AbortSignal): Promise {
+    if (!signal) {
+      return setupPromise
+    }
+    if (signal.aborted) {
+      return Promise.reject(createWatchAbortError())
+    }
+    return new Promise((resolve, reject) => {
+      const onAbort = (): void => {
+        cleanup()
+        reject(createWatchAbortError())
+      }
+      const cleanup = (): void => signal.removeEventListener('abort', onAbort)
+      signal.addEventListener('abort', onAbort, { once: true })
+      setupPromise.then(
+        () => {
+          cleanup()
+          resolve()
+        },
+        (error) => {
+          cleanup()
+          reject(error)
+        }
+      )
+    })
+  }
+
+  private releaseClientWatches(clientId: number): void {
+    for (const state of Array.from(this.watches.values())) {
+      this.releaseWatchClient(state, clientId)
+    }
+  }
+
+  private releaseStaleWatches(): void {
+    for (const state of Array.from(this.watches.values())) {
+      for (const [clientId, isStale] of state.clients) {
+        if (isStale()) {
+          state.clients.delete(clientId)
+        }
+      }
+      if (state.clients.size === 0) {
+        this.closeWatch(state)
+      }
+    }
+  }
+
+  private releaseWatchClient(state: RelayWatchState, clientId: number): void {
+    state.clients.delete(clientId)
+    if (!state.closed && state.clients.size === 0) {
+      this.closeWatch(state)
+    }
+  }
+
+  private closeWatch(state: RelayWatchState): void {
+    if (state.closed) {
+      return
+    }
+    state.closed = true
+    state.generation++
+    state.abortController.abort()
+    state.clients.clear()
+    if (this.watches.get(state.rootPath) === state) {
+      this.watches.delete(state.rootPath)
+    }
+    const subscription = state.subscription
+    state.subscription = null
+    if (subscription) {
+      // Why: a child can die during unwatch; release quarantine history even
+      // when physical teardown reports that already-contained failure.
+      void subscription.unsubscribe().then(
+        () => this.watcherPool.forgetRoot(state.rootPath),
+        () => this.watcherPool.forgetRoot(state.rootPath)
+      )
+      return
+    }
+    void state.setupPromise.then(
+      () => this.watcherPool.forgetRoot(state.rootPath),
+      () => this.watcherPool.forgetRoot(state.rootPath)
+    )
+  }
+}
diff --git a/src/relay/relay-watcher-process-pool.ts b/src/relay/relay-watcher-process-pool.ts
new file mode 100644
index 00000000000..e0093b9a166
--- /dev/null
+++ b/src/relay/relay-watcher-process-pool.ts
@@ -0,0 +1,29 @@
+import { join } from 'node:path'
+import { RuntimeWatcherProcessPool } from '../main/ipc/runtime-watcher-process-pool'
+import { WatcherProcessSupervisor } from '../main/ipc/parcel-watcher-process-supervisor'
+
+export type RelayWatcherProcessPool = Pick<
+  RuntimeWatcherProcessPool,
+  'dispose' | 'forgetRoot' | 'subscribe'
+>
+
+export function getRelayWatcherProcessEntryPath(): string {
+  return join(__dirname, 'relay-watcher.js')
+}
+
+export function createRelayWatcherProcessPool(
+  entryPath = getRelayWatcherProcessEntryPath()
+): RelayWatcherProcessPool {
+  return new RuntimeWatcherProcessPool({
+    // Why: one SSH workspace normally installs three roots, and each remote
+    // watcher child carries substantial Node/native-addon process overhead.
+    maxSharedSupervisors: 1,
+    createSupervisor: () =>
+      new WatcherProcessSupervisor({
+        entryPath,
+        // Why: a leaked VITEST environment must never move the native addon
+        // back into the relay when its crash-isolation child is missing.
+        useInProcessVitestFallback: false
+      })
+  })
+}
diff --git a/src/relay/relay.ts b/src/relay/relay.ts
index d47c2019582..7348dbfaf2d 100644
--- a/src/relay/relay.ts
+++ b/src/relay/relay.ts
@@ -20,6 +20,7 @@
 // SSH channel's stdin/stdout to the existing relay's socket.
 
 import { createServer, createConnection, type Socket, type Server } from 'node:net'
+import { randomUUID } from 'node:crypto'
 import { homedir } from 'node:os'
 import { resolve, join } from 'node:path'
 import { unlinkSync, existsSync, statSync } from 'node:fs'
@@ -669,6 +670,9 @@ async function main(): Promise {
   let socketServer: Server | null = null
   const launchVersion = readLaunchVersion()
   const startedAt = Date.now()
+  // Why: relay-local pty-N counters reset at process start. Thread this stable
+  // boot identity into app PTY ids so delayed exits cannot hit a recycled id.
+  const instanceId = randomUUID()
   let acceptedSocketConnections = 0
   let hasAcceptedSocketClient = false
   let graceDeadlineAt: number | null = null
@@ -676,6 +680,7 @@ async function main(): Promise {
 
   dispatcher.onRequest('relay.status', async () => ({
     pid: process.pid,
+    instanceId,
     uptimeMs: Date.now() - startedAt,
     detached,
     stdoutAlive,
@@ -972,7 +977,7 @@ async function main(): Promise {
     )
     ptyHandler.startGraceTimer(() => {
       relayLogLine(`[relay] Grace expired (${reason}); shutting down`)
-      shutdown()
+      void shutdown()
     }, timeoutMs)
   }
 
@@ -1014,29 +1019,41 @@ async function main(): Promise {
     })
   }
 
-  function shutdown(): void {
+  let shuttingDown = false
+  async function shutdown(): Promise {
+    if (shuttingDown) {
+      return
+    }
+    shuttingDown = true
     relayLogLine(
       `[relay] Shutdown: ptys=${ptyHandler.activePtyCount}, clients=${socketClients.size}, ownsSocket=${ownsSocketPath}`
     )
     graceDeadlineAt = null
     graceReason = null
     dispatcher.dispose()
-    ptyHandler.dispose()
-    fsHandler.dispose()
-    gitHandler.dispose()
-    hookServer.stop()
-    // Why: Node's Unix server.close() can unlink the listen path. If the path
-    // was externally removed and rebound by a newer relay, closing this older
-    // server would strand the newer daemon behind a missing socket.
-    if (socketServer && ownsCurrentSocketPath()) {
-      socketServer.close()
+    try {
+      await ptyHandler.shutdownAndWait(4_000)
+    } catch (error) {
+      relayLogLine(
+        `[relay] PTY shutdown drain incomplete: ${error instanceof Error ? error.message : String(error)}`
+      )
+    } finally {
+      fsHandler.dispose()
+      gitHandler.dispose()
+      hookServer.stop()
+      // Why: Node's Unix server.close() can unlink the listen path. If the path
+      // was externally removed and rebound by a newer relay, closing this older
+      // server would strand the newer daemon behind a missing socket.
+      if (socketServer && ownsCurrentSocketPath()) {
+        socketServer.close()
+      }
+      cleanupOwnedSocket()
+      process.exit(0)
     }
-    cleanupOwnedSocket()
-    process.exit(0)
   }
 
-  process.on('SIGTERM', shutdown)
-  process.on('SIGINT', shutdown)
+  process.on('SIGTERM', () => void shutdown())
+  process.on('SIGINT', () => void shutdown())
   // Why: when the SSH session drops, the OS sends SIGHUP to the relay's
   // process group. Node's default SIGHUP behavior is to exit immediately,
   // which kills all PTYs before the grace period can start. Ignoring
diff --git a/src/relay/subprocess.test.ts b/src/relay/subprocess.test.ts
index 4d19542b91c..0d9f1d0e089 100644
--- a/src/relay/subprocess.test.ts
+++ b/src/relay/subprocess.test.ts
@@ -10,6 +10,7 @@ import { getEndpointFileName } from '../shared/agent-hook-listener'
 import { relayTestSocketPath } from './relay-test-socket-path'
 
 const RELAY_TS_ENTRY = path.resolve(__dirname, 'relay.ts')
+const WATCHER_TS_ENTRY = path.resolve(__dirname, '../main/ipc/parcel-watcher-process-entry.ts')
 let bundleDir: string
 let relayEntry: string
 const spawnedSocketDirs: string[] = []
@@ -24,7 +25,17 @@ beforeAll(async () => {
     target: 'node18',
     format: 'cjs',
     outfile: relayEntry,
-    external: ['node-pty', '@parcel/watcher'],
+    external: ['node-pty', '@parcel/watcher', 'electron'],
+    sourcemap: false
+  })
+  await build({
+    entryPoints: [WATCHER_TS_ENTRY],
+    bundle: true,
+    platform: 'node',
+    target: 'node18',
+    format: 'cjs',
+    outfile: path.join(bundleDir, 'relay-watcher.js'),
+    external: ['@parcel/watcher'],
     sourcemap: false
   })
 }, 30_000)
@@ -421,14 +432,18 @@ describe('Subprocess: Relay entry point', () => {
     expect(resp.error).toBeUndefined()
     const status = resp.result as {
       pid: number
+      instanceId: string
       memory: { rss: number }
       ptys: { active: number }
       socket: { owned: boolean; listening: boolean; clients: number }
     }
     expect(status.pid).toBeGreaterThan(0)
+    expect(status.instanceId).toMatch(/^[0-9a-f-]{36}$/)
     expect(status.memory.rss).toBeGreaterThan(0)
     expect(status.ptys.active).toBe(0)
     expect(status.socket).toMatchObject({ owned: true, listening: true, clients: 0 })
+    const next = await relay.waitForResponse(relay.send('relay.status'))
+    expect((next.result as { instanceId: string }).instanceId).toBe(status.instanceId)
   }, 10_000)
 
   it('session.registerRoot request returns ok acknowledgment', async () => {
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index eeaff50abb5..9e111a6cb4d 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -1151,7 +1151,7 @@ function App(): React.JSX.Element {
           // UI writer and clobber ui.json (sidebar width, sort, filters, etc.).
           const fallbackUI = getStartupErrorFallbackUI(uiHydrated)
           if (fallbackUI) {
-            actions.hydratePersistedUI(fallbackUI)
+            actions.hydratePersistedUI(fallbackUI, 'startup')
           }
           // Why (issue #1158): surface a sticky, dismissible toast so the
           // user knows they're in degraded "no-save" mode. Without this, every
@@ -1441,6 +1441,10 @@ function App(): React.JSX.Element {
         hideAutomationGeneratedWorkspaces,
         showDotfilesByWorktree,
         filterRepoIds,
+        // Why: persist the active view so a reload restores it. openTaskPage etc.
+        // mutate activeView directly (not via setActiveView), so the value-keyed
+        // writer is what catches every transition.
+        activeView,
         // Why: rides the same debounced save so dashboard auto-acks (which fire
         // on focus/visibility) and the in-memory ack cleanup paths in
         // agent-status.ts (close/dismiss) both flow to disk through map
@@ -1467,6 +1471,7 @@ function App(): React.JSX.Element {
     hideAutomationGeneratedWorkspaces,
     showDotfilesByWorktree,
     filterRepoIds,
+    activeView,
     acknowledgedAgentsByPaneKey
   ])
 
diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css
index 56841b77245..90549525afe 100644
--- a/src/renderer/src/assets/main.css
+++ b/src/renderer/src/assets/main.css
@@ -204,6 +204,8 @@
   --git-graph-lane-4: #40b0a6;
   --git-graph-lane-5: #b66dff;
   --annotation-highlight: #f59e0b;
+  --markdown-search-match: #facc15;
+  --markdown-search-match-active: #fb923c;
   /* Why: tab-group splits use a dedicated divider — `--border` is too faint on
      card/editor surfaces when panes are side-by-side or stacked. Default must
      meet >=3:1 against `--card`, not only the hover/drag strong state. */
@@ -292,6 +294,8 @@
   --git-graph-lane-4: #40b0a6;
   --git-graph-lane-5: #b66dff;
   --annotation-highlight: #fbbf24;
+  --markdown-search-match: #facc15;
+  --markdown-search-match-active: #fb923c;
   /* Brighter than terminal defaults so the always-visible line clears 3:1 on `--card`. */
   --tab-group-split-divider: #71717a;
   --tab-group-split-divider-strong: #a1a1aa;
@@ -970,7 +974,8 @@
 .editor-header {
   display: flex;
   align-items: center;
-  gap: 12px;
+  /* Keep adjacent actions aligned with the app's compact toolbar spacing. */
+  gap: 4px;
   min-height: 36px;
   padding: 6px 14px;
   background: var(--editor-surface);
@@ -1209,6 +1214,31 @@
   background: color-mix(in srgb, var(--worktree-sidebar-foreground) 14%, var(--worktree-sidebar));
 }
 
+/* Why: one-shot status-bar callout is portaled fixed above the meters.
+   Opaque popover surface (not glass) so titlebar/terminal chrome cannot
+   bleed through; use design tokens + the documented floating elevation. */
+.status-bar-change-notice-card {
+  border: 1px solid var(--border);
+  background: var(--popover);
+  color: var(--popover-foreground);
+  /* STYLEGUIDE: floating elevation for popovers that escape the editor surface. */
+  box-shadow: 0 10px 24px rgb(0 0 0 / 0.18);
+}
+
+/* Why: caret points down at the usage meters this notice explains. */
+.status-bar-change-notice-card::after {
+  content: '';
+  position: absolute;
+  left: 1.25rem;
+  bottom: -6px;
+  width: 12px;
+  height: 12px;
+  transform: rotate(45deg);
+  border-right: 1px solid var(--border);
+  border-bottom: 1px solid var(--border);
+  background: var(--popover);
+}
+
 /* Why: the first-run Mobile Emulator intro sits directly under that menu row;
    an upward caret aligned to the phone icon reads as a callout, not a footer. */
 .mobile-emulator-tab-intro-callout--menu {
diff --git a/src/renderer/src/assets/markdown-preview.css b/src/renderer/src/assets/markdown-preview.css
index d043482689b..0b153d77cb1 100644
--- a/src/renderer/src/assets/markdown-preview.css
+++ b/src/renderer/src/assets/markdown-preview.css
@@ -237,15 +237,17 @@
   }
 }
 
-.markdown-preview-search-match {
-  padding: 0;
-  border-radius: 2px;
-  background: color-mix(in srgb, #facc15 50%, transparent);
+/* Why: search hits are painted with the CSS Custom Highlight API (Ranges, no
+   DOM mutation) instead of injected  elements — see markdown-preview-search.ts.
+   ::highlight() supports color/background only, not border-radius/padding. */
+::highlight(markdown-preview-search-match) {
+  background-color: color-mix(in srgb, var(--markdown-search-match) 50%, transparent);
   color: inherit;
 }
 
-.markdown-preview-search-match[data-active] {
-  background: color-mix(in srgb, #fb923c 60%, transparent);
+::highlight(markdown-preview-search-active-match) {
+  background-color: color-mix(in srgb, var(--markdown-search-match-active) 60%, transparent);
+  color: inherit;
 }
 
 .markdown-review-toolbar {
diff --git a/src/renderer/src/assets/rich-markdown-editor.css b/src/renderer/src/assets/rich-markdown-editor.css
index ccf79ad3638..38c625f0d37 100644
--- a/src/renderer/src/assets/rich-markdown-editor.css
+++ b/src/renderer/src/assets/rich-markdown-editor.css
@@ -1026,6 +1026,27 @@
   color: #58a6ff;
 }
 
+.rich-markdown-editor sup[data-rich-markdown-html-superscript-link] {
+  line-height: 0;
+}
+
+.rich-markdown-editor .rich-markdown-html-superscript-link {
+  color: #0969da;
+  text-decoration: underline;
+  text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
+  text-underline-offset: 2px;
+}
+
+.dark .rich-markdown-editor .rich-markdown-html-superscript-link {
+  color: #58a6ff;
+}
+
+.rich-markdown-mod-held
+  .rich-markdown-editor
+  .rich-markdown-html-superscript-link[data-actionable] {
+  cursor: pointer;
+}
+
 .rich-markdown-editor img {
   max-width: 100%;
   border-radius: 8px;
diff --git a/src/renderer/src/components/AgentStateDot.test.ts b/src/renderer/src/components/AgentStateDot.test.ts
index e02c5d496ce..2e17d0423df 100644
--- a/src/renderer/src/components/AgentStateDot.test.ts
+++ b/src/renderer/src/components/AgentStateDot.test.ts
@@ -23,6 +23,7 @@ describe('AgentStateDot', () => {
     expect(markup).toContain('border-yellow-500')
     expect(markup).toContain('border-t-transparent')
     expect(markup).toContain('[animation:spin_1s_steps(12,end)_infinite]')
+    expect(markup).toContain('motion-reduce:animate-none')
     expect(markup).not.toContain('animate-spin')
   })
 
diff --git a/src/renderer/src/components/AgentStateDot.tsx b/src/renderer/src/components/AgentStateDot.tsx
index 951e3be36f1..9a4fe792e13 100644
--- a/src/renderer/src/components/AgentStateDot.tsx
+++ b/src/renderer/src/components/AgentStateDot.tsx
@@ -30,6 +30,7 @@ export type AgentDotState =
   // worktree-level permission dot.
   | 'permission'
 
+/** Return the accessible label shared by every visual agent-state marker. */
 export function agentStateLabel(state: AgentDotState): string {
   switch (state) {
     case 'working':
@@ -57,6 +58,7 @@ type Props = {
   className?: string
 }
 
+/** Render the compact state glyph used by agent rows and terminal tabs. */
 export const AgentStateDot = React.memo(function AgentStateDot({
   state,
   size = 'sm',
@@ -76,7 +78,7 @@ export const AgentStateDot = React.memo(function AgentStateDot({
           className={cn(
             // Why: match the sidebar worktree spinner's stepped cadence so
             // long-running visible agents do not keep a full-frame-rate loop.
-            'block rounded-full border-2 border-yellow-500 border-t-transparent [animation:spin_1s_steps(12,end)_infinite]',
+            'block rounded-full border-2 border-yellow-500 border-t-transparent [animation:spin_1s_steps(12,end)_infinite] motion-reduce:animate-none',
             inner
           )}
         />
diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx
index fec1c28dab6..d076f0b325d 100644
--- a/src/renderer/src/components/GitHubItemDialog.tsx
+++ b/src/renderer/src/components/GitHubItemDialog.tsx
@@ -127,6 +127,7 @@ import {
   getPrCommentAudienceFilters,
   type PRCommentAudienceFilter
 } from '@/lib/pr-comment-audience'
+import { usePRBotAuthorOverrides } from '@/lib/pr-bot-author-overrides'
 import {
   getPRCommentGroupCount,
   getPRCommentGroupId,
@@ -3124,10 +3125,14 @@ function ConversationTab({
   const [bodyEditing, setBodyEditing] = useState(false)
   const [bodySaving, setBodySaving] = useState(false)
   const canUseRepoMutationContext = canUseGitHubRepoContext(repoPath, sourceContext)
-  const commentCounts = useMemo(() => getPRCommentAudienceCounts(comments), [comments])
+  const botAuthorOverrides = usePRBotAuthorOverrides()
+  const commentCounts = useMemo(
+    () => getPRCommentAudienceCounts(comments, botAuthorOverrides),
+    [botAuthorOverrides, comments]
+  )
   const visibleComments = useMemo(
-    () => filterPRCommentsByAudience(comments, commentFilter),
-    [commentFilter, comments]
+    () => filterPRCommentsByAudience(comments, commentFilter, botAuthorOverrides),
+    [botAuthorOverrides, commentFilter, comments]
   )
   const visibleCommentGroups = useMemo(() => groupPRComments(visibleComments), [visibleComments])
   const resolvedTimelineItems = timelineItems ?? EMPTY_GITHUB_ISSUE_TIMELINE_ITEMS
@@ -6338,68 +6343,20 @@ function GHEditSection({
         
 
         
+ {/* Why: primary open/start CTA lives only in the issue header — property + columns are metadata (status, assignees, labels, attached workspace). */}
{translate('auto.components.GitHubItemDialog.2e4d806c92', 'Workspace')}
{attachedWorkspaceLabel ? ( -
+
{attachedWorkspaceLabel}
- ) : null} - {hasAttachedWorkspace ? ( - - - - - - - - - onUse(item)}> - - {translate('auto.components.GitHubItemDialog.36182aa57f', 'Start new workspace')} - - - ) : ( - +
+ {translate('auto.components.GitHubItemDialog.886a64b081', 'None yet')} +
)}
@@ -7136,6 +7093,7 @@ export default function GitHubItemDialog({ const comments = details?.comments ?? [] const timelineItems = details?.timelineItems ?? [] const files = details?.files ?? [] + const filesUnavailable = details?.filesUnavailable ?? false const checks = details?.checks ?? [] const [pendingViewedPaths, setPendingViewedPaths] = useState>(() => new Set()) // Why: clipboard IPC can resolve after the dialog unmounts; skip copied-state @@ -7833,6 +7791,21 @@ export default function GitHubItemDialog({
+ ) : filesUnavailable && files.length === 0 ? ( + // Why: the file fetch failed (rate limit, auth, unresolved + // remote); offer a retry instead of implying the PR is empty. +
+
+ {translate( + 'auto.components.GitHubItemDialog.filesUnavailable', + "Couldn't load changed files." + )} +
+ +
) : files.length === 0 ? (
{translate( diff --git a/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx b/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx index 5e45c6e71da..f8f11c9567b 100644 --- a/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx +++ b/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx @@ -7,6 +7,10 @@ import { LoaderCircle } from 'lucide-react' import { createRichMarkdownExtensions } from '@/components/editor/rich-markdown-extensions' import { encodeRawMarkdownHtmlForRichEditor } from '@/components/editor/raw-markdown-html' +import { + createRichMarkdownEditorCodec, + type RichMarkdownEditorCodec +} from '@/components/editor/rich-markdown-source-transport' import { LinearIssueMarkdownToolbar } from '@/components/LinearIssueMarkdownToolbar' import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' import { cn } from '@/lib/utils' @@ -26,8 +30,8 @@ type LinearIssueMarkdownDescriptionEditorProps = { submitShortcutLabel: string } -function createLinearIssueMarkdownExtensions() { - const extensions = createRichMarkdownExtensions() +function createLinearIssueMarkdownExtensions(codec: RichMarkdownEditorCodec) { + const extensions = createRichMarkdownExtensions({ codec }) return [ ...extensions, Placeholder.configure({ @@ -54,18 +58,24 @@ export function LinearIssueMarkdownDescriptionEditor({ const richMarkdownSpellcheckEnabled = useAppStore( (s) => s.settings?.richMarkdownSpellcheckEnabled ?? true ) + // Why: changing language recreates Tiptap and re-registers tokenizers, so it + // must also receive a fresh private Marked registry instead of growing one. + const codec = useMemo(() => { + void language + return createRichMarkdownEditorCodec() + }, [language]) const linearIssueMarkdownExtensions = useMemo(() => { // Why: Tiptap freezes extension options when the editor is created; the // language value is the recreation key for translated extension options. void language - return createLinearIssueMarkdownExtensions() - }, [language]) + return createLinearIssueMarkdownExtensions(codec) + }, [codec, language]) const editor = useEditor( { immediatelyRender: false, extensions: linearIssueMarkdownExtensions, - content: encodeRawMarkdownHtmlForRichEditor(value), + content: encodeRawMarkdownHtmlForRichEditor(value, codec), contentType: 'markdown', editable: !disabled, editorProps: { @@ -99,7 +109,7 @@ export function LinearIssueMarkdownDescriptionEditor({ onChange(nextValue) } }, - [language] + [codec, language] ) useRichMarkdownSpellcheckAttribute(editor, richMarkdownSpellcheckEnabled) @@ -124,12 +134,12 @@ export function LinearIssueMarkdownDescriptionEditor({ // Why: Linear remains the source of truth when the selected issue changes // or an optimistic save is reverted; keep the rich view aligned with it. - editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(value), { + editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(value, codec), { contentType: 'markdown', emitUpdate: false }) lastEditorMarkdownRef.current = value - }, [editor, value]) + }, [codec, editor, value]) return (
getPRCommentAudienceCounts(comments), [comments]) + const botAuthorOverrides = usePRBotAuthorOverrides() + const commentCounts = useMemo( + () => getPRCommentAudienceCounts(comments, botAuthorOverrides), + [botAuthorOverrides, comments] + ) const visibleComments = useMemo( - () => filterPRCommentsByAudience(comments, commentFilter), - [commentFilter, comments] + () => filterPRCommentsByAudience(comments, commentFilter, botAuthorOverrides), + [botAuthorOverrides, commentFilter, comments] ) const visibleCommentGroups = useMemo(() => groupPRComments(visibleComments), [visibleComments]) const resolvedReplyingTo = resolveCommentReplyTarget(replyingTo, visibleComments) @@ -6807,6 +6812,7 @@ export default function PullRequestPage({ const body = details?.body ?? '' const comments = details?.comments ?? [] const files = details?.files ?? [] + const filesUnavailable = details?.filesUnavailable ?? false const checks = details?.checks ?? [] const [pendingViewedPaths, setPendingViewedPaths] = useState>(() => new Set()) // Why: clipboard IPC can resolve after the page unmounts; skip copied-state @@ -7319,6 +7325,21 @@ export default function PullRequestPage({
+ ) : filesUnavailable && files.length === 0 ? ( + // Why: the file fetch failed (rate limit, auth, unresolved + // remote); offer a retry instead of implying the PR is empty. +
+
+ {translate( + 'auto.components.PullRequestPage.filesUnavailable', + "Couldn't load changed files." + )} +
+ +
) : files.length === 0 ? (
{translate('auto.components.PullRequestPage.6ad2c1ab9c', 'No files changed.')} diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index c42be0d7bac..99fc42d7111 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -205,6 +205,7 @@ import { type TaskPageRepoSourceState } from '@/components/task-page-cache-selectors' import { shouldHideTaskPageListChrome } from '@/components/task-page-list-chrome-visibility' +import { accumulateWorkItemPages } from '@/components/task-page-work-item-pagination' import LinearIssueAttributeFilterDropdowns from '@/components/linear-issue-attribute-filter-dropdowns' import { resolveLinearIssueAttributeFilterPrimaryTeam } from '@/components/linear-issue-attribute-filter-primary-team' import { @@ -6280,32 +6281,32 @@ export default function TaskPage(): React.JSX.Element { setPaginationLoading(true) setLoadingTargetPage(target) try { - let cursor = oldestItem.updatedAt - let loadedPages = pages.length - const newPages: GitHubWorkItem[][] = [] - - while (loadedPages <= target) { - const { items } = await fetchWorkItemsNextPage( - repoArgs, - PER_REPO_FETCH_LIMIT, - CROSS_REPO_DISPLAY_LIMIT, - q, - cursor - ) - if (paginationGenerationRef.current !== requestGeneration) { - return + const result = await accumulateWorkItemPages({ + existingPages: pages, + initialCursor: oldestItem.updatedAt, + targetPage: target, + // Why: uniform page size keeps deduped pages from shrinking below the + // size `totalPages` (count ÷ effectivePageSize) assumes — otherwise + // the per-page overlap loss would strand the tail items. + pageSize: effectivePageSize, + isCancelled: () => paginationGenerationRef.current !== requestGeneration, + fetchPage: async (cursor) => { + const { items } = await fetchWorkItemsNextPage( + repoArgs, + PER_REPO_FETCH_LIMIT, + CROSS_REPO_DISPLAY_LIMIT, + q, + cursor + ) + return { items } } - if (items.length === 0) { - break - } - newPages.push(items) - cursor = items.at(-1)!.updatedAt - loadedPages += 1 + }) + if (result.cancelled) { + return } - - if (newPages.length > 0) { - setPages((prev) => [...prev, ...newPages]) - setCurrentPage(target < loadedPages ? target : loadedPages - 1) + if (result.newPages.length > 0) { + setPages((prev) => [...prev, ...result.newPages]) + setCurrentPage(target < result.loadedPages ? target : result.loadedPages - 1) } } catch (err) { console.error('Failed to load next page:', err) @@ -6316,7 +6317,14 @@ export default function TaskPage(): React.JSX.Element { } } }, - [paginationLoading, selectedRepos, pages, appliedTaskSearch, fetchWorkItemsNextPage] + [ + paginationLoading, + selectedRepos, + pages, + appliedTaskSearch, + fetchWorkItemsNextPage, + effectivePageSize + ] ) useEffect(() => { @@ -9736,13 +9744,21 @@ export default function TaskPage(): React.JSX.Element { ) : ( - + )} {item.type !== 'pr' ? ( diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index f8ae284a365..efee4e9ec3e 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -1334,9 +1334,9 @@ function Terminal(): React.JSX.Element | null { if (shouldDeferParkedPtyExitTabClose(tabId, ptyId)) { return } - handleCloseTab(tabId) + closeTerminalTab(tabId, { reason: 'pty-exit' }) }, - [consumeSuppressedPtyExit, handleCloseTab] + [consumeSuppressedPtyExit] ) const handleCloseOthers = useCallback( @@ -1364,11 +1364,17 @@ function Terminal(): React.JSX.Element | null { (unifiedTab?.contentType === 'browser' && browserWorkspaceHasRemoteOwner(state, unifiedTab.entityId, runtimeEnvironmentId))) ) { - void closeWebRuntimeSessionTab({ - worktreeId: activeWorktreeId, - tabId: unifiedTab.contentType === 'browser' ? unifiedTab.id : unifiedTab.entityId, - environmentId: runtimeEnvironmentId - }) + if (unifiedTab.contentType === 'terminal') { + // Why: paired-host bulk close must revoke renderer resume and hook + // authority as well as removing the host-owned session tab. + closeTerminalTab(unifiedTab.entityId) + } else { + void closeWebRuntimeSessionTab({ + worktreeId: activeWorktreeId, + tabId: unifiedTab.id, + environmentId: runtimeEnvironmentId + }) + } continue } if ((state.tabsByWorktree[activeWorktreeId] ?? []).some((tab) => tab.id === id)) { @@ -1423,11 +1429,17 @@ function Terminal(): React.JSX.Element | null { (unifiedTab?.contentType === 'browser' && browserWorkspaceHasRemoteOwner(state, unifiedTab.entityId, runtimeEnvironmentId))) ) { - void closeWebRuntimeSessionTab({ - worktreeId: activeWorktreeId, - tabId: unifiedTab.contentType === 'browser' ? unifiedTab.id : unifiedTab.entityId, - environmentId: runtimeEnvironmentId - }) + if (unifiedTab.contentType === 'terminal') { + // Why: route every terminal close through the destructive local + // lifecycle boundary before the paired host RPC. + closeTerminalTab(unifiedTab.entityId) + } else { + void closeWebRuntimeSessionTab({ + worktreeId: activeWorktreeId, + tabId: unifiedTab.id, + environmentId: runtimeEnvironmentId + }) + } continue } if ((state.tabsByWorktree[activeWorktreeId] ?? []).some((tab) => tab.id === id)) { diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 0b18f5f3f09..9b6fcef5b29 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -32,7 +32,7 @@ import { isAutomationGeneratedWorkspace, isDefaultBranchWorkspace } from '@/components/sidebar/visible-worktrees' -import { isInactiveWorkspace } from '@/lib/worktree-activity-state' +import { getLiveAgentStatusByWorktreeId, isInactiveWorkspace } from '@/lib/worktree-activity-state' import { orderEmptyQueryWorktrees } from '@/lib/order-empty-query-worktrees' import StatusIndicator from '@/components/sidebar/StatusIndicator' import { cn } from '@/lib/utils' @@ -104,6 +104,9 @@ import { getCmdJQuickActions, CREATE_WORKSPACE_QUICK_ACTION_ID } from '@/components/cmd-j/quick-actions' +import { buildWorktreeChecksReviewIndex } from '@/components/cmd-j/worktree-checks-review-index' +import { selectWorktreePaletteCacheInputs } from '@/components/cmd-j/worktree-palette-cache-inputs' +import { getRepoHostIdentity } from '@/store/slices/repo-host-identity' import { getComposerEligibleRepos, resolveComposerGitRepoId @@ -379,8 +382,12 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { terminalLayoutsByTabId, tabsByWorktree } = useAppStore(useShallow((s) => selectPaletteStatusInputs(s, visible || statusInputsLingering))) - const prCache = useAppStore((s) => s.prCache) - const issueCache = useAppStore((s) => s.issueCache) + const agentStatusEpoch = useAppStore((s) => + visible || statusInputsLingering ? s.agentStatusEpoch : 0 + ) + const { prCache, issueCache, hostedReviewCache } = useAppStore( + useShallow((s) => selectWorktreePaletteCacheInputs(s, visible || statusInputsLingering)) + ) const migrationUnsupportedByPtyId = useAppStore((s) => s.migrationUnsupportedByPtyId) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const activeTabType = useAppStore((s) => s.activeTabType) @@ -436,6 +443,10 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const preserveCreateLookupOnCloseRef = useRef(false) const repoMap = useMemo(() => new Map(repos.map((r) => [r.id, r])), [repos]) + const repoByHostIdentity = useMemo( + () => new Map(repos.map((repo) => [getRepoHostIdentity(repo), repo])), + [repos] + ) const hostLabelOverrides = useMemo(() => getHostDisplayLabelOverrides(settings), [settings]) // Why: host badges only appear when more than one execution host exists; reuse // the same registry the sidebar host-scope strip builds so labels stay in sync. @@ -465,6 +476,19 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const hasQuery = deferredQuery.trim().length > 0 const isLoading = repos.length > 0 && Object.keys(worktreesByRepo).length === 0 + // Why: keep running-agent workspaces visible under "Hide sleeping" even when + // their live PTY is momentarily absent, matching the sidebar filter. #7197 + const liveAgentActivity = useMemo(() => { + void agentStatusEpoch + const statusByWorktreeId = getLiveAgentStatusByWorktreeId( + agentStatusByPaneKey, + tabsByWorktree, + Date.now() + ) + return { statusByWorktreeId, worktreeIds: new Set(statusByWorktreeId.keys()) } + }, [agentStatusByPaneKey, agentStatusEpoch, tabsByWorktree]) + const worktreeIdsWithLiveAgent = liveAgentActivity.worktreeIds + // Why: the empty-query palette mirrors sidebar filters so opening Search // starts from the same quiet list. Typed search switches to the global // non-archived scope below. @@ -482,7 +506,13 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { } if ( !showSleepingWorkspaces && - isInactiveWorkspace(worktree.id, tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree) + isInactiveWorkspace( + worktree.id, + tabsByWorktree, + ptyIdsByTabId, + browserTabsByWorktree, + worktreeIdsWithLiveAgent + ) ) { return false } @@ -495,7 +525,8 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { hideDefaultBranchWorkspace, ptyIdsByTabId, showSleepingWorkspaces, - tabsByWorktree + tabsByWorktree, + worktreeIdsWithLiveAgent ] ) @@ -599,6 +630,18 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { [browserSortedWorktrees] ) + const checksReviewByWorktree = useMemo( + () => + buildWorktreeChecksReviewIndex({ + worktrees: allWorktrees, + repoByHostIdentity, + prCache, + hostedReviewCache, + settings + }), + [allWorktrees, hostedReviewCache, prCache, repoByHostIdentity, settings] + ) + const worktreeMatches = useMemo( () => searchWorktrees( @@ -607,9 +650,18 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { repoMap, prCache, issueCache, - getWorkspacePortsByWorktreeId(workspacePortScan) + getWorkspacePortsByWorktreeId(workspacePortScan), + checksReviewByWorktree ), - [sortedWorktrees, deferredQuery, repoMap, prCache, issueCache, workspacePortScan] + [ + sortedWorktrees, + deferredQuery, + repoMap, + prCache, + issueCache, + workspacePortScan, + checksReviewByWorktree + ] ) const browserPageEntries = useMemo(() => { @@ -1825,7 +1877,8 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { tabsByWorktree[worktree.id] ?? [], browserTabsByWorktree[worktree.id] ?? [], ptyIdsByTabId, - runtimePaneTitlesByTabId + runtimePaneTitlesByTabId, + { liveAgentStatus: liveAgentActivity.statusByWorktreeId.get(worktree.id) } ) const statusLabel = getWorktreeStatusLabel(status) const isCurrentWorktree = activeWorktreeId === worktree.id @@ -2340,5 +2393,7 @@ function getPaletteSupportingTextLabel( return translate('worktreeJumpPalette.matchLabel.port', 'Port') case 'pr': return translate('worktreeJumpPalette.matchLabel.pr', 'PR') + case 'mr': + return translate('worktreeJumpPalette.matchLabel.mr', 'MR') } } diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index d296f5da471..ccb57a05e80 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -67,6 +67,11 @@ import { parsePaneKey } from '../../../../shared/stable-pane-id' import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' import { translate } from '@/i18n/i18n' +import { + getActivityThreadTaskTitle, + getActivityThreadWorkspaceTitle, + resolveActivityThreadStatusPreview +} from '@/lib/activity-thread-display' import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text' type ThreadReadFilter = 'all' | 'unread' @@ -438,36 +443,28 @@ function agentMeta(event: ActivityEvent): string { return event.state === 'waiting' ? `${agent} waiting` : `${agent} blocked` } -// Why (label hierarchy): mirror DashboardAgentRow — the agent's last prompt -// IS what the agent is working on and is the primary signal users want at a -// glance. A user-renamed customTitle still wins (explicit rename intent), but -// the OSC-set live title ("Claude Code", "Codex", …) must NOT shadow the -// prompt: agent CLIs set that title eagerly, so preferring it would pin every -// row to the agent name and hide the actual turn. Fall back to a non-default -// liveTitle only when there is no prompt at all. -function paneTitleForEntry(entry: AgentStatusEntry, tab: TerminalTab): string { - const customTitle = tab.customTitle?.trim() - if (customTitle) { - return customTitle - } - const prompt = getAgentRowPrimaryText(entry) - if (prompt) { - return prompt - } - const liveTitle = tab.title?.trim() - const defaultTitle = tab.defaultTitle?.trim() - if (liveTitle && liveTitle !== defaultTitle) { - return liveTitle - } - return defaultTitle || liveTitle || 'Terminal' +// Why (label hierarchy): Activity rows need a stable task identity across +// follow-up turns. The live hook prompt tracks the current turn ("yes", +// "ok proceed") and must not replace the task title when scanning many agents +// across worktrees. +function paneTitleForEntry( + entry: AgentStatusEntry, + tab: TerminalTab, + generatedTitlesEnabled: boolean +): string { + return getActivityThreadTaskTitle({ entry, tab, generatedTitlesEnabled }) } -function paneTitleForEvent(event: ActivityEvent): string { - return paneTitleForEntry(event.entry, event.tab) +function paneTitleForEvent(event: ActivityEvent, generatedTitlesEnabled: boolean): string { + return paneTitleForEntry(event.entry, event.tab, generatedTitlesEnabled) } -function responsePreviewForEntry(entry: AgentStatusEntry): string { - return entry.lastAssistantMessage?.trim() ?? '' +function statusPreviewForEntry( + entry: AgentStatusEntry, + agentState?: AgentStatusState | null, + previousPreview?: string +): string { + return resolveActivityThreadStatusPreview(entry, agentState, previousPreview) } function isActivityEventState(state: AgentStatusState): state is ActivityEventState { @@ -772,7 +769,9 @@ export function buildActivityEvents(args: { export function buildAgentPaneThreads(args: { events: ActivityEvent[] liveAgentByPaneKey: Record + generatedTitlesEnabled?: boolean }): AgentPaneThread[] { + const generatedTitlesEnabled = args.generatedTitlesEnabled === true const byPaneKey = new Map() for (const event of args.events) { const paneKey = event.entry.paneKey @@ -780,14 +779,14 @@ export function buildAgentPaneThreads(args: { if (!existing) { byPaneKey.set(paneKey, { paneKey, - paneTitle: paneTitleForEvent(event), + paneTitle: paneTitleForEvent(event, generatedTitlesEnabled), worktree: event.worktree, repo: event.repo, tab: event.tab, agentType: event.agentType, currentAgentState: null, currentAgentEntry: null, - responsePreview: responsePreviewForEntry(event.entry), + responsePreview: statusPreviewForEntry(event.entry, event.state), latestTimestamp: event.timestamp, latestEvent: event, events: [event], @@ -802,10 +801,14 @@ export function buildAgentPaneThreads(args: { existing.migrationUnsupportedPtyId ?? event.migrationUnsupportedPtyId if (!existing.latestEvent || event.timestamp > existing.latestEvent.timestamp) { existing.latestEvent = event - existing.paneTitle = paneTitleForEvent(event) + existing.paneTitle = paneTitleForEvent(event, generatedTitlesEnabled) existing.agentType = event.agentType existing.tab = event.tab - existing.responsePreview = responsePreviewForEntry(event.entry) + existing.responsePreview = statusPreviewForEntry( + event.entry, + event.state, + existing.responsePreview + ) existing.latestTimestamp = event.timestamp } } @@ -815,14 +818,14 @@ export function buildAgentPaneThreads(args: { if (!existing) { byPaneKey.set(paneKey, { paneKey, - paneTitle: paneTitleForEntry(liveAgent.entry, liveAgent.tab), + paneTitle: paneTitleForEntry(liveAgent.entry, liveAgent.tab, generatedTitlesEnabled), worktree: liveAgent.worktree, repo: liveAgent.repo, tab: liveAgent.tab, agentType: liveAgent.agentType, currentAgentState: liveAgent.state, currentAgentEntry: liveAgent.entry, - responsePreview: responsePreviewForEntry(liveAgent.entry), + responsePreview: statusPreviewForEntry(liveAgent.entry, liveAgent.state), latestTimestamp: liveAgent.timestamp, latestEvent: null, events: [], @@ -833,14 +836,18 @@ export function buildAgentPaneThreads(args: { // Why: live metadata is the current thread identity. Historical events stay // in the event list, but the row title/time/target must follow the active // turn so a running agent never shows the previous prompt as primary. - existing.paneTitle = paneTitleForEntry(liveAgent.entry, liveAgent.tab) + existing.paneTitle = paneTitleForEntry(liveAgent.entry, liveAgent.tab, generatedTitlesEnabled) existing.worktree = liveAgent.worktree existing.repo = liveAgent.repo existing.tab = liveAgent.tab existing.agentType = liveAgent.agentType existing.currentAgentState = liveAgent.state existing.currentAgentEntry = liveAgent.entry - existing.responsePreview = responsePreviewForEntry(liveAgent.entry) + existing.responsePreview = statusPreviewForEntry( + liveAgent.entry, + liveAgent.state, + existing.responsePreview + ) existing.latestTimestamp = liveAgent.timestamp } @@ -928,6 +935,23 @@ export function ActivityThreadOptionsMenu({ ) } +function ActivityProjectLabel({ repo }: { repo: Repo | null }): React.JSX.Element { + const label = + repo?.displayName?.trim() || + translate('auto.components.activity.ActivityPrototypePage.5651b216c6', 'Unknown project') + return ( +
+ {repo ? : null} + + {label} + +
+ ) +} + function EventRepoBadge({ repo }: { repo: Repo | null }): React.JSX.Element | null { if (!repo) { return null @@ -1054,7 +1078,7 @@ function threadSearchText(thread: AgentPaneThread): string { const latestEventText = latest ? `${agentTitle(latest)} ${agentSummary(latest)} ${agentMeta(latest)}` : '' - return `${thread.paneTitle} ${thread.worktree.displayName} ${thread.repo?.displayName ?? ''} ${formatAgentTypeLabel(thread.agentType)} ${stateLabel} ${currentPrompt} ${rawCurrentPrompt} ${currentSummary} ${thread.responsePreview} ${latestEventText}`.toLowerCase() + return `${thread.paneTitle} ${getActivityThreadWorkspaceTitle(thread.worktree)} ${thread.worktree.branch ?? ''} ${thread.repo?.displayName ?? ''} ${formatAgentTypeLabel(thread.agentType)} ${stateLabel} ${currentPrompt} ${rawCurrentPrompt} ${currentSummary} ${thread.responsePreview} ${latestEventText}`.toLowerCase() } export const ACTIVITY_SEARCH_QUERY_MAX_BYTES = 2 * 1024 @@ -1219,6 +1243,14 @@ function ThreadRow({ const renderedResponsePreview = activityThreadResponseRenderPreview({ responsePreview: thread.responsePreview }) + const workspaceTitle = getActivityThreadWorkspaceTitle(thread.worktree) + const taskTitle = thread.paneTitle + const agentLabel = formatAgentTypeLabel(thread.agentType) + const showStatusPreview = + !compactMode && + renderedResponsePreview.length > 0 && + renderedResponsePreview !== taskTitle && + renderedResponsePreview !== workspaceTitle return (
) : null} - {/* Why (right cluster aligned to title, not centered between rows): - parking the timestamp on the title row leaves the secondary row - full-width for the repo badge + branch name, which used to get - truncated when the right cluster ate horizontal space. */}
@@ -1271,128 +1299,126 @@ function ThreadRow({
- - {thread.paneTitle} - - {!compactMode && renderedResponsePreview ? ( - - ) : null} -
- - {/* Why (bell matches WorktreeCard pattern): unread → amber filled - bell as a static, non-interactive cue (selecting the thread - auto-marks it read, so a Mark-read button would be redundant); - read → outline Bell that fades in on row hover and acts as - Mark-unread. Bare button (no shadcn outline) so it reads as - an inline cue rather than a discrete control square. */} - - {thread.unread ? ( - +
+ +
- ) : ( - - -
+ {taskTitle !== workspaceTitle ? ( +
+ {taskTitle} +
+ ) : null} + {showStatusPreview ? ( + + ) : null} +
+ {agentLabel} + {canJump ? ( + - - - - - {translate( - 'auto.components.activity.ActivityPrototypePage.59b131fbd9', - 'Mark thread unread' - )} - - - )} - - - -
-
- - - {thread.worktree.displayName} - - {/* Why (Jump-to-workspace lives on the secondary row): the bell slot - on the title row already holds the unread/Mark-unread state, so - the navigation action gets its own slot down here aligned with - the worktree name. On hover-capable pointers, the hidden state - keeps the worktree-name's flex-1 width stable across hover. */} - {canJump ? ( - - - - - - - {translate( - 'auto.components.activity.ActivityPrototypePage.4616ea39fd', - 'Jump to workspace' + + + + + + {translate( + 'auto.components.activity.ActivityPrototypePage.4616ea39fd', + 'Jump to workspace' + )} + + + + ) : null} +
+
+ + + {thread.unread ? ( + + ) : ( + + + + + + {translate( + 'auto.components.activity.ActivityPrototypePage.59b131fbd9', + 'Mark thread unread' + )} + + )} - - - - ) : null} + + +
+
+
) @@ -1439,7 +1465,8 @@ export default function ActivityPrototypePage(): React.JSX.Element { repoMap: getRepoMapFromState(s), acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey, acknowledgeAgents: s.acknowledgeAgents, - unacknowledgeAgents: s.unacknowledgeAgents + unacknowledgeAgents: s.unacknowledgeAgents, + generatedTitlesEnabled: s.settings?.tabAutoGenerateTitle === true })) ) // Why: agentStatusEpoch is included in the dependency array (but not in the @@ -1468,8 +1495,13 @@ export default function ActivityPrototypePage(): React.JSX.Element { ) const allThreads = useMemo( - () => buildAgentPaneThreads({ events: allEvents, liveAgentByPaneKey }), - [allEvents, liveAgentByPaneKey] + () => + buildAgentPaneThreads({ + events: allEvents, + liveAgentByPaneKey, + generatedTitlesEnabled: storeData.generatedTitlesEnabled + }), + [allEvents, liveAgentByPaneKey, storeData.generatedTitlesEnabled] ) const selectedPaneKeyIsLive = selectedPaneKey === null || allThreads.some((thread) => thread.paneKey === selectedPaneKey) diff --git a/src/renderer/src/components/agent/AgentCombobox.test.tsx b/src/renderer/src/components/agent/AgentCombobox.test.tsx index 6547c3704bd..d6fe7590f49 100644 --- a/src/renderer/src/components/agent/AgentCombobox.test.tsx +++ b/src/renderer/src/components/agent/AgentCombobox.test.tsx @@ -1,6 +1,8 @@ import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it, vi } from 'vitest' +import type { TuiAgent } from '../../../../shared/types' import { AGENT_CATALOG, AgentIcon } from '@/lib/agent-catalog' +import { AGENT_FAVICON_ASSETS } from '@/lib/agent-favicon-assets' import AgentCombobox from './AgentCombobox' describe('AgentCombobox', () => { @@ -37,4 +39,14 @@ describe('AgentCombobox', () => { expect(markup).not.toContain('https://www.google.com/s2/favicons') expect(markup).not.toContain(' { + // Why: previously loaded from Google's favicon service (#8451). Iterate the + // full asset map so missing files/key mismatches fail the test. + for (const agent of Object.keys(AGENT_FAVICON_ASSETS) as TuiAgent[]) { + const markup = renderToStaticMarkup() + expect(markup).toContain(`/shared/agent-icons/${agent}.png`) + expect(markup).not.toContain('https://www.google.com/s2/favicons') + } + }) }) diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index 322f2170f4d..98a78ef9845 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -18,6 +18,7 @@ import { toast } from 'sonner' import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' import type { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' import { ContextMenu, ContextMenuContent, @@ -1047,7 +1048,7 @@ export default function AutomationsPage(): React.JSX.Element { }, [automationHostTargetKey, isLoading, pendingAutomationRunNavigation, refresh]) const hydratePersistedUIState = useCallback(async (): Promise => { - useAppStore.getState().hydratePersistedUI(await window.api.ui.get()) + useAppStore.getState().hydratePersistedUI(await window.api.ui.get(), 'sync') }, []) useEffect(() => { @@ -1056,8 +1057,11 @@ export default function AutomationsPage(): React.JSX.Element { }, [fetchAllWorktrees, refresh]) useEffect(() => { - const timer = window.setInterval(() => setRelativeNow(Date.now()), 60 * 1000) - return () => window.clearInterval(timer) + // Pause the relative-time clock while the window is hidden. + return installWindowVisibilityInterval({ + run: () => setRelativeNow(Date.now()), + intervalMs: 60 * 1000 + }) }, []) useEffect(() => { diff --git a/src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts b/src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts new file mode 100644 index 00000000000..78889b1d2db --- /dev/null +++ b/src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'vitest' +import type { HostedReviewInfo } from '../../../../shared/hosted-review' +import type { PRInfo, Repo, Worktree } from '../../../../shared/types' +import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key' +import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity' +import { getRepoHostIdentity } from '@/store/slices/repo-host-identity' +import { buildWorktreeChecksReviewIndex } from './worktree-checks-review-index' + +const repo: Repo = { + id: 'repo-1', + path: '/remote/orca', + displayName: 'orca', + badgeColor: '#000000', + addedAt: 0, + executionHostId: 'ssh:staging' +} + +const worktree: Worktree = { + id: 'worktree-1', + repoId: repo.id, + path: '/remote/orca-worktrees/search', + head: 'abc123', + branch: 'refs/heads/feature/search', + isBare: false, + isMainWorktree: false, + displayName: 'Search reviews', + comment: '', + linkedIssue: null, + linkedPR: 42, + linkedLinearIssue: null, + hostId: 'ssh:staging', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 +} + +function makePR(): PRInfo { + return { + number: 42, + title: 'Search worktrees by their pull requests', + state: 'open', + url: 'https://github.com/acme/orca/pull/42', + checksStatus: 'success', + updatedAt: '2026-07-12T00:00:00Z', + mergeable: 'MERGEABLE' + } +} + +function makeGitLabReview(): HostedReviewInfo { + return { + provider: 'gitlab', + number: 17, + title: 'Search worktrees by merge request', + state: 'open', + url: 'https://gitlab.com/acme/orca/-/merge_requests/17', + status: 'pending', + updatedAt: '2026-07-12T00:00:00Z', + mergeable: 'UNKNOWN' + } +} + +describe('buildWorktreeChecksReviewIndex', () => { + it('reads the same host-scoped GitHub PR cache entry as Checks', () => { + const key = getGitHubPRCacheKey( + repo.path, + repo.id, + 'feature/search', + null, + repo.connectionId, + repo.executionHostId, + true + ) + + const reviews = buildWorktreeChecksReviewIndex({ + worktrees: [worktree], + repoByHostIdentity: new Map([[getRepoHostIdentity(repo), repo]]), + prCache: { [key]: { data: makePR(), fetchedAt: 1 } }, + hostedReviewCache: {}, + settings: null + }) + + expect(reviews.get(worktree)).toMatchObject({ + provider: 'github', + number: 42, + title: 'Search worktrees by their pull requests' + }) + }) + + it('uses the GitLab review selected by Checks instead of stale GitHub metadata', () => { + const gitLabWorktree = { ...worktree, linkedGitLabMR: 17 } + const prKey = getGitHubPRCacheKey( + repo.path, + repo.id, + 'feature/search', + null, + repo.connectionId, + repo.executionHostId, + true + ) + const reviewKey = getHostedReviewCacheKey( + repo.path, + 'feature/search', + null, + repo.id, + repo.connectionId, + repo.executionHostId, + true + ) + const gitLabReview = makeGitLabReview() + + const reviews = buildWorktreeChecksReviewIndex({ + worktrees: [gitLabWorktree], + repoByHostIdentity: new Map([[getRepoHostIdentity(repo), repo]]), + prCache: { [prKey]: { data: makePR(), fetchedAt: 1 } }, + hostedReviewCache: { [reviewKey]: { data: gitLabReview, fetchedAt: 1 } }, + settings: null + }) + + expect(reviews.get(gitLabWorktree)).toBe(gitLabReview) + }) + + it('records when a non-GitHub link suppresses stale GitHub metadata before its review loads', () => { + const gitLabWorktree = { ...worktree, linkedGitLabMR: 17 } + const prKey = getGitHubPRCacheKey( + repo.path, + repo.id, + 'feature/search', + null, + repo.connectionId, + repo.executionHostId, + true + ) + + const reviews = buildWorktreeChecksReviewIndex({ + worktrees: [gitLabWorktree], + repoByHostIdentity: new Map([[getRepoHostIdentity(repo), repo]]), + prCache: { [prKey]: { data: makePR(), fetchedAt: 1 } }, + hostedReviewCache: {}, + settings: null + }) + + expect(reviews.has(gitLabWorktree)).toBe(true) + expect(reviews.get(gitLabWorktree)).toBeNull() + }) + + it('keeps same-id worktrees isolated across execution hosts', () => { + const localRepo: Repo = { + ...repo, + path: '/local/orca', + executionHostId: 'local' + } + const localWorktree: Worktree = { ...worktree, hostId: 'local' } + const sshWorktree: Worktree = { ...worktree, hostId: 'ssh:staging' } + const sshKey = getGitHubPRCacheKey( + repo.path, + repo.id, + 'feature/search', + null, + repo.connectionId, + repo.executionHostId, + true + ) + + const reviews = buildWorktreeChecksReviewIndex({ + worktrees: [localWorktree, sshWorktree], + repoByHostIdentity: new Map([ + [getRepoHostIdentity(localRepo), localRepo], + [getRepoHostIdentity(repo), repo] + ]), + prCache: { [sshKey]: { data: makePR(), fetchedAt: 1 } }, + hostedReviewCache: {}, + settings: null + }) + + expect(reviews.has(localWorktree)).toBe(false) + expect(reviews.get(sshWorktree)).toMatchObject({ provider: 'github', number: 42 }) + }) +}) diff --git a/src/renderer/src/components/cmd-j/worktree-checks-review-index.ts b/src/renderer/src/components/cmd-j/worktree-checks-review-index.ts new file mode 100644 index 00000000000..507eee5543d --- /dev/null +++ b/src/renderer/src/components/cmd-j/worktree-checks-review-index.ts @@ -0,0 +1,84 @@ +import { branchName } from '@/lib/git-utils' +import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key' +import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity' +import { getRepoHostIdentityForParts } from '@/store/slices/repo-host-identity' +import type { AppState } from '@/store/types' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import type { HostedReviewInfo } from '../../../../shared/hosted-review' +import type { Repo, Worktree } from '../../../../shared/types' +import { selectChecksPanelReview } from '../right-sidebar/checks-panel-review' + +type WorktreeChecksReviewIndexArgs = { + worktrees: readonly Worktree[] + repoByHostIdentity: ReadonlyMap + prCache: AppState['prCache'] | null + hostedReviewCache: AppState['hostedReviewCache'] | null + settings: AppState['settings'] +} + +export function buildWorktreeChecksReviewIndex({ + worktrees, + repoByHostIdentity, + prCache, + hostedReviewCache, + settings +}: WorktreeChecksReviewIndexArgs): Map { + const reviews = new Map() + if (!prCache || !hostedReviewCache) { + return reviews + } + + for (const worktree of worktrees) { + const repo = repoByHostIdentity.get( + getRepoHostIdentityForParts(worktree.repoId, worktree.hostId ?? LOCAL_EXECUTION_HOST_ID) + ) + if (!repo) { + continue + } + const branch = branchName(worktree.branch) + const prKey = getGitHubPRCacheKey( + repo.path, + repo.id, + branch, + settings, + repo.connectionId, + repo.executionHostId, + true + ) + const hostedReviewKey = getHostedReviewCacheKey( + repo.path, + branch, + settings, + repo.id, + repo.connectionId, + repo.executionHostId, + true + ) + // Why: Cmd+J should expose exactly the review metadata Checks has already + // resolved, without starting another provider lookup from the search path. + const review = selectChecksPanelReview({ + hostedReview: hostedReviewCache[hostedReviewKey]?.data, + pr: prCache[prKey]?.data, + linkedGitLabMR: worktree.linkedGitLabMR ?? null, + linkedBitbucketPR: worktree.linkedBitbucketPR ?? null, + linkedAzureDevOpsPR: worktree.linkedAzureDevOpsPR ?? null, + linkedGiteaPR: worktree.linkedGiteaPR ?? null + }) + if (review) { + // Why: persisted IDs can be identical across execution hosts; the search + // scope preserves these object references while sorting and filtering. + reviews.set(worktree, review) + } else if ( + worktree.linkedGitLabMR != null || + worktree.linkedBitbucketPR != null || + worktree.linkedAzureDevOpsPR != null || + worktree.linkedGiteaPR != null + ) { + // Why: an empty Checks selection for a non-GitHub link is authoritative; + // omitting it would let Cmd+J surface stale GitHub metadata as a fallback. + reviews.set(worktree, null) + } + } + + return reviews +} diff --git a/src/renderer/src/components/cmd-j/worktree-palette-cache-inputs.test.ts b/src/renderer/src/components/cmd-j/worktree-palette-cache-inputs.test.ts new file mode 100644 index 00000000000..2e9254e86bf --- /dev/null +++ b/src/renderer/src/components/cmd-j/worktree-palette-cache-inputs.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import type { AppState } from '@/store/types' +import { + EMPTY_WORKTREE_PALETTE_CACHE_INPUTS, + selectWorktreePaletteCacheInputs +} from './worktree-palette-cache-inputs' + +describe('selectWorktreePaletteCacheInputs', () => { + it('stays referentially stable across review cache writes while the palette is closed', () => { + const emptyState: Pick = { + prCache: {}, + issueCache: {}, + hostedReviewCache: {} + } + const filledState: Pick = { + prCache: { pr: { data: null, fetchedAt: 1 } }, + issueCache: { issue: { data: null, fetchedAt: 1 } }, + hostedReviewCache: { review: { data: null, fetchedAt: 1 } } + } + + const before = selectWorktreePaletteCacheInputs(emptyState, false) + const after = selectWorktreePaletteCacheInputs(filledState, false) + + expect(before).toBe(EMPTY_WORKTREE_PALETTE_CACHE_INPUTS) + expect(after).toBe(before) + }) + + it('returns live cache identities while the palette is visible', () => { + const state = { + prCache: {}, + issueCache: {}, + hostedReviewCache: {} + } as Pick + + const selected = selectWorktreePaletteCacheInputs(state, true) + + expect(selected).toEqual(state) + expect(selected.prCache).toBe(state.prCache) + expect(selected.issueCache).toBe(state.issueCache) + expect(selected.hostedReviewCache).toBe(state.hostedReviewCache) + }) +}) diff --git a/src/renderer/src/components/cmd-j/worktree-palette-cache-inputs.ts b/src/renderer/src/components/cmd-j/worktree-palette-cache-inputs.ts new file mode 100644 index 00000000000..1a1c234f12d --- /dev/null +++ b/src/renderer/src/components/cmd-j/worktree-palette-cache-inputs.ts @@ -0,0 +1,29 @@ +import type { AppState } from '@/store/types' + +export type WorktreePaletteCacheInputs = { + prCache: AppState['prCache'] | null + issueCache: AppState['issueCache'] | null + hostedReviewCache: AppState['hostedReviewCache'] | null +} + +export const EMPTY_WORKTREE_PALETTE_CACHE_INPUTS: WorktreePaletteCacheInputs = Object.freeze({ + prCache: null, + issueCache: null, + hostedReviewCache: null +}) + +export function selectWorktreePaletteCacheInputs( + state: Pick, + active: boolean +): WorktreePaletteCacheInputs { + // Why: the palette stays mounted while closed; cache replacement from Checks + // must not rerender it when no search results are visible. + if (!active) { + return EMPTY_WORKTREE_PALETTE_CACHE_INPUTS + } + return { + prCache: state.prCache, + issueCache: state.issueCache, + hostedReviewCache: state.hostedReviewCache + } +} diff --git a/src/renderer/src/components/editor/DiffViewer.tsx b/src/renderer/src/components/editor/DiffViewer.tsx index c119abee471..dbeb8fca2bf 100644 --- a/src/renderer/src/components/editor/DiffViewer.tsx +++ b/src/renderer/src/components/editor/DiffViewer.tsx @@ -24,6 +24,7 @@ import { useDiffViewerLargeDiffLifecycle } from './useDiffViewerLargeDiffLifecyc import { getDiffViewerLargeDiffSaveAction } from './diff-viewer-large-diff-save-action' import type { DiffViewerProps } from './diff-viewer-props' import { buildDiffEditorWordWrapOptions } from './diff-editor-word-wrap-options' +import { useDiffEditorRegistration } from './diff-navigation-context' export default function DiffViewer({ modelKey, @@ -72,6 +73,7 @@ export default function DiffViewer({ (settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) const diffEditorRef = useRef(null) + const { registerDiffEditor, unregisterDiffEditor } = useDiffEditorRegistration() const diffBodyRef = useRef(null) const lineNumberOptionsSubRef = useRef<{ dispose: () => void } | null>(null) const [modifiedEditor, setModifiedEditor] = useState(null) @@ -244,10 +246,16 @@ export default function DiffViewer({ // must not keep comment decorators or save handlers talking to disposed UI. lineNumberOptionsSubRef.current?.dispose() lineNumberOptionsSubRef.current = null + // Why: capture before nulling so we unregister the exact instance the + // navigator may still hold (identity guard no-ops a stale dispose). + const fallenBackEditor = diffEditorRef.current diffEditorRef.current = null + if (fallenBackEditor) { + unregisterDiffEditor(fallenBackEditor) + } setModifiedEditor(null) setPopover(null) - }, []) + }, [unregisterDiffEditor]) const handleSubmitComment = async (body: string): Promise => { if (!popover) { @@ -307,6 +315,7 @@ export default function DiffViewer({ const handleMount: DiffOnMount = useCallback( (diffEditor, monaco) => { diffEditorRef.current = diffEditor + registerDiffEditor(diffEditor) lineNumberOptionsSubRef.current?.dispose() lineNumberOptionsSubRef.current = applyDiffEditorLineNumberOptions(diffEditor, sideBySide) @@ -363,11 +372,12 @@ export default function DiffViewer({ lineNumberOptionsSubRef.current?.dispose() lineNumberOptionsSubRef.current = null diffEditorRef.current = null + unregisterDiffEditor(diffEditor) setModifiedEditor(null) setPopover(null) }) }, - [editable, setupCopy, modelKey, filePath, sideBySide] + [editable, setupCopy, modelKey, filePath, sideBySide, registerDiffEditor, unregisterDiffEditor] ) // Why: VS Code snapshots diff view state on deactivation, not on scroll events. diff --git a/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx b/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx new file mode 100644 index 00000000000..3251b43ac1a --- /dev/null +++ b/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx @@ -0,0 +1,188 @@ +// @vitest-environment happy-dom +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { OpenFile } from '@/store/slices/editor' + +const lifecycle = vi.hoisted(() => ({ + events: [] as string[], + models: new Map(), + mountedProps: [] as { filePath: string; readOnly?: boolean; liveTail?: boolean }[] +})) + +vi.mock('@/lib/lazy-with-retry', async () => { + const React = await import('react') + const { syncContentOnMount } = await import('./monaco-content-sync') + return { + lazyWithRetry: (factory: () => Promise) => { + if (!factory.toString().includes('/MonacoEditor.tsx')) { + return () => null + } + return function MockRetainedMonaco(props: { + filePath: string + content: string + readOnly?: boolean + liveTail?: boolean + }) { + /* oxlint-disable react-hooks/exhaustive-deps -- Mount-only by design: a prop-effect would hide a missing outer React remount. */ + React.useEffect(() => { + lifecycle.events.push(`mount:${props.filePath}`) + lifecycle.mountedProps.push({ + filePath: props.filePath, + readOnly: props.readOnly, + liveTail: props.liveTail + }) + const retained = lifecycle.models.get(props.filePath) ?? { content: '', undo: [] } + const model = { + getValue: () => retained.content, + getEOL: () => '\n', + getFullModelRange: () => ({ + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: retained.content.length + 1 + }), + pushEditOperations: (_selections: unknown[], operations: { text: string }[]) => { + retained.content = operations[0]?.text ?? retained.content + } + } + // Why: exercising the real mount reconciler makes outer key ordering + // observable without replacing Monaco's retained-model semantics. + syncContentOnMount( + { + getModel: () => model, + pushUndoStop: () => { + retained.undo.push('unexpected undo stop') + return true + } + } as never, + props.content + ) + lifecycle.models.set(props.filePath, retained) + return () => { + lifecycle.events.push(`unmount:${props.filePath}`) + } + }, []) + /* oxlint-enable react-hooks/exhaustive-deps */ + return null + } + } + } +}) +vi.mock('@/store', () => ({ + useAppStore: Object.assign( + (selector: (state: Record) => unknown) => + selector({ + worktreesByRepo: {}, + openFile: vi.fn(), + openMarkdownPreview: vi.fn(), + openConflictReviewFile: vi.fn(), + openConflictReview: vi.fn(), + closeFile: vi.fn(), + setRightSidebarTab: vi.fn(), + setPendingEditorReveal: vi.fn(), + reloadOpenCheckRunDetailsTab: vi.fn() + }), + { + getState: () => ({ + folderWorkspaces: [], + projectGroups: [], + repos: [], + settings: {}, + worktreesByRepo: {}, + openFile: vi.fn(), + openMarkdownPreview: vi.fn(), + openConflictReviewFile: vi.fn(), + openConflictReview: vi.fn(), + closeFile: vi.fn(), + setRightSidebarTab: vi.fn(), + setPendingEditorReveal: vi.fn(), + reloadOpenCheckRunDetailsTab: vi.fn() + }) + } + ) +})) + +import { EditorContent } from './EditorContent' + +function file(filePath: string, overrides: Partial = {}): OpenFile { + return { + id: filePath, + filePath, + relativePath: filePath.split('/').at(-1) ?? filePath, + worktreeId: 'repo::/repo', + language: 'typescript', + isDirty: false, + mode: 'edit', + ...overrides + } +} + +function props(activeFile: OpenFile, content: string) { + return { + activeFile, + viewStateScopeId: 'same-pane', + fileContents: { [activeFile.id]: { content, isBinary: false } }, + diffContents: {}, + editBuffers: {}, + openFiles: [activeFile], + worktreeEntries: [], + resolvedLanguage: 'typescript', + isMarkdown: false, + isMermaid: false, + isCsv: false, + isNotebook: false, + mdViewMode: 'rich' as const, + isChangesMode: false, + sideBySide: false, + pendingEditorReveal: null, + handleContentChange: vi.fn(), + handleContentChangeForFile: vi.fn(), + handleDirtyStateHint: vi.fn(), + handleSave: vi.fn(), + handleSaveForFile: vi.fn(), + reloadContent: vi.fn() + } +} + +afterEach(() => { + cleanup() + lifecycle.events.length = 0 + lifecycle.models.clear() + lifecycle.mountedProps.length = 0 +}) + +describe('EditorContent Monaco lifecycle boundary', () => { + it('unmounts the prior path before reconciling a stale retained target model', () => { + const first = file('/repo/first.ts') + const second = file('/repo/second.ts') + lifecycle.models.set(first.filePath, { content: 'first with edits', undo: ['first undo'] }) + lifecycle.models.set(second.filePath, { content: 'stale second', undo: ['second undo'] }) + const view = render() + + view.rerender() + + expect(lifecycle.events).toEqual([ + 'mount:/repo/first.ts', + 'unmount:/repo/first.ts', + 'mount:/repo/second.ts' + ]) + expect(lifecycle.models.get(second.filePath)).toEqual({ + content: 'fresh second', + undo: ['second undo'] + }) + expect(lifecycle.models.get(first.filePath)).toEqual({ + content: 'first with edits', + undo: ['first undo'] + }) + }) + + it('passes live-tail ownership only for a read-only live log', () => { + const liveLog = file('/repo/session.jsonl', { readOnly: true, liveTail: true }) + + render() + + expect(lifecycle.mountedProps).toEqual([ + { filePath: liveLog.filePath, readOnly: true, liveTail: true } + ]) + }) +}) diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index abab01bf142..023c0b9fcae 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -313,10 +313,10 @@ export function EditorContent({ // Why: Without a key, React reuses the same MonacoEditor instance when // switching tabs or split panes, just updating props. That means // useLayoutEffect cleanup (which snapshots scroll position) never fires. - // Keying on the visible pane identity forces unmount/remount so each split - // tab keeps its own viewport state even when the underlying file is shared. + // Keying on the visible pane and path forces remount before a retained target + // model mounts, so the old path cannot receive the new file's reconciliation. void handleCopyPath()} - onOpenDiffTargetFile={handleOpenDiffTargetFile} - onOpenPreviewToSide={handleOpenPreviewToSide} - onOpenMarkdownPreview={handleOpenMarkdownPreview} - onOpenContainingFolder={handleOpenContainingFolder} - onToggleSideBySide={() => setSideBySide((prev) => !prev)} - onEditorToggleChange={handleEditorToggleChange} - onToggleMarkdownTableOfContents={() => - setMarkdownTableOfContentsVisible( - markdownDocumentStateFileId, - !isMarkdownTableOfContentsVisible - ) - } - onToggleMarkdownFrontmatter={() => - setMarkdownFrontmatterVisible(markdownDocumentStateFileId, !isMarkdownFrontmatterVisible) - } - onExportMarkdownToPdf={() => - void exportActiveMarkdownToPdf({ fileId: activeFile.id, root: panelRef.current }) - } - onContentChange={handleContentChange} - onContentChangeForFile={handleContentChangeForFile} - onDirtyStateHint={handleDirtyStateHint} - onSave={handleSave} - onSaveForFile={handleSaveForFile} - onReloadContent={reloadContent} - onCloseMarkdownTableOfContents={() => - setMarkdownTableOfContentsVisible(markdownDocumentStateFileId, false) - } - onCloseRenameDialog={closeRenameDialog} - onRenameConfirm={handleRenameConfirm} - markdownAnnotationsEnabled={markdownAnnotationsEnabled} - /> + // Why: each split pane needs an isolated bridge between its diff editor and header controls. + + void handleCopyPath()} + onOpenDiffTargetFile={handleOpenDiffTargetFile} + onOpenPreviewToSide={handleOpenPreviewToSide} + onOpenMarkdownPreview={handleOpenMarkdownPreview} + onOpenContainingFolder={handleOpenContainingFolder} + onToggleSideBySide={() => setSideBySide((prev) => !prev)} + onEditorToggleChange={handleEditorToggleChange} + onToggleMarkdownTableOfContents={() => + setMarkdownTableOfContentsVisible( + markdownDocumentStateFileId, + !isMarkdownTableOfContentsVisible + ) + } + onToggleMarkdownFrontmatter={() => + setMarkdownFrontmatterVisible(markdownDocumentStateFileId, !isMarkdownFrontmatterVisible) + } + onExportMarkdownToPdf={() => + void exportActiveMarkdownToPdf({ fileId: activeFile.id, root: panelRef.current }) + } + onContentChange={handleContentChange} + onContentChangeForFile={handleContentChangeForFile} + onDirtyStateHint={handleDirtyStateHint} + onSave={handleSave} + onSaveForFile={handleSaveForFile} + onReloadContent={reloadContent} + onCloseMarkdownTableOfContents={() => + setMarkdownTableOfContentsVisible(markdownDocumentStateFileId, false) + } + onCloseRenameDialog={closeRenameDialog} + onRenameConfirm={handleRenameConfirm} + markdownAnnotationsEnabled={markdownAnnotationsEnabled} + /> + ) } diff --git a/src/renderer/src/components/editor/EditorPanelHeader.test.tsx b/src/renderer/src/components/editor/EditorPanelHeader.test.tsx new file mode 100644 index 00000000000..0a03d312fdd --- /dev/null +++ b/src/renderer/src/components/editor/EditorPanelHeader.test.tsx @@ -0,0 +1,106 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import type { OpenFile } from '@/store/slices/editor' +import { EditorPanelHeader } from './EditorPanelHeader' + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ + activeGroupIdByWorktree: {}, + settings: {}, + updateSettings: vi.fn() + }) +})) + +vi.mock('@/store/worktree-diff-comments-selector', () => ({ + selectWorktreeDiffCommentsOrEmpty: () => [] +})) + +vi.mock('@/components/ui/tooltip', () => ({ + TooltipProvider: ({ + children, + delayDuration + }: { + children: React.ReactNode + delayDuration: number + }) => ( +
+ {children} +
+ ), + Tooltip: ({ children }: { children: React.ReactNode }) => {children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => {children} +})) + +vi.mock('./EditorPanelHeaderPath', () => ({ + EditorPanelHeaderPath: () => null +})) + +vi.mock('./EditorPanelMarkdownActionsMenu', () => ({ + EditorPanelMarkdownActionsMenu: () => null +})) + +vi.mock('./diff-navigation-context', () => ({ + useDiffNavigation: () => ({ + changeCount: 2, + goToPreviousDiff: vi.fn(), + goToNextDiff: vi.fn() + }) +})) + +const activeFile: OpenFile = { + id: 'diff:/repo/file.ts', + filePath: '/repo/file.ts', + relativePath: 'file.ts', + worktreeId: 'repo::/repo', + language: 'typescript', + isDirty: false, + mode: 'diff' +} + +describe('EditorPanelHeader', () => { + it('shares one tooltip provider across the diff header controls', () => { + const html = renderToStaticMarkup( + + ) + + expect(html.match(/data-tooltip-provider/g)).toHaveLength(1) + expect(html.match(/data-tooltip="true"/g)).toHaveLength(3) + expect(html).toContain('data-delay-duration="300"') + expect(html).toContain('aria-label="Previous change"') + expect(html).toContain('aria-label="Next change"') + }) +}) diff --git a/src/renderer/src/components/editor/EditorPanelHeader.tsx b/src/renderer/src/components/editor/EditorPanelHeader.tsx index 6cbb2b6f69b..c9f8c6e4d1b 100644 --- a/src/renderer/src/components/editor/EditorPanelHeader.tsx +++ b/src/renderer/src/components/editor/EditorPanelHeader.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import { Columns2, Eye, FileText, ListTree, Rows2 } from 'lucide-react' +import { ArrowDown, ArrowUp, Columns2, Eye, FileText, ListTree, Rows2 } from 'lucide-react' import { useAppStore } from '@/store' import { selectWorktreeDiffCommentsOrEmpty } from '@/store/worktree-diff-comments-selector' import type { OpenFile } from '@/store/slices/editor' @@ -14,6 +14,9 @@ import { DiffNotesSendMenu } from './DiffNotesSendMenu' import { EditorPanelMarkdownActionsMenu } from './EditorPanelMarkdownActionsMenu' import { translate } from '@/i18n/i18n' import { EditorPanelHeaderPath } from './EditorPanelHeaderPath' +import { useDiffNavigation } from './diff-navigation-context' +import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel' +import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' type EditorPanelHeaderProps = { activeFile: OpenFile @@ -92,6 +95,9 @@ export function EditorPanelHeader({ () => diffComments.filter((comment) => comment.filePath === activeFile.relativePath), [activeFile.relativePath, diffComments] ) + const { changeCount, goToPreviousDiff, goToNextDiff } = useDiffNavigation() + const previousChangeShortcut = useShortcutKeyDetails('editor.previousChange') + const nextChangeShortcut = useShortcutKeyDetails('editor.nextChange') return (
@@ -178,6 +184,8 @@ export function EditorPanelHeader({ /> )} {isDiffSurface && ( + // Why: the adjacent diff controls use the same tooltip timing, so they + // share one provider instead of creating redundant Radix contexts. @@ -201,6 +209,58 @@ export function EditorPanelHeader({ )} + + + + + + {translate('auto.components.editor.EditorPanelHeader.2076ecfc9c', 'Previous change')} + {previousChangeShortcut.keys.length > 0 && ( + + )} + + + + + + + + {translate('auto.components.editor.EditorPanelHeader.631dab0df3', 'Next change')} + {nextChangeShortcut.keys.length > 0 && ( + + )} + + )} {hasEditorToggle && ( diff --git a/src/renderer/src/components/editor/MarkdownPreview.link-routing.interaction.test.tsx b/src/renderer/src/components/editor/MarkdownPreview.link-routing.interaction.test.tsx index 44dd06fe026..9be38fdb1d2 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.link-routing.interaction.test.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.link-routing.interaction.test.tsx @@ -15,6 +15,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const createBrowserTabMock = vi.fn() const setActiveWorktreeMock = vi.fn() const openUrlMock = vi.fn() +const openFileUriMock = vi.fn() +const pathExistsMock = vi.fn(async () => true) +const connectionOwner = vi.hoisted(() => ({ value: null as string | null | undefined })) +const targetConnectionOwners = vi.hoisted(() => new Map()) +const worktreeLookup = vi.hoisted(() => ({ + value: [] as { id: string; path: string; diffComments: never[] }[] +})) +const statRuntimePathMock = vi.hoisted(() => vi.fn(async () => ({ isDirectory: false }))) // Minimal store: MarkdownPreview reads settings/worktreesByRepo plus a handful // of action functions. None of the actions fire on the http path under test. @@ -31,6 +39,9 @@ const storeState = { clearDeliveredDiffComments: vi.fn(), keybindings: {}, worktreesByRepo: {}, + repos: [], + folderWorkspaces: [], + projectGroups: [], openFiles: [], activeFileIdByWorktree: {}, settings: { openLinksInApp: true }, @@ -44,14 +55,22 @@ vi.mock('@/store', () => { ) return { useAppStore } }) -vi.mock('@/store/slices/worktree-helpers', () => ({ findWorktreeById: () => null })) +vi.mock('@/store/slices/worktree-helpers', () => ({ + findWorktreeById: (_worktrees: unknown, id: string) => + worktreeLookup.value.find((worktree) => worktree.id === id) ?? null +})) vi.mock('@/runtime/runtime-rpc-client', () => ({ settingsForRuntimeOwner: (settings: unknown) => settings })) vi.mock('@/runtime/runtime-file-client', () => ({ - statRuntimePath: vi.fn(async () => ({ isDirectory: false })) + statRuntimePath: statRuntimePathMock +})) +vi.mock('@/lib/connection-context', () => ({ + getConnectionIdForFile: (worktreeId: string) => targetConnectionOwners.get(worktreeId) +})) +vi.mock('@/lib/connection-owner-resolution', () => ({ + createConnectionIdForFileSelector: () => () => connectionOwner.value })) -vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null })) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) vi.mock('./useLocalImageSrc', () => ({ useLocalImageSrc: (src?: string) => src })) vi.mock('./MermaidBlock', () => ({ default: () => null })) @@ -77,8 +96,8 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => { ;(window as unknown as { api: unknown }).api = { shell: { openUrl: openUrlMock, - openFileUri: vi.fn(), - pathExists: vi.fn(async () => true) + openFileUri: openFileUriMock, + pathExists: pathExistsMock }, ui: { writeClipboardText: vi.fn(async () => true) } } @@ -91,6 +110,14 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => { createBrowserTabMock.mockClear() setActiveWorktreeMock.mockClear() openUrlMock.mockClear() + openFileUriMock.mockClear() + pathExistsMock.mockClear() + connectionOwner.value = null + targetConnectionOwners.clear() + worktreeLookup.value = [] + storeState.worktreesByRepo = {} + storeState.openMarkdownPreview.mockClear() + statRuntimePathMock.mockClear() }) afterEach(() => { @@ -100,21 +127,28 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => { container.remove() }) - function render(): HTMLAnchorElement { + function render( + content = '[example](https://example.com)', + expectedHref = 'https://example.com', + sourceWorktreeId = 'wt-1', + filePath = '/repo/docs/README.md' + ): HTMLAnchorElement { container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) act(() => { root.render( ) }) - const anchor = container.querySelector('a[href="https://example.com"]') + const anchor = Array.from(container.querySelectorAll('a')).find( + (candidate) => candidate.getAttribute('href') === expectedHref + ) if (!anchor) { throw new Error('expected a rendered http anchor') } @@ -144,4 +178,59 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => { expect(openUrlMock).toHaveBeenCalledWith('https://example.com/') expect(createBrowserTabMock).not.toHaveBeenCalled() }) + + it('keeps system-browser HTTP and file links inert while ownership is unknown', () => { + connectionOwner.value = undefined + const httpAnchor = render() + click(httpAnchor, { metaKey: true, shiftKey: true }) + expect(openUrlMock).not.toHaveBeenCalled() + expect(createBrowserTabMock).not.toHaveBeenCalled() + + act(() => { + root.unmount() + }) + container.remove() + const fileAnchor = render('[file](file:///tmp/example.md)', 'file:///tmp/example.md') + click(fileAnchor, { metaKey: true, shiftKey: true }) + expect(pathExistsMock).not.toHaveBeenCalled() + expect(openFileUriMock).not.toHaveBeenCalled() + }) + + it('keeps same-path file links on the explicit SSH source worktree', async () => { + const localWorktree = { id: 'local-wt', path: '/srv/repo', diffComments: [] as never[] } + const sshWorktree = { id: 'ssh-wt', path: '/srv/repo', diffComments: [] as never[] } + worktreeLookup.value = [localWorktree, sshWorktree] + storeState.worktreesByRepo = { repo: [localWorktree, sshWorktree] } + connectionOwner.value = 'ssh-1' + targetConnectionOwners.set('local-wt', null) + targetConnectionOwners.set('ssh-wt', 'ssh-1') + const anchor = render('[child](child.md)', 'child.md', 'ssh-wt', '/srv/repo/README.md') + + await act(async () => { + anchor.dispatchEvent(new window.MouseEvent('click', { bubbles: true, cancelable: true })) + }) + + expect(statRuntimePathMock).toHaveBeenCalledWith( + expect.objectContaining({ worktreeId: 'ssh-wt', connectionId: 'ssh-1' }), + '/srv/repo/child.md' + ) + expect(storeState.openMarkdownPreview).toHaveBeenCalledWith( + expect.objectContaining({ worktreeId: 'ssh-wt', filePath: '/srv/repo/child.md' }), + { anchor: null } + ) + }) + + it('renders a raw HTML superscript citation and routes it like a Markdown link', () => { + const source = '[12]' + const anchor = render(source) + + expect(anchor.parentElement?.tagName).toBe('SUP') + expect(anchor.textContent).toBe('[12]') + expect(container.textContent).not.toContain('') + click(anchor, { metaKey: true }) + expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', 'https://example.com/', { + activate: true + }) + expect(openUrlMock).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx b/src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx index 46301826f01..23f66fa9ee5 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx @@ -28,6 +28,9 @@ const storeState = { clearDeliveredDiffComments: vi.fn(), keybindings: {}, worktreesByRepo: {}, + repos: [], + folderWorkspaces: [], + projectGroups: [], openFiles: [], activeFileIdByWorktree: {}, settings: { openLinksInApp: true }, @@ -48,7 +51,10 @@ vi.mock('@/runtime/runtime-rpc-client', () => ({ vi.mock('@/runtime/runtime-file-client', () => ({ statRuntimePath: vi.fn(async () => ({ isDirectory: false })) })) -vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null })) +vi.mock('@/lib/connection-context', () => ({ getConnectionIdForFile: () => null })) +vi.mock('@/lib/connection-owner-resolution', () => ({ + createConnectionIdForFileSelector: () => () => null +})) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) vi.mock('./useLocalImageSrc', () => ({ useLocalImageSrc: (src?: string) => src })) vi.mock('./MermaidBlock', () => ({ default: () => null })) diff --git a/src/renderer/src/components/editor/MarkdownPreview.tsx b/src/renderer/src/components/editor/MarkdownPreview.tsx index b77aaa33fb5..331d683e715 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.tsx @@ -38,7 +38,8 @@ import { Input } from '@/components/ui/input' import { useAppStore } from '@/store' import { toast } from 'sonner' import { computeEditorFontSize } from '@/lib/editor-font-zoom' -import { getConnectionId } from '@/lib/connection-context' +import { getConnectionIdForFile } from '@/lib/connection-context' +import { createConnectionIdForFileSelector } from '@/lib/connection-owner-resolution' import { scrollTopCache, setWithLRU } from '@/lib/scroll-cache' import { detectLanguage } from '@/lib/language-detect' import type { DiffComment, MarkdownDocument, Worktree } from '../../../../shared/types' @@ -68,7 +69,7 @@ import { setActiveMarkdownPreviewSearchMatch } from './markdown-preview-search' import { usePreserveSectionDuringExternalEdit } from './usePreserveSectionDuringExternalEdit' -import { openHttpLink } from '@/lib/http-link-routing' +import { openHttpLink, type HttpLinkSourceOwner } from '@/lib/http-link-routing' import { getShortcutPlatform } from '@/lib/shortcut-platform' import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' import { markdownPreviewUrlTransform } from './markdown-preview-url-transform' @@ -390,14 +391,18 @@ export function deriveMarkdownPreviewSourceRoot( function findWorktreeForMarkdownPreviewPath( worktreesByRepo: Record, - absolutePath: string + absolutePath: string, + acceptsWorktree: (worktree: Worktree) => boolean = () => true ): Worktree | null { let bestMatch: Worktree | null = null let bestMatchLength = -1 for (const worktrees of Object.values(worktreesByRepo)) { for (const worktree of worktrees) { - if (relativePathInsideRoot(worktree.path, absolutePath) !== null) { + if ( + acceptsWorktree(worktree) && + relativePathInsideRoot(worktree.path, absolutePath) !== null + ) { const normalizedWorktreePathLength = normalizeMarkdownPreviewAbsolutePath( worktree.path ).length @@ -412,6 +417,27 @@ function findWorktreeForMarkdownPreviewPath( return bestMatch } +function findMarkdownPreviewTargetWorktree( + worktreesByRepo: Record, + absolutePath: string, + sourceWorktree: Worktree | null, + sourceOwner: HttpLinkSourceOwner +): Worktree | null { + if (sourceWorktree && relativePathInsideRoot(sourceWorktree.path, absolutePath) !== null) { + return sourceWorktree + } + return findWorktreeForMarkdownPreviewPath(worktreesByRepo, absolutePath, (worktree) => { + const connectionId = getConnectionIdForFile(worktree.id, absolutePath) + if (sourceOwner.kind === 'local') { + return connectionId === null + } + if (sourceOwner.kind === 'ssh') { + return connectionId === sourceOwner.connectionId + } + return false + }) +} + export function resolveMarkdownPreviewSourceWorktree( worktreesByRepo: Record, sourceWorktreeId: string | null | undefined, @@ -458,12 +484,18 @@ export default function MarkdownPreview({ input.focus() input.select() }, []) - const matchesRef = useRef([]) + const matchesRef = useRef([]) + // Stable token identifying this preview in the document-global highlight + // registry, so split/floating previews don't clobber each other's Find paint. + const searchInstanceRef = useRef({}) const lastAppliedInitialAnchorRef = useRef(null) const pendingEditorRevealFrameIdsRef = useRef([]) const [isSearchOpen, setIsSearchOpen] = useState(false) const [query, setQuery] = useState('') const [matchCount, setMatchCount] = useState(0) + // Bumps whenever the match ranges are recomputed, so the active-highlight + // effect re-runs even when a streamed rerender yields the same count/index. + const [searchRevision, setSearchRevision] = useState(0) const [activeMatchIndex, setActiveMatchIndex] = useState(-1) const isMac = navigator.userAgent.includes('Mac') const openFile = useAppStore((s) => s.openFile) @@ -498,9 +530,26 @@ export default function MarkdownPreview({ ) const allDiffComments = sourceWorktree?.diffComments const sourceRoutingWorktreeId = sourceWorktree?.id ?? resolvedSourceWorktreeId - const sourceConnectionId = sourceRoutingWorktreeId - ? (getConnectionId(sourceRoutingWorktreeId) ?? null) - : null + const runtimeOwnerId = resolvedSourceRuntimeEnvironmentId?.trim() + const sourceConnectionIdSelector = useMemo( + () => + createConnectionIdForFileSelector(sourceRoutingWorktreeId, filePath, { + skip: Boolean(runtimeOwnerId) + }), + [filePath, runtimeOwnerId, sourceRoutingWorktreeId] + ) + const sourceConnectionId = useAppStore(sourceConnectionIdSelector) + const sourceOwner = useMemo( + () => + runtimeOwnerId + ? { kind: 'runtime', runtimeEnvironmentId: runtimeOwnerId } + : sourceConnectionId === undefined + ? { kind: 'unknown' } + : sourceConnectionId === null + ? { kind: 'local' } + : { kind: 'ssh', connectionId: sourceConnectionId }, + [runtimeOwnerId, sourceConnectionId] + ) const worktreeRoot = sourceWorktree?.path ?? (sourceRoutingWorktreeId @@ -576,12 +625,11 @@ export default function MarkdownPreview({ .replace(/\r?\n(?:---|\+\+\+)\r?\n?$/, '') .trim() }, [frontMatter]) - // Why: front matter is hidden by default (#4468) and controlled from the - // markdown preview actions menu, keeping metadata out of the reading surface - // unless the user explicitly asks for it. + // Why: front matter shows by default and is toggled off from the markdown + // preview actions menu; the store map only carries per-file hide overrides. const toggleableSourceFileId: string | null = sourceFileId ?? null const frontmatterVisible = toggleableSourceFileId - ? (frontmatterVisibleByFile[toggleableSourceFileId] ?? false) + ? (frontmatterVisibleByFile[toggleableSourceFileId] ?? true) : true const [activeAnnotationBlockKey, setActiveAnnotationBlockKey] = useState(null) const [reviewNotesCopied, setReviewNotesCopied] = useState(false) @@ -793,29 +841,36 @@ export default function MarkdownPreview({ return } + const instanceId = searchInstanceRef.current + if (!isSearchOpen) { matchesRef.current = [] setMatchCount(0) - clearMarkdownPreviewSearchHighlights(body) + clearMarkdownPreviewSearchHighlights(instanceId) return } - // Search decorations are applied imperatively because the rendered preview is - // already owned by react-markdown. Rewriting the markdown AST for transient - // find state would make navigation and link rendering much harder to reason about. - const matches = applyMarkdownPreviewSearchHighlights(body, query) + // Search decorations are painted via the CSS Custom Highlight API (Ranges, + // no DOM mutation) because the rendered preview is owned by react-markdown; + // splitting its nodes to inject corrupted react's tree (crash 237acef1). + const matches = applyMarkdownPreviewSearchHighlights(instanceId, body, query) matchesRef.current = matches setMatchCount(matches.length) + setSearchRevision((v) => v + 1) setActiveMatchIndex((cur) => matches.length === 0 ? -1 : cur >= 0 && cur < matches.length ? cur : 0 ) - return () => clearMarkdownPreviewSearchHighlights(body) + return () => clearMarkdownPreviewSearchHighlights(instanceId) }, [renderedContent, isSearchOpen, query]) useEffect(() => { - setActiveMarkdownPreviewSearchMatch(matchesRef.current, activeMatchIndex) - }, [activeMatchIndex, matchCount]) + setActiveMarkdownPreviewSearchMatch( + searchInstanceRef.current, + matchesRef.current, + activeMatchIndex + ) + }, [activeMatchIndex, matchCount, searchRevision]) useLayoutEffect(() => { if (!initialAnchor || initialAnchor === lastAppliedInitialAnchorRef.current) { @@ -1254,6 +1309,9 @@ export default function MarkdownPreview({ // dangling in-worktree .md, pre-check existence so the user sees a // toast instead of the silent no-op from shell.openFileUri. if (isMarkdownPreviewSystemBrowserModifier(event, isMac)) { + if (sourceOwner.kind === 'unknown') { + return + } const osTarget = getMarkdownPreviewLinkTarget(href, filePath) if (!osTarget) { return @@ -1267,7 +1325,12 @@ export default function MarkdownPreview({ if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { openHttpLink( parsed.toString(), - resolveMarkdownPreviewHttpOpenOptions(event, isMac, sourceRoutingWorktreeId) + resolveMarkdownPreviewHttpOpenOptions( + event, + isMac, + sourceRoutingWorktreeId, + sourceOwner + ) ) return } @@ -1326,7 +1389,12 @@ export default function MarkdownPreview({ // handled above; this path only sees non-escape-hatch clicks.) openHttpLink( target.toString(), - resolveMarkdownPreviewHttpOpenOptions(event, isMac, sourceRoutingWorktreeId) + resolveMarkdownPreviewHttpOpenOptions( + event, + isMac, + sourceRoutingWorktreeId, + sourceOwner + ) ) return } @@ -1347,12 +1415,25 @@ export default function MarkdownPreview({ ? { line: classifiedFileTarget.line, column: classifiedFileTarget.column } : parseLineTarget(target.hash) + // Why: same-file anchors need no ownership/filesystem resolution (e.g. + // `./README.md#heading` when this file is README.md). Run before the + // unknown-ownership guard so ambiguous folder-workspace ownership still + // scrolls within the open document. if (absolutePath === filePath && target.hash && !lineTarget) { void scrollToAnchor(target.hash.slice(1)) return } - const targetWorktree = findWorktreeForMarkdownPreviewPath(worktreesByRepo, absolutePath) + if (sourceOwner.kind === 'unknown') { + return + } + + const targetWorktree = findMarkdownPreviewTargetWorktree( + worktreesByRepo, + absolutePath, + sourceWorktree, + sourceOwner + ) if (!targetWorktree) { if (sourceRoutingWorktreeId && worktreeRoot) { // Why: floating markdown files are owned by a synthetic workspace, @@ -1362,7 +1443,8 @@ export default function MarkdownPreview({ sourceFilePath: filePath, worktreeId: sourceRoutingWorktreeId, worktreeRoot, - runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId + runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId, + sourceOwner }) return } @@ -1384,8 +1466,15 @@ export default function MarkdownPreview({ return } - const relativePath = absolutePath.slice(targetWorktree.path.length + 1) + const relativePath = relativePathInsideRoot(targetWorktree.path, absolutePath) + if (relativePath === null) { + return + } const language = detectLanguage(absolutePath) + const targetConnectionId = getConnectionIdForFile(targetWorktree.id, absolutePath) + if (targetConnectionId === undefined) { + return + } try { const stats = await statRuntimePath( { @@ -1395,7 +1484,7 @@ export default function MarkdownPreview({ ), worktreeId: targetWorktree.id, worktreePath: targetWorktree.path, - connectionId: getConnectionId(targetWorktree.id) ?? undefined + connectionId: targetConnectionId ?? undefined }, absolutePath ) @@ -1512,7 +1601,8 @@ export default function MarkdownPreview({ sourceFilePath: filePath, worktreeId: sourceRoutingWorktreeId, worktreeRoot, - runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId + runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId, + sourceOwner }) } @@ -1673,6 +1763,8 @@ export default function MarkdownPreview({ setMarkdownViewMode, setPendingEditorReveal, sourceConnectionId, + sourceOwner, + sourceWorktree, resolvedSourceRuntimeEnvironmentId, sourceRoutingWorktreeId, worktreeRoot, @@ -1840,7 +1932,10 @@ export default function MarkdownPreview({ ) : null} ) : null} -
+ {/* Why: translate="no" keeps browser/OS page-translation from swapping + text nodes react owns, which otherwise triggers the same + insertBefore/removeChild reconciliation crash (237acef1). */} +
{/* Why: remarkFrontmatter strips front matter from normal markdown output. When the user opts in from the preview actions menu, render the raw metadata as a compact read-only block above the document body. */} diff --git a/src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx b/src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx new file mode 100644 index 00000000000..790752921e2 --- /dev/null +++ b/src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx @@ -0,0 +1,62 @@ +// @vitest-environment happy-dom +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const editorProps = vi.hoisted(() => ({ current: null as Record | null })) + +vi.mock('@monaco-editor/react', () => ({ + default: (props: Record) => { + editorProps.current = props + return null + }, + loader: { config: vi.fn() } +})) +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ + settings: { theme: 'dark', terminalFontSize: 13, terminalFontFamily: 'monospace' }, + editorFontZoomLevel: 0, + setPendingEditorReveal: vi.fn(), + setEditorCursorLine: vi.fn(), + addDiffComment: vi.fn(), + deleteDiffComment: vi.fn(), + updateDiffComment: vi.fn(), + scrollToDiffCommentId: null, + setScrollToDiffCommentId: vi.fn(), + worktreeDiffComments: {} + }) +})) +vi.mock('../diff-comments/useDiffCommentDecorator', () => ({ + useDiffCommentDecorator: vi.fn() +})) +vi.mock('./useContextualCopySetup', () => ({ + useContextualCopySetup: () => ({ setupCopy: vi.fn(), toastNode: null }) +})) + +import MonacoEditor from './MonacoEditor' + +afterEach(() => { + cleanup() + editorProps.current = null +}) + +describe('MonacoEditor content ownership', () => { + it('initializes the wrapper without a controlled value updater', () => { + render( + + ) + + expect(editorProps.current?.defaultValue).toBe('initial content') + expect(editorProps.current).not.toHaveProperty('value') + }) +}) diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx index 6c608ceefa1..daf288d8ce9 100644 --- a/src/renderer/src/components/editor/MonacoEditor.tsx +++ b/src/renderer/src/components/editor/MonacoEditor.tsx @@ -15,7 +15,11 @@ import { registerFileSearchSelectedTextProvider } from '@/lib/file-search-select import { useContextualCopySetup } from './useContextualCopySetup' import { MAX_REVEAL_CONTENT_WAIT_FRAMES, performReveal } from './monaco-reveal' -import { syncContentOnMount, syncContentUpdate } from './monaco-content-sync' +import { + syncContentOnMount, + syncContentUpdate, + type MonacoContentSyncMode +} from './monaco-content-sync' import { getMonacoCodebaseSearchQuery } from './monaco-codebase-search' import { beginProgrammaticContentSync, @@ -52,11 +56,13 @@ import { } from './monaco-markdown-selection-annotation' import { translate } from '@/i18n/i18n' import { handleMonacoLargeTextPaste } from './monaco-large-text-paste' +import { buildFileEditorWordWrapOptions } from './file-editor-word-wrap-options' import { clampMonacoAutoHeight, getMonacoAutoHeightForContent, isMonacoAutoHeightCapped } from './monaco-auto-height' +import { installMonacoE2EProbe } from './monaco-e2e-probe' type MonacoEditorProps = { fileId: string @@ -75,6 +81,7 @@ type MonacoEditorProps = { markdownAnnotationsEnabled?: boolean conflictDecorationsEnabled?: boolean readOnly?: boolean + liveTail?: boolean autoHeight?: boolean } @@ -99,6 +106,7 @@ export default function MonacoEditor({ markdownAnnotationsEnabled = false, conflictDecorationsEnabled = false, readOnly = false, + liveTail = false, autoHeight = false }: MonacoEditorProps): React.JSX.Element { const editorRef = useRef(null) @@ -129,6 +137,8 @@ export default function MonacoEditor({ propsRef.current = { relativePath, language, onSave, onContentChange } const readOnlyRef = useRef(readOnly) readOnlyRef.current = readOnly + const contentSyncModeRef = useRef('undoable') + contentSyncModeRef.current = readOnly && liveTail ? 'read-only-live-tail' : 'undoable' const settings = useAppStore((s) => s.settings) const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel) @@ -146,6 +156,8 @@ export default function MonacoEditor({ settings?.terminalFontSize ?? 13, editorFontZoomLevel ) + const editorFontFamily = settings?.terminalFontFamily || 'monospace' + const editorWordWrap = settings?.editorWordWrap const estimatedAutoHeight = useMemo(() => { if (!autoHeight) { return null @@ -329,6 +341,7 @@ export default function MonacoEditor({ (editorInstance, monaco) => { editorRef.current = editorInstance setMountedEditor(editorInstance) + const uninstallE2EProbe = installMonacoE2EProbe(editorInstance, filePath) let autoHeightSub: { dispose: () => void } | null = null let autoHeightFrame: number | null = null const updateAutoHeight = (): void => { @@ -365,7 +378,11 @@ export default function MonacoEditor({ beginProgrammaticContentSync(filePath) isApplyingProgrammaticContentRef.current = true try { - const didSyncOnMount = syncContentOnMount(editorInstance, contentRef.current) + const didSyncOnMount = syncContentOnMount( + editorInstance, + contentRef.current, + contentSyncModeRef.current + ) if (didSyncOnMount) { lastSyncedContentRef.current = contentRef.current } @@ -504,6 +521,7 @@ export default function MonacoEditor({ } conflictDecorationsRef.current?.clear() conflictDecorationsRef.current = null + uninstallE2EProbe() editorRef.current = null setMountedEditor(null) setCommentPopover(null) @@ -666,7 +684,7 @@ export default function MonacoEditor({ beginProgrammaticContentSync(filePath) isApplyingProgrammaticContentRef.current = true try { - syncContentUpdate(ed, content) + syncContentUpdate(ed, content, contentSyncModeRef.current) lastSyncedContentRef.current = content } finally { isApplyingProgrammaticContentRef.current = false @@ -707,14 +725,15 @@ export default function MonacoEditor({ // Update editor options when settings change useEffect(() => { - if (!editorRef.current || !settings) { + if (!editorRef.current) { return } editorRef.current.updateOptions({ fontSize: editorFontSize, - fontFamily: settings.terminalFontFamily || 'monospace' + fontFamily: editorFontFamily, + ...buildFileEditorWordWrapOptions(editorWordWrap) }) - }, [editorFontSize, settings]) + }, [editorFontFamily, editorFontSize, editorWordWrap]) useEffect(() => { markdownDocLinkDecorationsRef.current?.refresh() @@ -822,7 +841,9 @@ export default function MonacoEditor({ selectWorktreeDiffComments(s, worktreeId) ) - const worktreeRoot = useAppStore( - (s) => getIndexedWorktreeById(s.worktreesByRepo, worktreeId)?.path ?? null - ) + const { codec, htmlSuperscriptLinkContext, worktreeRoot } = useRichMarkdownSuperscriptLinkSetup({ + filePath, + runtimeEnvironmentId, + worktreeId + }) const scrollContainerRef = useRef(null) const menu = useRichMarkdownMenuController({ markdownDocuments }) const isMac = navigator.userAgent.includes('Mac') @@ -196,6 +202,8 @@ export default function RichMarkdownEditor({ ) const editor = useRichMarkdownEditorInstance({ + codec, + htmlSuperscriptLinkContext, content, filePath, worktreeId, @@ -240,6 +248,13 @@ export default function RichMarkdownEditor({ setSlashMenu: menu.setSlashMenu, setDocLinkMenu: menu.setDocLinkMenu }) + // Why: useEditor defaults shouldRerenderOnTransaction to false, so selection-only + // citation NodeSelections would leave aria status stale without useEditorState. + const selectedCitationStatus = useEditorState({ + editor, + selector: (snapshot) => + getSelectedHtmlSuperscriptLinkStatus(snapshot.editor, htmlSuperscriptLinkContext) + }) useRichMarkdownSpellcheckAttribute(editor, richMarkdownSpellcheckEnabled) // Why: use useLayoutEffect (synchronous cleanup) so the pending serialization @@ -265,6 +280,7 @@ export default function RichMarkdownEditor({ }) useRichMarkdownProgrammaticSync({ + codec, content, docLinkMenuSetter: menu.setDocLinkMenu, editor, @@ -289,12 +305,14 @@ export default function RichMarkdownEditor({ handleLinkRemove, handleLinkEditCancel, handleLinkOpen, + handleLinkCopy, toggleLinkFromToolbar } = useLinkBubble(editor, rootRef, linkBubble, setLinkBubble, setIsEditingLink, { sourceFilePath: filePath, worktreeId, worktreeRoot, - runtimeEnvironmentId + runtimeEnvironmentId, + htmlSuperscriptLinkContext }) useEffect(() => { @@ -370,6 +388,12 @@ export default function RichMarkdownEditor({ showTableOfContents={showTableOfContents} searchState={searchState} searchActions={searchActions} + citationStatus={ + selectedCitationStatus + ? formatSelectedHtmlSuperscriptLinkStatus(selectedCitationStatus) + : '' + } + linkBubbleOwnerId={codec.transport.key} linkBubbleActions={{ dismissLinkBubble: () => { setLinkBubble(null) @@ -379,6 +403,7 @@ export default function RichMarkdownEditor({ handleLinkRemove, handleLinkEditCancel, handleLinkOpen, + handleLinkCopy, setIsEditingLink }} onToggleLink={toggleLinkFromToolbar} diff --git a/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx b/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx index f5283616abb..fd619247c2a 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx @@ -76,6 +76,7 @@ type RichMarkdownEditorSurfaceProps = { matchCase: boolean matchCount: number replaceQuery: string + replaceDisabled: boolean searchQuery: string searchInputRef: React.RefObject wholeWord: boolean @@ -91,12 +92,15 @@ type RichMarkdownEditorSurfaceProps = { toggleReplaceMode: () => void toggleWholeWord: () => void } + citationStatus: string + linkBubbleOwnerId: string linkBubbleActions: { dismissLinkBubble: () => void handleLinkSave: (href: string) => void handleLinkRemove: () => void handleLinkEditCancel: () => void handleLinkOpen: () => void + handleLinkCopy: () => void setIsEditingLink: (editing: boolean) => void } onToggleLink: () => void @@ -155,6 +159,8 @@ export function RichMarkdownEditorSurface({ showTableOfContents, searchState, searchActions, + citationStatus, + linkBubbleOwnerId, linkBubbleActions, onToggleLink, onImagePick, @@ -237,6 +243,7 @@ export function RichMarkdownEditorSurface({ matchCount={searchState.matchCount} query={searchState.searchQuery} replaceQuery={searchState.replaceQuery} + replaceDisabled={searchState.replaceDisabled} searchInputRef={searchState.searchInputRef} wholeWord={searchState.wholeWord} onClose={searchActions.closeSearch} @@ -262,8 +269,13 @@ export function RichMarkdownEditorSurface({ onEditStart={() => linkBubbleActions.setIsEditingLink(true)} onEditCancel={linkBubbleActions.handleLinkEditCancel} onOpen={linkBubbleActions.handleLinkOpen} + onCopy={linkBubbleActions.handleLinkCopy} + ownerId={linkBubbleOwnerId} /> ) : null} + + {citationStatus} + {slashMenu ? ( void onEditCancel: () => void onOpen: () => void + onCopy: () => void + ownerId?: string portalToDocument?: boolean } @@ -173,6 +184,8 @@ export function RichMarkdownLinkBubble({ onEditStart, onEditCancel, onOpen, + onCopy, + ownerId, portalToDocument = false }: RichMarkdownLinkBubbleProps): React.JSX.Element { const bubbleRef = useRef(null) @@ -270,6 +283,8 @@ export function RichMarkdownLinkBubble({
{ // Prevent editor blur when clicking bubble buttons, but let inputs @@ -278,7 +293,28 @@ export function RichMarkdownLinkBubble({ e.preventDefault() } }} - onKeyDown={(e) => e.stopPropagation()} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === 'Escape') { + event.preventDefault() + onDismiss() + anchorElement?.querySelector('[contenteditable="true"]')?.focus() + return + } + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') { + return + } + const buttons = Array.from( + bubbleRef.current?.querySelectorAll('button:not([disabled])') ?? [] + ) + const currentIndex = buttons.indexOf(document.activeElement as HTMLButtonElement) + if (currentIndex === -1 || buttons.length === 0) { + return + } + event.preventDefault() + const direction = event.key === 'ArrowRight' ? 1 : -1 + buttons[(currentIndex + direction + buttons.length) % buttons.length]?.focus() + }} > {isEditing ? ( @@ -287,39 +323,45 @@ export function RichMarkdownLinkBubble({ {linkBubble.href.length > 40 ? `${linkBubble.href.slice(0, 40)}…` : linkBubble.href} - - - + + + {linkBubble.kind === 'markdown' ? ( + <> + + + + + + + + ) : null} )}
@@ -329,3 +371,36 @@ export function RichMarkdownLinkBubble({ // actions must portal to the app layer to remain above the right sidebar. return portalToDocument ? createPortal(bubble, document.body) : bubble } + +function LinkBubbleAction({ + children, + disabled = false, + label, + onClick +}: { + children: React.ReactNode + disabled?: boolean + label: string + onClick: () => void +}): React.JSX.Element { + return ( + + + + + + {label} + + + ) +} diff --git a/src/renderer/src/components/editor/RichMarkdownSearchBar.tsx b/src/renderer/src/components/editor/RichMarkdownSearchBar.tsx index ca7fd042a83..9ea8d17e068 100644 --- a/src/renderer/src/components/editor/RichMarkdownSearchBar.tsx +++ b/src/renderer/src/components/editor/RichMarkdownSearchBar.tsx @@ -22,6 +22,7 @@ type RichMarkdownSearchBarProps = { matchCount: number query: string replaceQuery: string + replaceDisabled: boolean searchInputRef: React.RefObject wholeWord: boolean onClose: () => void @@ -43,6 +44,7 @@ export function RichMarkdownSearchBar({ matchCount, query, replaceQuery, + replaceDisabled, searchInputRef, wholeWord, onClose, @@ -58,6 +60,7 @@ export function RichMarkdownSearchBar({ // Why: surface the same replace shortcut the source editor uses so the toggle // is discoverable; reads the user's effective binding, formatted per platform. const replaceShortcut = useOptionalShortcutLabel('editor.replace') + const readOnlyExplanationId = React.useId() if (!isOpen) { return null @@ -71,6 +74,10 @@ export function RichMarkdownSearchBar({ } const noMatches = matchCount === 0 + const readOnlyReplaceExplanation = translate( + 'auto.components.editor.RichMarkdownSearchBar.preservedRichContentReadOnly', + 'Preserved rich content is read-only in rich mode.' + ) const toggleReplaceLabel = isReplaceMode ? translate('auto.components.editor.RichMarkdownSearchBar.e8c147435f', 'Hide replace') : translate('auto.components.editor.RichMarkdownSearchBar.9cdc38be33', 'Toggle replace') @@ -259,6 +266,7 @@ export function RichMarkdownSearchBar({ 'auto.components.editor.RichMarkdownSearchBar.44682b4159', 'Replace in rich markdown editor' )} + aria-describedby={replaceDisabled ? readOnlyExplanationId : undefined} />
+ {replaceDisabled ? ( + + {readOnlyReplaceExplanation} + + ) : null}
) : null} diff --git a/src/renderer/src/components/editor/diff-navigation-context.test.tsx b/src/renderer/src/components/editor/diff-navigation-context.test.tsx new file mode 100644 index 00000000000..d2efce0bd36 --- /dev/null +++ b/src/renderer/src/components/editor/diff-navigation-context.test.tsx @@ -0,0 +1,173 @@ +// @vitest-environment happy-dom +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { editor } from 'monaco-editor' +import { + DiffNavigationProvider, + useDiffEditorRegistration, + useDiffNavigation, + type DiffEditorRegistrationContextValue, + type DiffNavigationContextValue +} from './diff-navigation-context' + +type FakeDiffEditor = editor.IStandaloneDiffEditor & { + setLineChanges: (count: number) => void + fireUpdate: () => void + goToDiff: ReturnType + disposeUpdate: ReturnType + containerNode: HTMLElement +} + +function createFakeEditor(initialCount: number): FakeDiffEditor { + let count = initialCount + let updateCallback: (() => void) | null = null + const disposeUpdate = vi.fn(() => { + updateCallback = null + }) + const containerNode = document.createElement('div') + const editor = { + getLineChanges: () => (count > 0 ? Array.from({ length: count }, () => ({})) : []), + goToDiff: vi.fn(), + getContainerDomNode: () => containerNode, + onDidUpdateDiff: (cb: () => void) => { + updateCallback = cb + return { + dispose: disposeUpdate + } + }, + setLineChanges: (next: number) => { + count = next + }, + fireUpdate: () => updateCallback?.(), + disposeUpdate, + containerNode + } as unknown as FakeDiffEditor + return editor +} + +let captured: DiffNavigationContextValue | null = null +let registration: DiffEditorRegistrationContextValue | null = null +let registrationRenderCount = 0 + +function Probe(): null { + captured = useDiffNavigation() + return null +} + +function RegistrationProbe(): null { + registration = useDiffEditorRegistration() + registrationRenderCount += 1 + return null +} + +describe('DiffNavigationProvider', () => { + let container: HTMLDivElement | null = null + let root: Root | null = null + + function mount(): void { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => { + root?.render( + + + + + ) + }) + } + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + container = null + root = null + captured = null + registration = null + registrationRenderCount = 0 + }) + + it('exposes the change count and routes nav actions to the registered editor', () => { + mount() + const editor = createFakeEditor(3) + act(() => registration?.registerDiffEditor(editor)) + + expect(captured?.changeCount).toBe(3) + + act(() => captured?.goToNextDiff()) + expect(editor.goToDiff).toHaveBeenCalledWith('next') + + act(() => captured?.goToPreviousDiff()) + expect(editor.goToDiff).toHaveBeenCalledWith('previous') + }) + + it('re-renders when onDidUpdateDiff flips the count 0 -> N (count is state)', () => { + mount() + const editor = createFakeEditor(0) + act(() => registration?.registerDiffEditor(editor)) + expect(captured?.changeCount).toBe(0) + + act(() => { + editor.setLineChanges(2) + editor.fireUpdate() + }) + expect(captured?.changeCount).toBe(2) + expect(registrationRenderCount).toBe(1) + }) + + it('ignores a stale unregister for an editor that is no longer current (identity guard)', () => { + mount() + const oldEditor = createFakeEditor(1) + const newEditor = createFakeEditor(4) + + // Fast-swap: new editor registers before the old one's dispose fires. + act(() => registration?.registerDiffEditor(oldEditor)) + act(() => registration?.registerDiffEditor(newEditor)) + expect(captured?.changeCount).toBe(4) + expect(oldEditor.disposeUpdate).toHaveBeenCalledOnce() + + // A stale update from the old editor must not flip the count back: registering + // the new editor disposed the old subscription, so its callback no longer fires. + act(() => { + oldEditor.setLineChanges(9) + oldEditor.fireUpdate() + }) + expect(captured?.changeCount).toBe(4) + + act(() => registration?.unregisterDiffEditor(oldEditor)) + + // New editor's count is intact and nav still routes to it. + expect(captured?.changeCount).toBe(4) + act(() => captured?.goToNextDiff()) + expect(newEditor.goToDiff).toHaveBeenCalledWith('next') + expect(oldEditor.goToDiff).not.toHaveBeenCalled() + }) + + it('disposes the active diff update subscription when the provider unmounts', () => { + mount() + const editor = createFakeEditor(1) + act(() => registration?.registerDiffEditor(editor)) + + act(() => root?.unmount()) + + expect(editor.disposeUpdate).toHaveBeenCalledOnce() + root = null + }) + + it('installs a capture-phase key listener on register and removes it on unregister', () => { + mount() + const editor = createFakeEditor(2) + const addSpy = vi.spyOn(editor.containerNode, 'addEventListener') + const removeSpy = vi.spyOn(editor.containerNode, 'removeEventListener') + + act(() => registration?.registerDiffEditor(editor)) + expect(addSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true) + + act(() => registration?.unregisterDiffEditor(editor)) + expect(removeSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true) + }) +}) diff --git a/src/renderer/src/components/editor/diff-navigation-context.tsx b/src/renderer/src/components/editor/diff-navigation-context.tsx new file mode 100644 index 00000000000..9bd1175de45 --- /dev/null +++ b/src/renderer/src/components/editor/diff-navigation-context.tsx @@ -0,0 +1,126 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import type { editor } from 'monaco-editor' +import { installMonacoDiffChangeNavigationShortcut } from './editor-shortcuts' + +export type DiffEditorRegistrationContextValue = { + registerDiffEditor: (editor: editor.IStandaloneDiffEditor) => void + unregisterDiffEditor: (editor: editor.IStandaloneDiffEditor) => void +} + +export type DiffNavigationContextValue = { + goToPreviousDiff: () => void + goToNextDiff: () => void + changeCount: number +} + +const noop = (): void => {} + +// Why: registration stays separate from changeCount so diff recomputation only +// rerenders the header controls, not the heavy Monaco DiffViewer consumer. +const DiffEditorRegistrationContext = createContext({ + registerDiffEditor: noop, + unregisterDiffEditor: noop +}) + +const DiffNavigationContext = createContext({ + goToPreviousDiff: noop, + goToNextDiff: noop, + changeCount: 0 +}) + +function countChanges(diffEditor: editor.IStandaloneDiffEditor): number { + return diffEditor.getLineChanges()?.length ?? 0 +} + +export function DiffNavigationProvider({ + children +}: { + children: React.ReactNode +}): React.JSX.Element { + const editorRef = useRef(null) + const updateSubRef = useRef<{ dispose: () => void } | null>(null) + // Why: F7/Shift+F7 change navigation shares the registered editor with the + // header buttons, so the keyboard listener lives here rather than in DiffViewer. + const shortcutCleanupRef = useRef<(() => void) | null>(null) + // Why: changeCount must be state, not a ref — the header is a sibling consumer + // and only re-renders (enabling the buttons) when the value object identity + // changes on the 0 -> N flip once the diff computation lands. + const [changeCount, setChangeCount] = useState(0) + + const registerDiffEditor = useCallback((diffEditor: editor.IStandaloneDiffEditor) => { + editorRef.current = diffEditor + // Hold at most one update subscription; replace any prior editor's. + updateSubRef.current?.dispose() + updateSubRef.current = diffEditor.onDidUpdateDiff(() => { + // Why: ignore updates from an editor that is no longer current so a stale + // subscription in the fast-swap case can't write a wrong count. + if (editorRef.current === diffEditor) { + setChangeCount(countChanges(diffEditor)) + } + }) + // Hold at most one keyboard listener; replace any prior editor's. + shortcutCleanupRef.current?.() + shortcutCleanupRef.current = installMonacoDiffChangeNavigationShortcut(diffEditor) + setChangeCount(countChanges(diffEditor)) + }, []) + + const unregisterDiffEditor = useCallback((diffEditor: editor.IStandaloneDiffEditor) => { + // Why: identity guard for the fast-swap race — a stale dispose carrying the + // old editor must not wipe a freshly-registered new one. + if (editorRef.current !== diffEditor) { + return + } + updateSubRef.current?.dispose() + updateSubRef.current = null + shortcutCleanupRef.current?.() + shortcutCleanupRef.current = null + editorRef.current = null + setChangeCount(0) + }, []) + + const goToPreviousDiff = useCallback(() => { + editorRef.current?.goToDiff('previous') + }, []) + + const goToNextDiff = useCallback(() => { + editorRef.current?.goToDiff('next') + }, []) + + useEffect(() => { + return () => { + updateSubRef.current?.dispose() + updateSubRef.current = null + shortcutCleanupRef.current?.() + shortcutCleanupRef.current = null + } + }, []) + + const registrationValue = useMemo( + () => ({ registerDiffEditor, unregisterDiffEditor }), + [registerDiffEditor, unregisterDiffEditor] + ) + const navigationValue = useMemo( + () => ({ + goToPreviousDiff, + goToNextDiff, + changeCount + }), + [goToPreviousDiff, goToNextDiff, changeCount] + ) + + return ( + + + {children} + + + ) +} + +export function useDiffEditorRegistration(): DiffEditorRegistrationContextValue { + return useContext(DiffEditorRegistrationContext) +} + +export function useDiffNavigation(): DiffNavigationContextValue { + return useContext(DiffNavigationContext) +} diff --git a/src/renderer/src/components/editor/editor-shortcuts.test.ts b/src/renderer/src/components/editor/editor-shortcuts.test.ts index daacea7f647..51c89878aef 100644 --- a/src/renderer/src/components/editor/editor-shortcuts.test.ts +++ b/src/renderer/src/components/editor/editor-shortcuts.test.ts @@ -17,7 +17,11 @@ vi.mock('@/store', () => ({ } })) -import { installEditorFindShortcut, installMonacoEditorFindShortcut } from './editor-shortcuts' +import { + installEditorFindShortcut, + installMonacoDiffChangeNavigationShortcut, + installMonacoEditorFindShortcut +} from './editor-shortcuts' type ShortcutFixture = { container: HTMLDivElement @@ -196,3 +200,85 @@ describe('installEditorFindShortcut', () => { dispose() }) }) + +describe('installMonacoDiffChangeNavigationShortcut', () => { + function createDiffNavigationFixture(): { + container: HTMLDivElement + dispose: () => void + input: HTMLTextAreaElement + goToDiff: ReturnType + onDownstreamKeyDown: ReturnType + } { + const container = document.createElement('div') + const input = document.createElement('textarea') + const goToDiff = vi.fn() + const onDownstreamKeyDown = vi.fn() + container.appendChild(input) + document.body.appendChild(container) + input.addEventListener('keydown', onDownstreamKeyDown) + + return { + container, + dispose: installMonacoDiffChangeNavigationShortcut({ + getContainerDomNode: () => container, + goToDiff + }), + input, + goToDiff, + onDownstreamKeyDown + } + } + + it.each([ + { key: 'F7', shiftKey: false, direction: 'next' }, + { key: 'F7', shiftKey: true, direction: 'previous' } + ] as const)('routes $key (shift=$shiftKey) to $direction', ({ key, shiftKey, direction }) => { + const fixture = createDiffNavigationFixture() + + const event = dispatchKeyDown(fixture.input, { key, code: key, shiftKey }) + + expect(event.defaultPrevented).toBe(true) + expect(fixture.goToDiff).toHaveBeenCalledWith(direction) + expect(fixture.onDownstreamKeyDown).not.toHaveBeenCalled() + fixture.dispose() + }) + + it('consumes repeats without navigating again', () => { + const fixture = createDiffNavigationFixture() + + const event = dispatchKeyDown(fixture.input, { + key: 'F7', + code: 'F7', + repeat: true + }) + + expect(event.defaultPrevented).toBe(true) + expect(fixture.goToDiff).not.toHaveBeenCalled() + expect(fixture.onDownstreamKeyDown).not.toHaveBeenCalled() + fixture.dispose() + }) + + it('honors custom bindings and removes the listener when disposed', () => { + shortcutState.keybindings = { 'editor.nextChange': ['Mod+G'] } + const fixture = createDiffNavigationFixture() + + const defaultEvent = dispatchKeyDown(fixture.input, { key: 'F7', code: 'F7' }) + const customEvent = dispatchKeyDown(fixture.input, { + key: 'g', + code: 'KeyG', + metaKey: true + }) + fixture.dispose() + const disposedEvent = dispatchKeyDown(fixture.input, { + key: 'g', + code: 'KeyG', + metaKey: true + }) + + expect(defaultEvent.defaultPrevented).toBe(false) + expect(customEvent.defaultPrevented).toBe(true) + expect(disposedEvent.defaultPrevented).toBe(false) + expect(fixture.goToDiff).toHaveBeenCalledTimes(1) + expect(fixture.goToDiff).toHaveBeenCalledWith('next') + }) +}) diff --git a/src/renderer/src/components/editor/editor-shortcuts.ts b/src/renderer/src/components/editor/editor-shortcuts.ts index 86a7e721226..825caf6f1ee 100644 --- a/src/renderer/src/components/editor/editor-shortcuts.ts +++ b/src/renderer/src/components/editor/editor-shortcuts.ts @@ -46,6 +46,39 @@ export function installEditorFindShortcut(target: HTMLElement, onFind: () => voi return () => target.removeEventListener('keydown', handleKeyDown, true) } +type MonacoDiffNavigationEditor = { + getContainerDomNode: () => HTMLElement + goToDiff: (target: 'next' | 'previous') => void +} + +export function installMonacoDiffChangeNavigationShortcut( + editor: MonacoDiffNavigationEditor +): () => void { + const handleKeyDown = (event: KeyboardEvent): void => { + let direction: 'next' | 'previous' | null = null + if (editorShortcutMatches('editor.nextChange', event)) { + direction = 'next' + } else if (editorShortcutMatches('editor.previousChange', event)) { + direction = 'previous' + } + if (!direction) { + return + } + // Why: capture-phase preventDefault/stopPropagation beats Monaco's built-in + // F7 accessible-review pane, like the find shortcut does for Cmd+F. + event.preventDefault() + event.stopPropagation() + // Consume matched repeats but navigate once per press (matches find shortcut). + if (!event.repeat) { + editor.goToDiff(direction) + } + } + + const target = editor.getContainerDomNode() + target.addEventListener('keydown', handleKeyDown, true) + return () => target.removeEventListener('keydown', handleKeyDown, true) +} + type MonacoFindShortcutEditor = { getAction: (id: string) => { run: () => void | Promise } | null getContainerDomNode: () => HTMLElement diff --git a/src/renderer/src/components/editor/file-editor-word-wrap-options.test.ts b/src/renderer/src/components/editor/file-editor-word-wrap-options.test.ts new file mode 100644 index 00000000000..c50d4389ab3 --- /dev/null +++ b/src/renderer/src/components/editor/file-editor-word-wrap-options.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { buildFileEditorWordWrapOptions } from './file-editor-word-wrap-options' + +describe('buildFileEditorWordWrapOptions', () => { + it('keeps wrapping enabled for existing profiles without the preference', () => { + expect(buildFileEditorWordWrapOptions(undefined)).toEqual({ wordWrap: 'on' }) + expect(buildFileEditorWordWrapOptions(true)).toEqual({ wordWrap: 'on' }) + }) + + it('disables wrapping for horizontal file-editor scrolling', () => { + expect(buildFileEditorWordWrapOptions(false)).toEqual({ wordWrap: 'off' }) + }) +}) diff --git a/src/renderer/src/components/editor/file-editor-word-wrap-options.ts b/src/renderer/src/components/editor/file-editor-word-wrap-options.ts new file mode 100644 index 00000000000..af9bedfb2ef --- /dev/null +++ b/src/renderer/src/components/editor/file-editor-word-wrap-options.ts @@ -0,0 +1,8 @@ +import type { editor } from 'monaco-editor' + +export function buildFileEditorWordWrapOptions( + editorWordWrap: boolean | undefined +): Pick { + // Why: profiles saved before this preference existed must retain Orca's previous wrapped default. + return { wordWrap: editorWordWrap === false ? 'off' : 'on' } +} diff --git a/src/renderer/src/components/editor/html-character-reference-decoder.ts b/src/renderer/src/components/editor/html-character-reference-decoder.ts new file mode 100644 index 00000000000..e8bd9d1689d --- /dev/null +++ b/src/renderer/src/components/editor/html-character-reference-decoder.ts @@ -0,0 +1,22 @@ +export type HtmlAttributeQuote = '"' | "'" | null + +export function decodeHtmlTextCharacterReferences(value: string): string { + // Why: a